In a recent post, we described how to correlate OCI IAM user capabilities with credential usage – answering vital audit questions like “Who can create credentials, and who actually did?” This foundational audit, detailed in [Auditing Usage of User Capabilities in an OCI IAM Domain], provides crucial compliance and hygiene insights by mapping what’s allowed vs. what’s in use. But the story doesn’t end there. Today’s most security-conscious organizations demand not just a point-in-time snapshot of credential usage, but continuous assurance that credentials aren’t aging into risk – with automated alerts before they become a liability. 

In this blog, we’ll show how to elevate your OCI IAM audits from a one-off usage review to a proactive, CIS-aligned credential hygiene monitoring solution. The goal: catch and rotate aging credentials before they violate policy or trigger an incident.

A customer wanted to see if there is a proactive way of knowing when the credentials for a user would expire? this blog helps address that

I extended my previous blog idea a liitte further [Auditing Usage of User Capabilities in an OCI IAM Domain], We list each user’s enabled credential capabilities and detect which credentials have actually been configured. That usage audit is a prerequisite for any higher-level security automation in OCI IAM, as it establishes the ground truth of who has what. Knowing that the user is using a credential is step one. The next step is ensuring those credentials stay fresh through timely rotation. The CIS OCI Foundations Benchmark recommends rotating API keys, auth tokens, and customer secret keys every 90 days.
In addition to these, at OCI IAM every user has  other credential types such as SMTP credentials, OAuth 2.0 client credentials, and database passwords. These are not explicitly covered by the CIS OCI Foundations Benchmark as of the latest version. We can always disable credentails that are not needed for each of the user, refere to this blog for the steps

However, it is still considered a best practice to monitor their age and flag them for review as part of a proactive credential hygiene process. For the purpose of this blog, we will assume that all credential types follow a 90-day rotation policy for consistency and simplicity.

Monitoring Credential Age:

OCI Cloud Guard has detectors for credentials older than 90 days, but those fire only once the threshold is passed – a reactive alert when a credential is already out of compliance. As the requirement was a proactive apporach we are tracking the age of the credentail, so you know when to rotate them before they violate policy.

Building on the logic of our previous post, we developed an enhanced Python script that does the following:

  • Audits all users in a given OCI Identity Domain for every enabled credential type.

  • Checks each enabled credential type (API keys, auth tokens, SMTP credentials, customer secret keys, DB passwords, OAuth2 client credentials) to see if any credentials are actually configured for that user.

  • Determines the age of each existing credential and computes how many days remain until it reaches the 90-day rotation limit (per CIS policy).

  • Filters the results to include only users who have at least one credential configured. (Users who have a capability enabled but no credentials created are omitted for clarity.)

  • Flags credentials nearing or exceeding the rotation threshold – so your team can get ahead of upcoming CIS non-compliance or audit surprises.

This solution leverages the Identity Domains API with the Oracle-specific SCIM extension for user capabilities. We reuse techniques from the prior script (efficient paging through all users, filtering on each credential type, and handling permissions). Here, however, the focus shifts to actionable, time-based reporting rather than just a static usage snapshot.

This approach I believe helps address the below needs:

  • Regulatory compliance: Satisfy CIS benchmark requirements for periodic credential rotation (e.g. rotate keys/tokens every 90 days).

  • Proactive compliance: Catch impending violations before they show up in risk scans or Cloud Guard alerts, avoiding last-minute scrambles.

  • Noise reduction: By filtering to only users with actual credentials, the report zeroes in on who actually requires rotation attention.

Here is the sample script:

Note: This is a sample script provided for educational and illustrative purposes only. It should be tested thoroughly in a non-production or sandbox environment before being used in any customer tenancy. Ensure appropriate error handling, logging, and access controls are in place when adapting this for use in real-world environments.

import argparse
import csv
import logging
import oci
from oci.exceptions import ServiceError
from datetime import datetime, timezone, timedelta

CONFIG_PATH = "~/.oci/config"
PROFILE_NAME = "DEFAULT"

EXPIRY_DAYS = 90
WARNING_DAYS = 75

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

