In a previous post, we explored how to correlate OCI IAM user capabilities with actual credential usage—answering a critical audit question:

Who can create credentials, and who actually did?

That foundational audit provides essential visibility into what is allowed versus what is configured. However, in a mature cloud security posture, visibility alone is not enough. Credentials that exist but are never rotated eventually become liabilities.

OCI provides powerful native mechanisms for credential security. OCI Cloud Guard can identify risky IAM configurations and suspicious behavior and when enabled, it includes out-of-the-box detector recipes that will flag stale API keys, Secrets and Auth Tokens. We have a blog that documented approaches for automatic credential rotation using scheduled automation with OCI Functions, which organizations employ to prevent long-lived credentials..

However, many organizations still face a practical challenge:
How do we clearly see who has credentials, how old they are, when they must be rotated, and how to prove that control to auditors—without immediately enforcing rotation everywhere?

This blog introduces an additional, complementary approach: a governance-focused credential audit that correlates IAM capabilities, actual credential usage, and credential age into a single, actionable report. This approach emphasizes visibility, prioritization, and auditability—helping teams understand what needs action, when, and why.

From Usage Visibility to Credential Hygiene

The initial capability-to-usage audit answers:

  • Which credential types are enabled for each user
  • Which credentials are actually configured

The next—and more operational—question is:

Are those credentials still within an acceptable rotation window?

The CIS OCI Foundations Benchmark recommends rotating API keys, Auth Tokens, and Customer Secret Keys at regular intervals (commonly 90 days). Meeting this guidance requires continuous monitoring of credential age, not just periodic audits of credential presence.

Monitoring Credential Age and Rotation Compliance

Building on the logic from the earlier script, the enhanced solution introduces age-based analysis:

  • Enumerates all users in an OCI Identity Domain
  • Identifies configured credentials for each enabled capability:
    • API keys
    • Auth tokens
    • SMTP credentials
    • Customer secret keys
    • DB credentials
    • OAuth2 client credentials
  • Extracts credential creation timestamps
  • Calculates:
    • Current age in days
    • Rotation due date based on a defined interval
  • Flags credentials that are nearing or exceeding rotation thresholds
  • Filters results to include only users with at least one configured credential, eliminating noise from unused capability assignments

The result is a sharply focused report that highlights only actionable items. This approach can be used both for reactive credential cleanup and for establishing continuous, proactive credential hygiene.

Sample Python 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 real-world use.

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"
SCIM_COUNT = 1
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",
}

EXPIRY_DAYS = 90
WARNING_DAYS = 75

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_credentials_expiry.csv"):
    idc = get_clients(domain_url)
    rows = []
    saw_unauth = set()

    for user in iter_all_users(idc):
        caps = get_caps(user)
        user_name = getattr(user, "user_name", "") or getattr(user, "userName", "")

        # Only report users who can use AND have credentials configured
        for key, enabled in caps.items():
            if not enabled:
                continue
            method_name = LIST_METHODS[key]
            credentials, err = get_credential_details(idc, method_name, user.id)
            if err == "UNAUTHORIZED":
                saw_unauth.add(key)
                continue
            if err or not credentials:
                continue
            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 None:
                        continue
                    if age_days >= 0:
                        rotation_due_date = (datetime.fromisoformat(created.replace("Z", "+00:00")) + timedelta(days=EXPIRY_DAYS)).strftime("%Y-%m-%d")
                        # Only output in ASCII
                        rows.append({
                            "domain_url": domain_url,
                            "user_name": user_name,
                            "credential_type": key,
                            "created": datetime.fromisoformat(created.replace("Z", "+00:00")).strftime("%Y-%m-%d"),
                            "days_old": age_days,
                            "rotation_due_date": rotation_due_date
                        })
    if write_csv and rows:
        fieldnames = ["domain_url", "user_name", "credential_type", "created", "days_old", "rotation_due_date"]
        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)} credentials to {csv_path}")
    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_credentials_expiry.csv")
    args = p.parse_args()
    audit_with_expiry(args.domain_url, write_csv=True, csv_path=args.csv)

Example Output (Filtered for Action)

user_namecredential_typecreateddays_oldrotation_due_datestatus
aliceapi_keys2025-08-10892025-11-08ROTATION NEEDED
bobauth_tokens2025-10-20122026-01-18ENABLED & CONFIGURED

Unlike generic exports, this report excludes users who merely have credential capabilities enabled but no credentials configured. This keeps rotation campaigns focused and operationally efficient.

Operationalizing the Insights

Once credential age is visible, the data becomes actionable. This report can be used to:

  • Send targeted notifications to users with upcoming rotation deadlines
  • Automatically open tickets for credentials nearing expiration
  • Support scheduled compliance checks and audit evidence generation

To turn this into an always-on control, the script can be deployed as an OCI Function and executed on a schedule. The function can proactively alert administrators—via email or notifications—when credentials approach or exceed rotation thresholds, well before they become audit findings.

Conclusion

Effective credential governance in OCI IAM requires more than knowing who can create credentials or who did create them. It also requires ensuring those credentials are continuously rotated and never silently age into risk. By combining usage audits with credential age monitoring, organizations gain a proactive, compliance-ready approach to credential hygiene—reducing exposure, improving audit outcomes, and strengthening overall cloud security posture. Used alongside Cloud Guard and automated rotation, this governance-focused approach helps close the gap between detection, enforcement, and audit-ready visibility.