Audit-Proof User Access and Role Assignment in Oracle EPM Cloud
A practical Oracle FCCS + OCI security-audit pattern using EPM Groups, Groovy REST, and OCI Cloud Shell.
Why this matters
A security model can be technically correct and still be painful to audit. The problem usually starts when access is assigned directly to individual users. Over time, direct assignments create a security model that is difficult to review, difficult to explain, and difficult to prove during an audit.
My preferred pattern is simple:
- Create groups in OCI IAM / the Identity Domain for user lifecycle and identity-side membership.
- Create EPM Groups in Oracle EPM Cloud for application and granular-access alignment.
- Assign EPM roles and application access to groups instead of assigning them directly to users wherever possible.
- Audit each security layer from the system that owns the evidence.
The standard Group Assignment Audit Report is useful - but mixed
Oracle's Group Assignment Audit Report v2 can return both EPM Group and IDCS Group membership changes. Oracle added IDCS group audit information to the report in the 26.04 update. The report exposes the group type so the two sources can be distinguished.
| Audit data | Maximum report window |
|---|---|
| IDCS Group assignment / unassignment | 14 days |
| EPM Group assignment / unassignment | 120 days |
Source: Oracle Group Assignment Audit Report v2 and Oracle audit retention update.
A real-world issue we observed
During testing, several users were deleted from OCI IAM within a short period. OCI Audit recorded the corresponding successful user-delete and remove-from-group events, including admin.user.delete.success and admin.group.remove.member.success.
However, the EPM Group Assignment Audit Report returned only a subset of the expected IDCS unassignment rows for that deletion batch. The same result was reproduced through EPM Automate, which ruled out the custom Groovy wrapper as the cause.
The audit-proof pattern: split the evidence
Stream 1 - Oracle EPM evidence
Use Oracle EPM Cloud for EPM Group assignment/unassignment history, role assignment audit, and the current role-to-group snapshot. For the Group Assignment Audit Report, generate the standard report and create a second audit file that keeps only Group Type = EPM Group.
Stream 2 - OCI identity evidence
Use OCI Audit directly for user creation, user deletion, adding a user to an OCI/IDCS group, and removing a user from an OCI/IDCS group. OCI Audit logs are retained for 365 days at the tenancy level. Oracle also documents that Identity Domain AuditEvents API access is limited to 14 days; older events should be accessed through OCI Audit.
Sources: OCI Audit log retention and Identity Domain audit retention.
Why I prefer Groovy over a local EPM Automate dependency for the EPM side
EPM Automate is fully supported and remains useful. For this pattern, I prefer an Oracle EPM Groovy business rule because the rule can run inside the EPM environment and use a secure Named Connection.
- No local EPM Automate installation is required for the report workflow.
- The rule can run as a stand-alone business rule or from an administrative process.
- Output files are written directly to EPM Inbox/Outbox.
- Credentials stay in a Named Connection instead of being hard-coded.
- The same rule can create both the source report and the EPM-only filtered evidence file.
FCCS / Oracle EPM Groovy solution
Prerequisites
- Create or reuse an EPM Named Connection named
EPM_REST. - The connection must point to the Oracle EPM environment and have authorization to run the required Access Control reports.
- The rule is stand-alone: no form context, no
operation.grid, and no RTP are required. - The script uses supported
csvIteratorandcsvWritermethods to create the filtered Outbox file.
Oracle references: Connection API and EpmScript API.
The rule creates:
import oracle.epm.api.model.Connection import oracle.epm.api.model.rest.HttpResponse import oracle.epm.api.model.csv.CsvWriter import groovy.json.JsonSlurper import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.TimeZone /* * ===================================================================== * AUDIT-PROOF USER ACCESS AND ROLE ASSIGNMENT * ===================================================================== * * Oracle EPM Cloud / FCCS stand-alone Groovy business rule. * * Reports generated: * 1. Role Assignment Audit Report (v1) - 14-day window * 2. Group Assignment Audit Report (v2) - 120-day window * 3. EPM-only Group Assignment Audit Report - filtered from #2 * 4. Role Assignment Report for Groups (v2) - current role snapshot * * Why the EPM-only file? * Group Assignment Audit Report v2 can contain both EPM Group and * IDCS Group rows. For application-access evidence, this rule creates * a second CSV that keeps only rows where Group Type = EPM Group. * * Named Connection: * EPM_REST * * No RTP and no form context are required. * ===================================================================== */ /* ===================================================================== */ /* Configuration */ /* ===================================================================== */ final String CONNECTION_NAME = 'EPM_REST' /* * Application-role audit data is available for a shorter window than * granular-role audit data. Using 14 days keeps the Role Assignment * Audit Report request inside the fully supported application-role window. */ final int ROLE_ASSIGNMENT_AUDIT_DAYS = 14 /* * EPM Group Assignment Audit data can be requested for up to 120 days. * IDCS rows inside the same source report are limited by the IDCS window, * but the derived EPM-only file retains the full EPM Group window. */ final int GROUP_ASSIGNMENT_AUDIT_DAYS = 120 final int MAX_POLL_ATTEMPTS = 180 final long POLL_INTERVAL_MS = 2000L final String ROLE_ASSIGNMENT_AUDIT_PREFIX = 'Role Assignment Audit Report v1' final String GROUP_ASSIGNMENT_AUDIT_PREFIX = 'Group Assignment Audit Report v2' final String EPM_GROUP_ASSIGNMENT_AUDIT_PREFIX = 'Group Assignment Audit EPM Groups' final String ROLE_ASSIGNMENT_GROUPS_PREFIX = 'Role Assignment Report for Groups v2' /* ===================================================================== */ /* Helper methods */ /* ===================================================================== */ Map<String, Object> parseJsonObject(String responseBody) { String body = responseBody == null || responseBody.trim().isEmpty() ? '{}' : responseBody Object parsedObject = new JsonSlurper().parseText(body) if (!(parsedObject instanceof Map)) { throw new RuntimeException( 'The REST response is not a JSON object. Response: ' + body ) } return (Map<String, Object>) parsedObject } int getMigrationStatus(Map<String, Object> responsePayload) { Object statusObject = responsePayload.get('status') if (statusObject == null) { throw new RuntimeException( 'The REST response does not contain a status value. ' + 'Response: ' + responsePayload.toString() ) } return Integer.parseInt( statusObject.toString() ) } String getErrorDetails(Map<String, Object> responsePayload) { Object detailsObject = responsePayload.get('details') if (detailsObject != null) { return detailsObject.toString() } Object errorObject = responsePayload.get('error') if (errorObject != null) { return errorObject.toString() } Object messageObject = responsePayload.get('message') if (messageObject != null) { return messageObject.toString() } return responsePayload.toString() } String getJobStatusPath(Map<String, Object> responsePayload) { Object linksObject = responsePayload.get('links') if (!(linksObject instanceof Collection)) { return null } for (Object linkObject : (Collection<?>) linksObject) { if (!(linkObject instanceof Map)) { continue } Map<?, ?> link = (Map<?, ?>) linkObject Object relationObject = link.get('rel') String relation = relationObject == null ? '' : relationObject.toString() if (!relation.equalsIgnoreCase('Job Status')) { continue } Object hrefObject = link.get('href') String href = hrefObject == null ? '' : hrefObject.toString() if (href.trim().isEmpty()) { return null } int relativePathPosition = href.indexOf('/interop/') if (relativePathPosition >= 0) { return href.substring( relativePathPosition ) } return href } return null } String encodeFormFileName(String fileName) { /* * java.net.URLEncoder is intentionally avoided because some Planning * Groovy sandboxes restrict classes outside the supported allow-list. */ return fileName.replace( ' ', '%20' ) } String stringValue(Object value) { return value == null ? '' : value.toString() } void validateHttpResponse( HttpResponse<String> response, String requestName ) { int httpStatus = response.status if (httpStatus < 200 || httpStatus >= 300) { throw new RuntimeException( requestName + ' REST request failed. HTTP status: ' + httpStatus + '. Response: ' + ( response.body == null ? '' : response.body ) ) } } void waitForAsyncJob( Connection connection, String jobStatusPath, String reportName, int maximumPollAttempts, long pollIntervalMilliseconds ) { for ( int attempt = 1; attempt <= maximumPollAttempts; attempt++ ) { HttpResponse<String> statusResponse = connection .get(jobStatusPath) .header( 'Accept', 'application/json' ) .asString() validateHttpResponse( statusResponse, reportName + ' status check' ) Map<String, Object> statusPayload = parseJsonObject( statusResponse.body ) int jobStatus = getMigrationStatus( statusPayload ) if (jobStatus == 0) { println( reportName + ' completed successfully.' ) return } if (jobStatus != -1) { throw new RuntimeException( reportName + ' failed. REST status: ' + jobStatus + '. Details: ' + getErrorDetails(statusPayload) ) } println( reportName + ' is still running. Poll attempt: ' + attempt + ' of ' + maximumPollAttempts ) sleep( pollIntervalMilliseconds ) } throw new RuntimeException( reportName + ' did not complete within the polling limit. ' + 'Last Job Status URL: ' + jobStatusPath ) } void processAsyncReportResponse( Connection connection, HttpResponse<String> initialResponse, String reportName, int maximumPollAttempts, long pollIntervalMilliseconds ) { validateHttpResponse( initialResponse, reportName ) Map<String, Object> responsePayload = parseJsonObject( initialResponse.body ) int reportStatus = getMigrationStatus( responsePayload ) if (reportStatus == 0) { println( reportName + ' completed successfully.' ) return } if (reportStatus != -1) { throw new RuntimeException( reportName + ' failed to start. REST status: ' + reportStatus + '. Details: ' + getErrorDetails(responsePayload) ) } String jobStatusPath = getJobStatusPath( responsePayload ) if (jobStatusPath == null || jobStatusPath.trim().isEmpty()) { throw new RuntimeException( reportName + ' started asynchronously, but the REST response did not ' + 'contain a Job Status URL. Response: ' + ( initialResponse.body == null ? '' : initialResponse.body ) ) } println( reportName + ' started. Job Status URL: ' + jobStatusPath ) waitForAsyncJob( connection, jobStatusPath, reportName, maximumPollAttempts, pollIntervalMilliseconds ) } String getFromDate( Calendar currentUtcCalendar, int calendarDays, SimpleDateFormat apiDateFormat ) { Calendar fromCalendar = (Calendar) currentUtcCalendar.clone() fromCalendar.add( Calendar.DAY_OF_MONTH, -(calendarDays - 1) ) return apiDateFormat.format( fromCalendar.getTime() ) } /* ===================================================================== */ /* Main */ /* ===================================================================== */ try { /* ----------------------------------------------------------------- */ /* Named connection */ /* ----------------------------------------------------------------- */ if (!operation.application.hasConnection( CONNECTION_NAME )) { throw new RuntimeException( 'Named connection was not found: ' + CONNECTION_NAME ) } Connection connection = operation.application.getConnection( CONNECTION_NAME ) /* ----------------------------------------------------------------- */ /* UTC dates and output names */ /* ----------------------------------------------------------------- */ TimeZone utcTimeZone = TimeZone.getTimeZone('UTC') SimpleDateFormat apiDateFormat = new SimpleDateFormat('yyyy-MM-dd') apiDateFormat.setTimeZone( utcTimeZone ) SimpleDateFormat outputFileTimestampFormat = new SimpleDateFormat('yyyyMMdd_HHmmss_SSS') outputFileTimestampFormat.setTimeZone( utcTimeZone ) String outputFileTimestamp = outputFileTimestampFormat.format( new Date() ) Calendar currentUtcCalendar = Calendar.getInstance( utcTimeZone ) currentUtcCalendar.set( Calendar.HOUR_OF_DAY, 12 ) currentUtcCalendar.set( Calendar.MINUTE, 0 ) currentUtcCalendar.set( Calendar.SECOND, 0 ) currentUtcCalendar.set( Calendar.MILLISECOND, 0 ) String toDate = apiDateFormat.format( currentUtcCalendar.getTime() ) String roleAuditFromDate = getFromDate( currentUtcCalendar, ROLE_ASSIGNMENT_AUDIT_DAYS, apiDateFormat ) String groupAuditFromDate = getFromDate( currentUtcCalendar, GROUP_ASSIGNMENT_AUDIT_DAYS, apiDateFormat ) String roleAssignmentAuditFile = ROLE_ASSIGNMENT_AUDIT_PREFIX + ' - ' + outputFileTimestamp + '.csv' String groupAssignmentAuditFile = GROUP_ASSIGNMENT_AUDIT_PREFIX + ' - ' + outputFileTimestamp + '.csv' String epmGroupAssignmentAuditFile = EPM_GROUP_ASSIGNMENT_AUDIT_PREFIX + ' - ' + outputFileTimestamp + '.csv' String roleAssignmentGroupsFile = ROLE_ASSIGNMENT_GROUPS_PREFIX + ' - ' + outputFileTimestamp + '.csv' println '------------------------------------------------------------' println 'Audit-proof User Access and Role Assignment' println '------------------------------------------------------------' println 'Connection : ' + CONNECTION_NAME println 'Current UTC date : ' + toDate println 'Role Audit from : ' + roleAuditFromDate println 'Group Audit from : ' + groupAuditFromDate println 'Output timestamp : ' + outputFileTimestamp println '------------------------------------------------------------' /* ================================================================= */ /* 1. Role Assignment Audit Report (v1) */ /* ================================================================= */ println 'Starting Role Assignment Audit Report (v1)...' String roleAssignmentAuditRequestBody = 'from_date=' + roleAuditFromDate + '&to_date=' + toDate + '&filename=' + encodeFormFileName( roleAssignmentAuditFile ) HttpResponse<String> roleAssignmentAuditResponse = connection .post( '/interop/rest/security/v1/' + 'roleassignmentauditreport' ) .header( 'Content-Type', 'application/x-www-form-urlencoded' ) .header( 'Accept', 'application/json' ) .body( roleAssignmentAuditRequestBody ) .asString() processAsyncReportResponse( connection, roleAssignmentAuditResponse, 'Role Assignment Audit Report (v1)', MAX_POLL_ATTEMPTS, POLL_INTERVAL_MS ) /* ================================================================= */ /* 2. Group Assignment Audit Report (v2) - source */ /* ================================================================= */ println 'Starting Group Assignment Audit Report (v2)...' Map<String, Object> groupAssignmentAuditPayload = new LinkedHashMap<String, Object>() groupAssignmentAuditPayload.put( 'fileName', groupAssignmentAuditFile ) groupAssignmentAuditPayload.put( 'from_date', groupAuditFromDate ) groupAssignmentAuditPayload.put( 'to_date', toDate ) HttpResponse<String> groupAssignmentAuditResponse = connection .post( '/interop/rest/v2/reports/groupaudit' ) .header( 'Content-Type', 'application/json' ) .header( 'Accept', 'application/json' ) .body( json( groupAssignmentAuditPayload ) ) .asString() processAsyncReportResponse( connection, groupAssignmentAuditResponse, 'Group Assignment Audit Report (v2)', MAX_POLL_ATTEMPTS, POLL_INTERVAL_MS ) /* ================================================================= */ /* 3. Create EPM-only Group Assignment Audit CSV */ /* ================================================================= */ println 'Filtering Group Assignment Audit to Group Type = EPM Group...' int epmGroupRowCount = 0 boolean headerFound = false int groupTypeColumnIndex = -1 csvIterator( groupAssignmentAuditFile ).withCloseable { reader -> csvWriter( epmGroupAssignmentAuditFile ).withCloseable { CsvWriter writer -> reader.each { String[] row -> if (!headerFound) { for ( int columnIndex = 0; columnIndex < row.length; columnIndex++ ) { String headerValue = row[columnIndex] == null ? '' : row[columnIndex].trim() if (headerValue.equalsIgnoreCase( 'Group Type' )) { groupTypeColumnIndex = columnIndex break } } if (groupTypeColumnIndex < 0) { throw new RuntimeException( 'Group Type column was not found in ' + groupAssignmentAuditFile ) } writer.writeNext( row ) headerFound = true return } if (row.length <= groupTypeColumnIndex) { return } String groupTypeValue = row[groupTypeColumnIndex] == null ? '' : row[groupTypeColumnIndex].trim() if (groupTypeValue.equalsIgnoreCase( 'EPM Group' )) { writer.writeNext( row ) epmGroupRowCount++ } } } } if (!headerFound) { throw new RuntimeException( 'The Group Assignment Audit source file was empty: ' + groupAssignmentAuditFile ) } println( 'EPM-only Group Assignment rows written: ' + epmGroupRowCount ) /* ================================================================= */ /* 4. Role Assignment Report for Groups (v2) */ /* ================================================================= */ println 'Starting Role Assignment Report for Groups (v2)...' HttpResponse<String> roleAssignmentGroupsResponse = connection .get( '/interop/rest/security/v2/report/' + 'roleassignmentreport/group' ) .header( 'Accept', 'application/json' ) .asString() validateHttpResponse( roleAssignmentGroupsResponse, 'Role Assignment Report for Groups (v2)' ) Map<String, Object> roleAssignmentGroupsPayload = parseJsonObject( roleAssignmentGroupsResponse.body ) int roleAssignmentGroupsStatus = getMigrationStatus( roleAssignmentGroupsPayload ) if (roleAssignmentGroupsStatus != 0) { throw new RuntimeException( 'Role Assignment Report for Groups (v2) failed. ' + 'REST status: ' + roleAssignmentGroupsStatus + '. Details: ' + getErrorDetails( roleAssignmentGroupsPayload ) ) } Object groupsObject = roleAssignmentGroupsPayload.get( 'details' ) List<?> groups = new ArrayList<Object>() if (groupsObject instanceof List) { groups = (List<?>) groupsObject } csvWriter( roleAssignmentGroupsFile ).withCloseable { CsvWriter writer -> writer.writeNext( 'Group Name', 'Description', 'Group Type', 'Role Name', 'Role Type', 'Granted Through Group' ) for (Object groupObject : groups) { if (!(groupObject instanceof Map)) { continue } Map<?, ?> group = (Map<?, ?>) groupObject String groupName = stringValue( group.get('groupname') ) String groupDescription = stringValue( group.get('description') ) String groupType = stringValue( group.get('type') ) Object rolesObject = group.get('roles') if (!(rolesObject instanceof Collection) || ((Collection<?>) rolesObject).isEmpty()) { writer.writeNext( groupName, groupDescription, groupType, '', '', '' ) continue } for (Object roleObject : (Collection<?>) rolesObject) { if (!(roleObject instanceof Map)) { continue } Map<?, ?> role = (Map<?, ?>) roleObject writer.writeNext( groupName, groupDescription, groupType, stringValue( role.get('rolename') ), stringValue( role.get('roletype') ), stringValue( role.get('grantedthroughgroup') ) ) } } } println 'Role Assignment Report for Groups (v2) completed successfully.' /* ================================================================= */ /* Completion */ /* ================================================================= */ println '------------------------------------------------------------' println 'All EPM security audit outputs completed successfully.' println 'Files created in Inbox/Outbox:' println ' - ' + roleAssignmentAuditFile println ' - ' + groupAssignmentAuditFile println ' - ' + epmGroupAssignmentAuditFile println ' - ' + roleAssignmentGroupsFile println '------------------------------------------------------------' } catch (Exception exception) { String errorMessage = exception.message == null ? exception.getClass().getName() : exception.message println '------------------------------------------------------------' println 'EPM security audit generation failed.' println( exception.getClass().getName() + ': ' + errorMessage ) println '------------------------------------------------------------' throwVetoException( 'EPM security audit generation failed: ' + errorMessage ) } OCI side: 30-day user lifecycle audit from the 365-day Audit source
The OCI side is intentionally independent of FCCS. You do not need an OCI Function, API Gateway, VCN, or custom server to produce this report manually. OCI Cloud Shell provides a browser-based Linux shell with a pre-authenticated OCI CLI and persistent home storage.
Oracle documents Cloud Shell as available directly from the OCI Console, with 5 GB of persistent home-directory storage and file upload/download support. See Using Cloud Shell.