LIST_METHODS = {
    "api_keys": "list_api_keys",
    "auth_tokens": "list_auth_tokens",
    "customer_secret_keys": "list_customer_secret_keys",
    "db_credentials": "list_user_db_credentials",
    "oauth2_client_credentials": "list_o_auth2_client_credentials",
    "smtp_credentials": "list_smtp_credentials",
}

USER_ATTRS = (
    "id,userName," +
    "urn:ietf:params:scim:schemas:oracle:idcs:extension:capabilities:User"
)

def get_clients(domain_url):
    config = oci.config.from_file(CONFIG_PATH, PROFILE_NAME)
    idc = oci.identity_domains.IdentityDomainsClient(config, domain_url)
    return idc

def iter_all_users(idc):
    pages = oci.pagination.list_call_get_all_results_generator(
        idc.list_users,
        "response",
        attributes=USER_ATTRS,
        count=200
    )
    for resp in pages:
        for u in getattr(resp.data, "resources", []) or []:
            yield u

def get_caps(user):
    caps = getattr(user, "urn_ietf_params_scim_schemas_oracle_idcs_extension_capabilities_user", None)
    def b(name): return bool(getattr(caps, name, False)) if caps else False
    return {
        "api_keys": b("can_use_api_keys"),
        "auth_tokens": b("can_use_auth_tokens"),
        "customer_secret_keys": b("can_use_customer_secret_keys"),
        "db_credentials": b("can_use_db_credentials"),
        "oauth2_client_credentials": b("can_use_o_auth2_client_credentials"),
        "smtp_credentials": b("can_use_smtp_credentials"),
    }

def get_credential_details(idc, method_name, user_id):
    try:
        method = getattr(idc, method_name)
    except AttributeError:
        return [], f"NO_METHOD:{method_name}"
    try:
        r = method(filter=f'user.value eq "{user_id}"', attributes="id,meta")
        resources = getattr(r.data, "resources", []) or []
        return resources, None
    except ServiceError as e:
        if e.status in (401, 403):
            return [], "UNAUTHORIZED"
        return [], f"ERROR:{e.status}"

def check_expiry(creation_date_str):
    if not creation_date_str:
        return None, None, None
    try:
        creation_date = datetime.fromisoformat(creation_date_str.replace('Z', '+00:00'))
        now = datetime.now(timezone.utc)
        age_days = (now - creation_date).days
        days_until_expiry = EXPIRY_DAYS - age_days
        needs_rotation = age_days >= WARNING_DAYS
        return age_days, days_until_expiry, needs_rotation
    except Exception as e:
        logging.warning(f"Error parsing date {creation_date_str}: {e}")
        return None, None, None

