Audit Proof User Access and Role Assignment

Build an audit-proof Oracle EPM Cloud access process by combining EPM Group reporting, Groovy automation, and OCI Audit for complete user and group assignment history.

ORACLE EPM CLOUD • SECURITY AUDIT

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.

FCCSOCI IAMGroovyCloud Shell
Suggested slug: audit-proof-user-access-role-assignment
Meta description: Build cleaner Oracle FCCS and OCI access-audit evidence with group-based security, Groovy REST reports, EPM-only group filtering, and a 30-day OCI Cloud Shell export backed by 365-day OCI Audit retention.

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.
OCI IAM Users ↓ OCI / IDCS Groups ↓ EPM Groups ↓ EPM Roles / Granular Access

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 dataMaximum report window
IDCS Group assignment / unassignment14 days
EPM Group assignment / unassignment120 days

Source: Oracle Group Assignment Audit Report v2 and Oracle audit retention update.

Anonymized Oracle EPM Group Assignment Audit Report with EPM and IDCS group rows
Anonymized example: one standard report can contain both EPM Group and IDCS Group rows.

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.

Anonymized EPM report showing one IDCS unassignment
Observed EPM output: only one IDCS unassignment row was returned for the test batch.
Anonymized OCI Audit report showing delete and group removal events
OCI Audit contained the underlying delete and remove-from-group events.
Important: I am not labeling this as a universal Oracle defect. It is an observed behavior from a real test case. It is enough, however, for me to avoid using the mixed EPM report as the sole evidence source for OCI/IDCS user-lifecycle activity.

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

  1. Create or reuse an EPM Named Connection named EPM_REST.
  2. The connection must point to the Oracle EPM environment and have authorization to run the required Access Control reports.
  3. The rule is stand-alone: no form context, no operation.grid, and no RTP are required.
  4. The script uses supported csvIterator and csvWriter methods to create the filtered Outbox file.

Oracle references: Connection API and EpmScript API.

The rule creates:

Role Assignment Audit Report v1 - <timestamp>.csv Group Assignment Audit Report v2 - <timestamp>.csv Group Assignment Audit EPM Groups - <timestamp>.csv Role Assignment Report for Groups v2 - <timestamp>.csv
Full Oracle EPM / FCCS Groovy script
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     ) } 
Production note: the Role Assignment Audit example uses a 14-day rolling window so the request remains inside the complete application-role audit window. If your control is granular-role-only, design a separate granular-role process with the appropriate retention window.

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.

Official Oracle OCI Console screenshot showing the Cloud Shell launch menu
Official Oracle OCI screenshot: open Cloud Shell from the Cloud Shell / Code Editor icon in the Console header. Source: Oracle Cloud Infrastructure documentation.

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.

Verify that the Python file is in the Cloud Shell home directory
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

Cloud Shell command
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:

admin.user.create.success admin.user.delete.success admin.group.add.member.success admin.group.remove.member.success

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.

Full OCI Cloud Shell Python script
#!/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.

Example Cloud Shell run summary
======================================================================== 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 IDDisplay / Observed NameActionGroupPerformed ByDate and Time UTC
cartereEmily CarterRemoved from GroupFCCS Viewerjohnsona2026-08-28 08:46:40 UTC
cartereEmily CarterRemoved from GroupPBCS Viewerjohnsona2026-08-28 08:46:40 UTC
cartereEmily CarterUser Deletedjohnsona2026-08-28 08:46:40 UTC
millerjJames MillerRemoved from GroupFCCS Viewerjohnsona2026-08-28 08:47:00 UTC
millerjJames MillerUser Deletedjohnsona2026-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

Audit-proof access is not one report. It is the right evidence from the right source.
EPM Groups → EPM audit evidence OCI Groups → OCI audit evidence Groovy → repeatable EPM export Cloud Shell + Python → repeatable OCI export

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

EPMXpert Advisory LLC Specialized Oracle EPM Cloud consulting, implementation, optimization and system administration.

Services | Applications | EPM Doctor | Blog | About | Contact

contact.us@epmxpert.com

Privacy Policy | Terms of Use

© 2026 EPMXpert Advisory LLC

EPMXpert Advisory LLC Specialized Oracle EPM Cloud consulting, implementation, optimization and system administration.

Services | Applications | EPM Doctor | Blog | About | Contact

contact.us@epmxpert.com

Privacy Policy | Terms of Use

© 2026 EPMXpert Advisory LLC