Upload the Python file once
Save the code as oci_iam_user_audit.py, open Cloud Shell, then use Cloud Shell menu → Upload. Because the Cloud Shell home directory is persistent between normal sessions, the file does not need to be uploaded every time.
ls -lh ~/oci_iam_user_audit.py If you run the script with python3, executable permission is not required. chmod +x is only needed if you want to run the script directly with ./oci_iam_user_audit.py.
Run the 30-day audit
python3 ~/oci_iam_user_audit.py --days 30 --domain "Default" The date window is rolling; there is no hard-coded start date to maintain. The script queries these OCI IAM events:
Why the script normalizes user identity
OCI audit events do not always represent a user with the same display value. One event can show a login ID while another shows a display name, and some delete-driven group-removal events can omit the referenced user name. The script therefore correlates by User Resource ID, User OCID, OCI-supplied name, and finally a conservative temporal fallback. If the source event does not contain enough metadata to safely resolve the user, the script does not guess; the row is retained and marked as unresolved.
#!/usr/bin/env python3 import argparse import csv import os import sys from collections import Counter from datetime import datetime, timedelta, timezone import oci # ============================================================== # OCI IAM USER / GROUP MEMBERSHIP AUDIT # ============================================================== # # Source: # OCI Audit via OCI Logging Search # # Default period: # Last 30 calendar dates through current UTC time # # Included events: # admin.user.create.success # admin.user.delete.success # admin.group.add.member.success # admin.group.remove.member.success # # User normalization: # # User Create/Delete: # adminResourceId # adminResourceOcid # adminResourceName # # Group Add/Remove: # adminRefResourceId # adminRefResourceOcid # adminRefResourceName # # Correlation priority: # # 1. User Resource ID # 2. User OCID # 3. OCI supplied user name # 4. Conservative temporal correlation # # Temporal correlation is ONLY used if OCI omitted all user # reference information and exactly one User Create/Delete event # from the same actor/domain exists within 3 seconds. # # Ambiguous rows are NOT guessed. They remain Unresolved. # ============================================================== EVENT_USER_CREATE = "admin.user.create.success" EVENT_USER_DELETE = "admin.user.delete.success" EVENT_GROUP_ADD = "admin.group.add.member.success" EVENT_GROUP_REMOVE = "admin.group.remove.member.success" EVENT_ACTIONS = { EVENT_USER_CREATE: "User Created", EVENT_USER_DELETE: "User Deleted", EVENT_GROUP_ADD: "Added to Group", EVENT_GROUP_REMOVE: "Removed from Group" } ALLOWED_EVENTS = set(EVENT_ACTIONS.keys()) USER_CRUD_EVENTS = {EVENT_USER_CREATE, EVENT_USER_DELETE} GROUP_MEMBERSHIP_EVENTS = {EVENT_GROUP_ADD, EVENT_GROUP_REMOVE} SEARCH_CHUNK_DAYS = 7 PAGE_SIZE = 1000 TEMPORAL_FALLBACK_SECONDS = 3 def as_dict(value): return value if isinstance(value, dict) else {} def string_value(value): if value is None: return "" return str(value).strip() def first_non_blank(*values): for value in values: text = string_value(value) if text: return text return "" def parse_datetime(value): if value is None: return None try: if isinstance(value, (int, float)): return datetime.fromtimestamp( float(value) / 1000.0, tz=timezone.utc ) text = str(value).strip() if text.isdigit(): return datetime.fromtimestamp( int(text) / 1000.0, tz=timezone.utc ) if text.endswith("Z"): parsed = datetime.fromisoformat(text[:-1] + "+00:00") else: parsed = datetime.fromisoformat(text) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed.astimezone(timezone.utc) except Exception: return None def datetime_to_output(value): parsed = value if isinstance(value, datetime) else parse_datetime(value) if parsed is None: return string_value(value) return parsed.astimezone(timezone.utc).strftime( "%Y-%m-%d %H:%M:%S UTC" ) def most_common_non_blank(counter): if not counter: return "" for value, _count in counter.most_common(): if string_value(value): return string_value(value) return "" def build_search_query(compartment_id): return f""" search "{compartment_id}/_Audit" | where data.additionalDetails.eventId = '{EVENT_USER_CREATE}' or data.additionalDetails.eventId = '{EVENT_USER_DELETE}' or data.additionalDetails.eventId = '{EVENT_GROUP_ADD}' or data.additionalDetails.eventId = '{EVENT_GROUP_REMOVE}' | sort by datetime asc """.strip() def search_window(client, search_query, window_start, window_end): records = [] details = oci.loggingsearch.models.SearchLogsDetails( time_start=window_start, time_end=window_end, search_query=search_query, is_return_field_info=False ) page = None page_number = 0 while True: page_number += 1 parameters = {"limit": PAGE_SIZE} if page: parameters["page"] = page response = client.search_logs(details, **parameters) results = [] if response.data is not None and response.data.results is not None: results = response.data.results print( f" Page {page_number}: " f"{len(results)} matching audit events" ) records.extend(results) page = response.headers.get("opc-next-page") if not page: break return records def make_user_key(domain, user_id, user_ocid, observed_name): domain_key = string_value(domain).lower() user_id = string_value(user_id) user_ocid = string_value(user_ocid) observed_name = string_value(observed_name) if user_id: return f"{domain_key}|id:{user_id}" if user_ocid: return f"{domain_key}|ocid:{user_ocid}" if observed_name: return f"{domain_key}|name:{observed_name.lower()}" return "" def normalize_search_result(search_result, domain_filter): raw = as_dict(search_result.data) log_content = as_dict(raw.get("logContent")) audit_data = as_dict(log_content.get("data")) additional = as_dict(audit_data.get("additionalDetails")) event_id = string_value(additional.get("eventId")) if event_id not in ALLOWED_EVENTS: return None domain = string_value(additional.get("domainDisplayName")) if domain_filter: if domain.lower() != domain_filter.lower(): return None actor_name = string_value(additional.get("actorName")) actor_display_name = string_value(additional.get("actorDisplayName")) performed_by = first_non_blank(actor_name, actor_display_name) target_name = string_value(additional.get("adminResourceName")) target_id = string_value(additional.get("adminResourceId")) target_ocid = string_value(additional.get("adminResourceOcid")) target_type = string_value(additional.get("adminResourceType")) ref_name = string_value(additional.get("adminRefResourceName")) ref_id = string_value(additional.get("adminRefResourceId")) ref_ocid = string_value(additional.get("adminRefResourceOcid")) ref_type = string_value(additional.get("adminRefResourceType")) event_dt = parse_datetime(raw.get("datetime")) event_dt_text = datetime_to_output( event_dt if event_dt is not None else raw.get("datetime") ) message = string_value(audit_data.get("message")) audit_record_id = string_value(log_content.get("id")) if not audit_record_id: audit_record_id = "|".join([ event_dt_text, domain, performed_by, event_id, target_id, ref_id, target_name, ref_name ]) action = EVENT_ACTIONS[event_id] user_id = "" user_ocid = "" observed_user_name = "" group_name = "" if event_id in USER_CRUD_EVENTS: user_id = target_id user_ocid = target_ocid observed_user_name = target_name else: if ref_type and ref_type.lower() != "user": return None user_id = ref_id user_ocid = ref_ocid observed_user_name = ref_name group_name = target_name user_key = make_user_key( domain, user_id, user_ocid, observed_user_name ) return { "auditRecordId": audit_record_id, "eventDateTime": event_dt, "dateTime": event_dt_text, "domain": domain, "performedBy": performed_by, "action": action, "eventId": event_id, "group": group_name, "userId": user_id, "userOcid": user_ocid, "observedUserName": observed_user_name, "userKey": user_key, "refType": ref_type, "message": message, "rawTargetName": target_name, "rawTargetId": target_id, "rawTargetOcid": target_ocid, "rawTargetType": target_type, "rawRefName": ref_name, "rawRefId": ref_id, "rawRefOcid": ref_ocid, "rawRefType": ref_type } def build_user_profiles(events): profiles = {} for event in events: key = event.get("userKey", "") if not key: continue if key not in profiles: profiles[key] = { "loginNames": Counter(), "relationshipNames": Counter(), "userIds": Counter(), "userOcids": Counter(), "domains": Counter() } profile = profiles[key] observed_name = string_value(event.get("observedUserName")) user_id = string_value(event.get("userId")) user_ocid = string_value(event.get("userOcid")) domain = string_value(event.get("domain")) if event.get("eventId") in USER_CRUD_EVENTS: if observed_name: profile["loginNames"][observed_name] += 1 else: if observed_name: profile["relationshipNames"][observed_name] += 1 if user_id: profile["userIds"][user_id] += 1 if user_ocid: profile["userOcids"][user_ocid] += 1 if domain: profile["domains"][domain] += 1 resolved_profiles = {} for key, profile in profiles.items(): login_id = most_common_non_blank(profile["loginNames"]) relationship_name = most_common_non_blank( profile["relationshipNames"] ) if login_id: login_source = "User create/delete event" elif relationship_name: login_id = relationship_name login_source = "Group membership name fallback" else: login_source = "Unresolved" display_name = relationship_name if not display_name: display_name = login_id all_names = set(profile["loginNames"].keys()) all_names.update(profile["relationshipNames"].keys()) aliases = sorted( name for name in all_names if name and name not in {login_id, display_name} ) resolved_profiles[key] = { "loginId": login_id, "displayName": display_name, "aliases": "; ".join(aliases), "loginSource": login_source, "userId": most_common_non_blank(profile["userIds"]), "userOcid": most_common_non_blank(profile["userOcids"]), "domain": most_common_non_blank(profile["domains"]) } return resolved_profiles def find_unique_temporal_user(event, crud_events): event_dt = event.get("eventDateTime") if event_dt is None: return None domain = string_value(event.get("domain")).lower() actor = string_value(event.get("performedBy")).lower() candidates = {} for candidate in crud_events: candidate_dt = candidate.get("eventDateTime") candidate_key = candidate.get("userKey", "") if candidate_dt is None or not candidate_key: continue if string_value(candidate.get("domain")).lower() != domain: continue if string_value(candidate.get("performedBy")).lower() != actor: continue delta_seconds = abs( (candidate_dt - event_dt).total_seconds() ) if delta_seconds <= TEMPORAL_FALLBACK_SECONDS: previous = candidates.get(candidate_key) if previous is None or delta_seconds < previous: candidates[candidate_key] = delta_seconds if len(candidates) != 1: return None candidate_key = next(iter(candidates.keys())) return ( candidate_key, candidates[candidate_key] ) def resolve_events(events, profiles): crud_events = [ event for event in events if event.get("eventId") in USER_CRUD_EVENTS ] for event in events: key = event.get("userKey", "") profile = profiles.get(key) if key else None resolution_method = "" data_quality = "" if profile: login_id = profile.get("loginId", "") display_name = profile.get("displayName", "") aliases = profile.get("aliases", "") user_id = first_non_blank( event.get("userId"), profile.get("userId") ) user_ocid = first_non_blank( event.get("userOcid"), profile.get("userOcid") ) if event.get("userId"): resolution_method = "User ID correlation" elif event.get("userOcid"): resolution_method = "User OCID correlation" elif event.get("observedUserName"): resolution_method = "Audit user name" else: resolution_method = "Profile correlation" if ( profile.get("loginSource") == "User create/delete event" ): data_quality = ( "Resolved - login confirmed by user event" ) else: data_quality = ( "Resolved - best OCI name available" ) else: login_id = string_value( event.get("observedUserName") ) display_name = login_id aliases = "" user_id = string_value(event.get("userId")) user_ocid = string_value(event.get("userOcid")) if login_id: resolution_method = "Audit user name only" data_quality = ( "Resolved - best OCI name available" ) elif event.get("eventId") in GROUP_MEMBERSHIP_EVENTS: temporal_match = find_unique_temporal_user( event, crud_events ) if temporal_match: matched_key, delta_seconds = temporal_match matched_profile = profiles.get(matched_key) if matched_profile: login_id = matched_profile.get("loginId", "") display_name = matched_profile.get( "displayName", "" ) aliases = matched_profile.get("aliases", "") user_id = matched_profile.get("userId", "") user_ocid = matched_profile.get("userOcid", "") resolution_method = ( "Unique temporal correlation " f"({delta_seconds:.3f}s)" ) data_quality = ( "Resolved - conservative temporal fallback" ) if not login_id: resolution_method = "Unresolved" data_quality = ( "Unresolved - OCI event contains " "no usable user reference" ) event["loginId"] = login_id event["displayName"] = display_name event["aliases"] = aliases event["resolvedUserId"] = user_id event["resolvedUserOcid"] = user_ocid event["resolutionMethod"] = resolution_method event["dataQuality"] = data_quality return events def main(): parser = argparse.ArgumentParser( description=( "Export OCI IAM user and group membership " "audit events to a normalized CSV." ) ) parser.add_argument( "--days", type=int, default=30, help=( "Number of calendar dates to retrieve. " "Default: 30" ) ) parser.add_argument( "--compartment-id", default=None, help=( "Compartment OCID containing the Identity Domain. " "Default: tenancy root compartment." ) ) parser.add_argument( "--domain", default=None, help=( "Optional Identity Domain display-name filter, " "for example: Default" ) ) parser.add_argument( "--region", default=None, help=( "Optional OCI region override, " "for example: us-phoenix-1" ) ) parser.add_argument( "--output", default=None, help="Optional output CSV file name." ) args = parser.parse_args() if args.days < 1 or args.days > 365: print( "ERROR: --days must be between 1 and 365.", file=sys.stderr ) sys.exit(1) config = oci.config.from_file() effective_region = first_non_blank( args.region, config.get("region"), os.environ.get("OCI_CLI_REGION"), os.environ.get("OCI_REGION") ) if effective_region: config["region"] = effective_region compartment_id = ( args.compartment_id if args.compartment_id else config["tenancy"] ) client = oci.loggingsearch.LogSearchClient(config) now_utc = datetime.now(timezone.utc) start_utc = ( now_utc - timedelta(days=args.days - 1) ).replace( hour=0, minute=0, second=0, microsecond=0 ) end_utc = now_utc if args.output: output_file = args.output else: timestamp = now_utc.strftime("%Y%m%d_%H%M%S") output_file = ( f"OCI_IAM_User_Group_Audit_" f"{args.days}D_" f"{timestamp}.csv" ) search_query = build_search_query(compartment_id) print("") print("=" * 72) print("OCI IAM USER / GROUP MEMBERSHIP AUDIT") print("=" * 72) print(f"Region : {effective_region}") print(f"Compartment : {compartment_id}") print( "Identity Domain : " f"{args.domain if args.domain else 'ALL'}" ) print(f"Period start UTC: {start_utc.isoformat()}") print(f"Period end UTC : {end_utc.isoformat()}") print(f"Calendar days : {args.days}") print(f"Chunk size : {SEARCH_CHUNK_DAYS} days") print(f"Output : {output_file}") print("=" * 72) raw_results = [] current_start = start_utc chunk_number = 0 while current_start < end_utc: chunk_number += 1 current_end = min( current_start + timedelta(days=SEARCH_CHUNK_DAYS), end_utc ) print("") print( f"Chunk {chunk_number}: " f"{current_start.isoformat()} " f"-> {current_end.isoformat()}" ) try: chunk_results = search_window( client, search_query, current_start, current_end ) except oci.exceptions.ServiceError as exc: print("") print("OCI SERVICE ERROR") print(f"Status : {exc.status}") print(f"Code : {exc.code}") print(f"Message: {exc.message}") print("") raise raw_results.extend(chunk_results) current_start = current_end print("") print( "Raw matching events received: " f"{len(raw_results)}" ) unique_events = {} for search_result in raw_results: event = normalize_search_result( search_result, args.domain ) if event is None: continue unique_events[event["auditRecordId"]] = event events = list(unique_events.values()) profiles = build_user_profiles(events) events = resolve_events(events, profiles) minimum_datetime = datetime.min.replace( tzinfo=timezone.utc ) events.sort( key=lambda event: ( event.get("eventDateTime") if event.get("eventDateTime") is not None else minimum_datetime, event.get("loginId", ""), event.get("group", ""), event.get("eventId", "") ) ) with open( output_file, "w", newline="", encoding="utf-8-sig" ) as csv_file: writer = csv.writer(csv_file) writer.writerow([ "Login ID", "Display / Observed Name", "Action", "Group", "Performed By", "Date and Time UTC", "Identity Domain", "OCI Event", "OCI User ID", "OCI User OCID", "Resolution Method", "Data Quality", "Other Observed Names", "Event Description", "Raw Target Name", "Raw Beneficiary Name", "Raw Beneficiary Type", "OCI Audit Record ID" ]) for event in events: writer.writerow([ event.get("loginId", ""), event.get("displayName", ""), event.get("action", ""), event.get("group", ""), event.get("performedBy", ""), event.get("dateTime", ""), event.get("domain", ""), event.get("eventId", ""), event.get("resolvedUserId", ""), event.get("resolvedUserOcid", ""), event.get("resolutionMethod", ""), event.get("dataQuality", ""), event.get("aliases", ""), event.get("message", ""), event.get("rawTargetName", ""), event.get("rawRefName", ""), event.get("rawRefType", ""), event.get("auditRecordId", "") ]) action_counts = Counter( event.get("action", "") for event in events ) quality_counts = Counter( event.get("dataQuality", "") for event in events ) unresolved = [ event for event in events if event.get("resolutionMethod") == "Unresolved" ] temporal = [ event for event in events if event.get( "resolutionMethod", "" ).startswith( "Unique temporal correlation" ) ] print("") print("=" * 72) print("REPORT COMPLETED") print("=" * 72) print(f"Final records : {len(events)}") print(f"User identity profiles : {len(profiles)}") print(f"Temporal fallback rows : {len(temporal)}") print(f"Unresolved user rows : {len(unresolved)}") print("") for action in sorted(action_counts): print( f"{action:30}: " f"{action_counts[action]}" ) print("") print("Data quality:") for quality in sorted(quality_counts): print( f" {quality:50}: " f"{quality_counts[quality]}" ) print("") print( f"CSV created : " f"{output_file}" ) if unresolved: print("") print( "WARNING: Some OCI audit events did not contain " "enough information for safe user attribution." ) print( "These rows were kept in the CSV and marked " "as Unresolved." ) print("=" * 72) if __name__ == "__main__": main() Download the finished OCI report
The script creates a file similar to OCI_IAM_User_Group_Audit_30D_20260828_162236.csv. In Cloud Shell use Cloud Shell menu → Download and enter the generated file name. Oracle documents that the Download dialog retrieves files from the Cloud Shell home directory.
======================================================================== OCI IAM USER / GROUP MEMBERSHIP AUDIT ======================================================================== Calendar days : 30 Chunk size : 7 days ... REPORT COMPLETED Final records : 77 Added to Group : 13 Removed from Group : 45 User Created : 4 User Deleted : 15 CSV created : OCI_IAM_User_Group_Audit_30D_20260828_162236.csv ======================================================================== Sample OCI audit output
| Login ID | Display / Observed Name | Action | Group | Performed By | Date and Time UTC |
|---|---|---|---|---|---|
| cartere | Emily Carter | Removed from Group | FCCS Viewer | johnsona | 2026-08-28 08:46:40 UTC |
| cartere | Emily Carter | Removed from Group | PBCS Viewer | johnsona | 2026-08-28 08:46:40 UTC |
| cartere | Emily Carter | User Deleted | johnsona | 2026-08-28 08:46:40 UTC | |
| millerj | James Miller | Removed from Group | FCCS Viewer | johnsona | 2026-08-28 08:47:00 UTC |
| millerj | James Miller | User Deleted | johnsona | 2026-08-28 08:47:00 UTC |
The full output also contains OCI Event, OCI User ID, OCI User OCID, Resolution Method, Data Quality, aliases, and OCI Audit Record ID.
What I would archive for an audit
Oracle EPM evidence:
- Group Assignment Audit source report
- EPM-only Group Assignment Audit derived report
- Role Assignment Audit Report
- Role Assignment Report for Groups current snapshot
OCI evidence:
- 30-day OCI IAM user/group audit CSV
- Cloud Shell execution evidence where required
- The Python script version used for the export
Runbook evidence: date range, execution timestamp, environment, script version, output filenames, and archive location.
Final takeaway
This separation avoids a mixed evidence file becoming the single point of failure for an access review, while keeping the process lightweight and based on supported Oracle capabilities.
Oracle references
- Group Assignment Audit Report v2 - REST API
- groupAssignmentAuditReport - EPM Automate
- IDCS Group Audit Information Included in the Group Assignment Audit Report - 26.04
- Audit Data Availability Through EPM Automate and REST APIs
- Role Assignment Report for Groups v2
- Oracle EPM Groovy Connection API
- Oracle EPM Groovy EpmScript / csvIterator / csvWriter
- OCI Audit Log Retention Period
- Identity Domain Audit Retention
- Using OCI Cloud Shell
