PingOne (Ping Identity) Identity Security Posture Management (ISPM)
Console-only posture scanning for a PingOne environment: 25 weighted Management API checks in nine categories, run from the dashboard Posture tab and scored 0-100.
Overview
PasskeyBridge reads a PingOne environment through the PingOne Management API, runs 25 checks against its sign-on policies, users, applications, password policies, certificates and risk policies, and stores each one as a finding with a weight, a status and evidence. The dashboard renders a 0-100 posture score and the findings grouped by category. Every call the scan makes is a read.
It lives in the dashboard Posture tab, under the Ping Identity provider tab. Scan rows carry provider set to ping, which is also the value to filter on in the scans table.
Who can run it. Posture is an Enterprise tab: on the starter and pro plans the dashboard shows an upgrade panel in place of the tab. The plan gate is in the dashboard only. The shield-ping-ispm function enforces something different: the caller must present a valid dashboard session and be an admin member of the tenant in shield_tenant_members.
Console-only. shield-ping-ispm is not in the Cloudflare worker's PUBLIC_FUNCTIONS list, so it has no https://api.passkeybridge.io/v1 route and no API key lane. The dashboard calls it directly with supabase.functions.invoke and your session JWT.
Worker application and dashboard fields
In PingOne. Create a worker application in the environment you want scanned, note its client ID and secret, and give it a role assignment that allows reading the environment's configuration and identity data. The function requests a token with the client_credentials grant and sends no scope parameter, so access is decided entirely by the roles on the worker application.
The region you pick selects both hosts.
| Region | Token host | API host |
|---|---|---|
| NA | https://auth.pingone.com | https://api.pingone.com/v1 |
| EU | https://auth.pingone.eu | https://api.pingone.eu/v1 |
| AP | https://auth.pingone.asia | https://api.pingone.asia/v1 |
| CA | https://auth.pingone.ca | https://api.pingone.ca/v1 |
The token comes from POST {token host}/{environment id}/as/token. The scan then prefetches eleven collections under /environments/{environment id}/ and reuses them: signOnPolicies and each policy's actions, users, groups, populations, applications, passwordPolicies, certificates, identityProviders, riskPolicySets, schemas and branding/themes. Lists ask for 100 per page and follow the HAL _links.next.href link for up to 20 pages or 2,000 items, with themes capped at 5 pages.
A collection the worker application cannot read turns its checks into skipped findings whose evidence carries the HTTP status and the first 300 characters of PingOne's error body, and skipped checks leave the score alone.
In the dashboard. Posture tab, Ping Identity provider tab, expand Ping Identity configuration.
| Field | Goes to | Stored as |
|---|---|---|
| Environment ID | ping_environment_id on shield_tenants | Clear text. Must be a UUID. |
| Region | ping_region on shield_tenants | Clear text. One of NA, EU, AP or CA. |
| Client ID | ping_client_id on shield_tenants | Clear text. |
| Client secret | ping_client_secret_encrypted on shield_tenants | AES-256-GCM under the server-held SHIELD_ENCRYPTION_KEY. |
Press Save. The environment ID, client ID and client secret are required on every save.
{ "action": "save_ping_config", "tenant_id": "<tenant uuid>", "ping_environment_id": "<environment uuid>", "ping_client_id": "<client uuid>", "ping_client_secret": "<secret>", "ping_region": "NA" }A successful save answers {"ok": true} and writes a save_ping_config audit row.
| Status | Body |
|---|---|
| 400 | ping_environment_id, ping_client_id, and ping_client_secret are all required |
| 400 | Invalid PingOne Environment ID format (expected UUID) |
| 400 | Invalid region: X. Valid: NA, EU, AP, CA |
| 500 | Failed to save provider configuration |
Checks by category
25 checks run on every scan, in nine categories.
| Category | Checks | What they read |
|---|---|---|
| authentication | 6 | Sign-on policy coverage, the share of login policies carrying an MFA action, FIDO2 or security key in those MFA actions, policy sprawl above 15, identifier-first flows, and whether MFA actions reference a device authentication policy. |
| applications | 5 | Application inventory by protocol, disabled applications, admin-privileged applications, sign-on policy assignment coverage, and SAML assertion signing configuration. |
| identity_store | 4 | User count, the share of disabled users, population segmentation, and schemas with extended attributes. |
| credential_management | 3 | Password policies shorter than 12 characters or without complexity, missing lockout thresholds, and password history below 5. |
| certificates | 2 | Certificates already expired or expiring within 30 days, and the certificate inventory. |
| governance | 2 | Progressive profiling or consent actions, and whether a branding theme carries a logo. |
| authorization | 1 | Group count, passing at 3 or more. |
| federation | 1 | External identity providers configured. |
| risk | 1 | Whether any PingOne Protect risk policy set exists. |
There is no admin-role check. The module calls no roles API. Admin-Privileged Application Sprawl is a heuristic over the application list: it counts applications whose access control role type is ADMIN_USERS_ONLY or whose name contains "admin", passing at 2 or fewer and failing above 5. The finding says so in its description.
MFA coverage is per policy. The MFA check compares the number of sign-on policies that contain an MFA action against those that contain a LOGIN action. It passes only at 100 percent, warns at 50 or more, and fails below that. If no policy actions could be fetched at all, the check is skipped instead of failing.
Posture score
The score is a penalty ratio over the checks that actually ran. Every check carries a hard-coded weight and a score_impact: zero when it passes, a negative number when it fails or warns. The function computes, over all non-skipped checks, 100 x (1 - total_penalty / max_penalty), where total_penalty is the sum of the absolute score_impact values and max_penalty is the sum of weight x 2. It rounds it and clamps it to 0-100. Weights run from 1 to 10. Ping's heaviest checks are sign-on policy coverage and MFA enforcement at 10 each, then password policy strength at 7.
There are no severity multipliers. severity (critical, high, medium, low, info) is a label on the finding row and has no effect on the arithmetic.
Skipped checks leave both sides of the ratio, so a check that could not run neither helps nor hurts. If every check is skipped the score is 0.
The tab turns the number into one of four labels.
| Score | Label in the tab |
|---|---|
| 80-100 | Strong |
| 60-79 | Moderate |
| 40-59 | Needs work |
| 0-39 | Critical |
Credential and result storage
Credentials. Only the client secret is encrypted, with AES-256-GCM under SHIELD_ENCRYPTION_KEY, and it is decrypted inside the function for the length of one scan. Encryption is mandatory: a save fails rather than storing plaintext if the key is missing. The environment ID, client ID and region stay in clear text because the token request needs them, and a migration revokes column-level SELECT on ping_client_secret_encrypted from the authenticated and anon roles.
Findings. Evidence holds counts, percentages and object names, for example the names of weak password policies, the names of applications flagged as admin-level, or certificate expiry counts. PingOne user records are counted inside the function and are not stored.
Scan rows land in shield_posture_scans and one finding row per check in shield_posture_findings. Both tables are tenant-admin read-only under row-level security: insert, update and delete are denied to the authenticated role, and the function writes them with its service-role client.
Audit trail. A completed scan writes a run_ping_posture_scan row to shield_audit_log with the provider, score, check counts and scan id. A failed scan writes the same verb with result set to failure.
Running a scan and retrying failures
Scans run on demand. No cron job calls any ISPM function, so a scan happens when someone presses a button in the Posture tab. Open the tab, select Ping Identity, and press Run scan in the tab header. The button is disabled while a scan is in flight and the tab reloads the scan list when it finishes.
The button invokes the function with your signed-in session.
const { data, error } = await supabase.functions.invoke("shield-ping-ispm", {
body: { action: "run_scan", tenant_id: "<tenant uuid>" },
});A completed scan answers with the summary the tab renders.
{
"scan_id": "7c1f0b2e-...",
"parent_scan_id": null,
"posture_score": 68,
"checks_total": 25,
"checks_passed": 14,
"checks_failed": 5,
"checks_warning": 4,
"checks_skipped": 2,
"duration_ms": 8421,
"partial_failures": []
}Retry failed checks appears in the Findings header once a completed scan has at least one failed or warning check. It creates a second scan that re-runs only the failed and warning check ids and copies the passed and skipped findings forward, so a skipped check changes only on a full Run scan.
{ "action": "retry_failed_checks", "tenant_id": "<tenant uuid>", "retry_of_scan_id": "<scan uuid>" }| Status | Body | Cause |
|---|---|---|
| 401 | Missing authorization header or Unauthorized | No session JWT, or it has expired. |
| 400 | Invalid JSON body | The body did not parse as JSON. |
| 400 | tenant_id required | tenant_id was missing from the body. |
| 403 | Forbidden: tenant admin access required | The signed-in user is not an admin member of that tenant. |
| 500 | Failed to verify tenant access | The membership lookup itself failed. |
| 400 | Unknown action | action was not a save action, run_scan or retry_failed_checks. |
| 400 | PingOne configuration not found. Configure Environment ID, Client ID, and Client Secret first. | No credentials are stored for this tenant. |
| 400 | PingOne authentication failed: ... | PingOne rejected the client credentials. The status and body are included. |
| 400 | retry_of_scan_id required | Retry was called without a parent scan id. |
| 404 | parent scan not found | No scan row has that id. |
| 403 | parent scan does not belong to this tenant/provider | The parent scan belongs to another tenant or another provider. |
| 400 | no failed or warning checks to retry | Every check in the parent scan passed or was skipped. |
| 500 | Scan failed: ... | The scan threw. The scan row is stored with status failed and the thrown message. |
Confirm it worked. The score card shows the band, the score out of 100, and the Passed, Failed and Warnings tiles. The card under it shows Scan, Duration, Started and Type, where Type reads Full scan or Retry, N checks. Findings are grouped by category below; expanding one shows its description, Remediation, Evidence JSON, and a line reading Weight, impact and check with that check's numbers and id. The Alerts section in the same tab can post a completed scan to a Slack webhook or to email recipients once its failed-check threshold is met.
Related from the blog
- Anatomy of an A2A Handshake: How Two AI Agents Establish Trust Without a Shared Secretsecurity · 13 min read
- Roaming Steering and Identity: When the HPLMN You Trust Hands You to a VPLMN You Don'tsecurity · 20 min read
- Network Slicing in 5G Standalone and the Birth of Per-Slice Identity Posturentn · 15 min read