def audit_with_expiry(domain_url, write_csv=True, csv_path="user_capabilities_expiry_audit.csv"):
    idc = get_clients(domain_url)
    rows = []
    saw_unauth = set()
    expiry_warnings = []

    for user in iter_all_users(idc):
        caps = get_caps(user)
        user_name = getattr(user, "user_name", "") or getattr(user, "userName", "")
        result = {
            "user_name": user_name,
            "can_use_api_keys": caps["api_keys"],
            "can_use_auth_tokens": caps["auth_tokens"],
            "can_use_customer_secret_keys": caps["customer_secret_keys"],
            "can_use_db_credentials": caps["db_credentials"],
            "can_use_oauth2_client_credentials": caps["oauth2_client_credentials"],
            "can_use_smtp_credentials": caps["smtp_credentials"],
        }
        user_has_credential = False
        for key, enabled in caps.items():
            method_name = LIST_METHODS[key]
            count_col = f"{key}_count"
            status_col = f"{key}_status"
            age_col = f"{key}_oldest_age_days"
            expiry_col = f"{key}_days_until_expiry"
            if not enabled:
                result[count_col] = 0
                result[status_col] = "DISABLED"
                result[age_col] = "N/A"
                result[expiry_col] = "N/A"
                continue
            credentials, err = get_credential_details(idc, method_name, user.id)
            if err == "UNAUTHORIZED":
                result[count_col] = "UNAUTHORIZED"
                result[status_col] = "UNAUTHORIZED"
                result[age_col] = "UNAUTHORIZED"
                result[expiry_col] = "UNAUTHORIZED"
                saw_unauth.add(key)
                continue
            elif err:
                result[count_col] = err
                result[status_col] = err
                result[age_col] = err
                result[expiry_col] = err
                continue
            result[count_col] = len(credentials)
            if len(credentials) == 0:
                result[status_col] = "ENABLED, None CONFIGURED"
                result[age_col] = "N/A"
                result[expiry_col] = "N/A"
            else:
                user_has_credential = True
                oldest_age = None
                oldest_days_until_expiry = None
                needs_rotation = False
                for cred in credentials:
                    meta = getattr(cred, "meta", None)
                    if meta:
                        created = getattr(meta, "created", None)
                        age_days, days_until_expiry, rotation_needed = check_expiry(created)
                        if age_days is not None:
                            if oldest_age is None or age_days > oldest_age:
                                oldest_age = age_days
                                oldest_days_until_expiry = days_until_expiry
                            if rotation_needed:
                                needs_rotation = True
                                expiry_warnings.append({
                                    "user": user_name,
                                    "credential_type": key.replace("_", " ").title(),
                                    "age_days": age_days,
                                    "days_until_expiry": days_until_expiry
                                })
                result[age_col] = oldest_age if oldest_age is not None else "Unknown"
                result[expiry_col] = oldest_days_until_expiry if oldest_days_until_expiry is not None else "Unknown"
                # ASCII only
                if needs_rotation:
                    result[status_col] = f"ROTATION NEEDED ({oldest_age} days old)"
                else:
                    result[status_col] = "ENABLED & CONFIGURED"
        # Only append users who have at least one configured credential
        if user_has_credential:
            rows.append(result)
    if write_csv and rows:
        fieldnames = [
            "user_name",
            "can_use_api_keys", "api_keys_count", "api_keys_oldest_age_days", "api_keys_days_until_expiry", "api_keys_status",
            "can_use_auth_tokens", "auth_tokens_count", "auth_tokens_oldest_age_days", "auth_tokens_days_until_expiry", "auth_tokens_status",
            "can_use_customer_secret_keys", "customer_secret_keys_count", "customer_secret_keys_oldest_age_days", "customer_secret_keys_days_until_expiry", "customer_secret_keys_status",
            "can_use_db_credentials", "db_credentials_count", "db_credentials_oldest_age_days", "db_credentials_days_until_expiry", "db_credentials_status",
            "can_use_oauth2_client_credentials", "oauth2_client_credentials_count", "oauth2_client_credentials_oldest_age_days", "oauth2_client_credentials_days_until_expiry", "oauth2_client_credentials_status",
            "can_use_smtp_credentials", "smtp_credentials_count", "smtp_credentials_oldest_age_days", "smtp_credentials_days_until_expiry", "smtp_credentials_status",
        ]
        with open(csv_path, "w", newline="", encoding="utf-8") as f:
            w = csv.DictWriter(f, fieldnames=fieldnames)
            w.writeheader()
            w.writerows(rows)
        logging.info(f" Wrote {len(rows)} users to {csv_path}")
    if expiry_warnings:
        logging.warning(f"\n{'='*80}")
        logging.warning(f"ROTATION WARNINGS ({len(expiry_warnings)} credentials need attention)")
        logging.warning(f"{'='*80}\n")
        for warning in expiry_warnings:
            logging.warning(f"User: {warning['user']}")
            logging.warning(f"  Credential Type: {warning['credential_type']}")
            logging.warning(f"  Age: {warning['age_days']} days")
            logging.warning(f"  Days until {EXPIRY_DAYS}-day expiry: {warning['days_until_expiry']}")
            logging.warning(f"  Action: Rotate this credential per CIS best practices\n")
    else:
        logging.info("\nNo credentials require rotation at this time.")
    if saw_unauth:
        logging.error(f"UNAUTHORIZED for: {', '.join(sorted(saw_unauth))}")

if __name__ == "__main__":
    p = argparse.ArgumentParser()
    p.add_argument("--domain-url", required=True, help="Identity Domain base URL (e.g., https://idcs-xxxx.identity.oraclecloud.com)")
    p.add_argument("--csv", default="user_capabilities_expiry_audit.csv")
    args = p.parse_args()
    audit_with_expiry(args.domain_url, write_csv=True, csv_path=args.csv)