Skip to content

PAM Access Request Analysis Agent — Technical Guide

This guide walks through the design and implementation of an AI-powered access request (workflow) analysis agent built on the 12Port PAM platform. It covers the API surface you'll need, the design decisions that matter, and the patterns that make the agent reliable when running on a schedule in a production environment.

The agent retrieves workflow access requests from the vault, enriches them with context, submits them to OpenAI for risk scoring and anomaly detection, takes configurable automated actions (approve or reject), and produces a report. By the end of this guide you should have a clear picture of how to build something similar in Python, or extend this approach to other automated workflows on the 12Port platform.


Authentication and Session Management

Every API call to the 12Port vault uses a JWT bearer token (API Token). The cleanest approach is to configure a shared requests.Session once at startup and reuse it for the entire run:

session = requests.Session()
session.verify = False  # if using self-signed certificates
session.headers.update({
    "Authorization": f"Bearer {jwt_token}",
    "Content-Type": "application/json",
    "Accept": "application/json",
})

The vault exposes two distinct API surfaces that share the same base URL:

  • MCP endpoints (/api/mcp/...) — the machine-callable operations surface, described by a machine-readable spec at /.well-known/mcp-agent.json
  • Main REST API (/api/...) — the full platform API

Fetch the MCP spec at startup and index its endpoints by operationId. This gives you a dynamic endpoint reference that stays current as the platform is updated, eliminating the need to hardcode paths.

Note

For a more detailed guide focused solely on Credential Retrieval, please see our Building an AI Agent That Retrieves Credentials Securely from the 12Port Vault guide.


The OpenAI Key in the Vault

Storing the OpenAI API key in the vault as a credential, rather than in a .env file, is worth doing for the same reason you store any other privileged credential there: audit logging, access control, and optional workflow enforcement. The agent retrieves it at runtime using the MCP get-asset-credentials endpoint.

The retrieval path has two branches. If the asset has no workflow assigned, you get the credential directly with an HTTP 200. If it does have a workflow, the first call returns HTTP 500 with a message code:

  • MSG-00863 — no request has been submitted yet; submit one
  • MSG-00864 — a request is already pending; discover and poll it

Tip

The Agent user will need to be giving adequate permission to read the secured key from the asset stored in the Vault.

After submitting a request, you need to discover the action type before you can poll status. Probe the get-request-status/{action} endpoint with each possible action type value until you get one that returns status=Active for your asset ID. Save the action type and request ID to a .pending_request.json file immediately. If the agent is interrupted, you can resume polling on the next run rather than submitting a duplicate request.

Once the request reaches status=Open, call get-asset-credentials again to retrieve the credential. On exit, offer to call complete-request to close the workflow cleanly.


Fetching Access Requests

The auditor requests endpoint returns all requests visible to the authenticated user:

GET /api/request/auditor-requests

Key parameters:

Parameter Purpose
from Epoch milliseconds timestamp — returns requests created after this time
pgs Page size
pgp Page position (zero-based, increment by pgs)
s Sort — use created,desc
rqt Set to 1 to include total count

Calculate the from timestamp as now minus your lookback window in epoch milliseconds:

from_ts = int((datetime.now(UTC) - timedelta(days=lookback_days)).timestamp() * 1000)

The response is a dict, not a flat array. Access the results via body["data"]. Paginate by incrementing pgp until you receive fewer results than pgs.

Understanding Request Status Values

The vault uses four status values, and the distinction between them matters significantly when designing an action engine:

Status Meaning Valid actions
Active Pending — awaiting approval Approve or reject
Approved Approved but not yet formally closed Close
Completed Formally closed Read-only
Rejected Rejected Read-only

One important note: Approved and Completed are not the same thing. A request moves to Approved when someone approves it. It only moves to Completed after it is explicitly closed. Requests can sit in Approved state for a long time (workflow expiration); this is the stale approved request scenario the agent handles.


Enriching Requests Before Analysis

The raw request data from the API gives you the asset name, requestor identity, timestamps, justification message, and status. Before sending to OpenAI, it's worth computing several derived signals client-side:

Requestor frequency — count how many requests each user has submitted across the entire fetched dataset. A user with 40 requests in a 3-day window is a different signal than one with 2. Build this frequency map across all requests first, then attach the count to each individual request.

Off-hours flag — parse the created timestamp and check whether the hour falls outside your configured business hours window (stored as UTC hours). Compute this in Python rather than asking the AI model to reason about timezones. The model should receive a pre-computed boolean, not a raw timestamp and an instruction to judge it.

Days since created / days since approved — useful for detecting stale pending requests and stale approved requests respectively.

Deep enrichment — if you want richer analysis context, fetch the full asset record at GET /api/asset/asset/{asset-id}. This gives you the asset type, vault container path, and taxonomy tags. With these fields, the model can confirm that an asset tagged Component :: Server :: Windows in a Domain Controllers container is sensitive infrastructure rather than inferring it from the name alone. One of the potential tradeoffs with deep analysis is two additional API calls per request, which matters at scale.


Designing the Analysis Prompt

A few design decisions significantly affect the quality of the output:

Pass a slim payload. Don't send the full enriched request dict to OpenAI. Select only the fields the model needs for analysis, like enrichment fields, timestamps, justification message, entity name. Sending unnecessary fields wastes tokens and can dilute the model's attention.

Batch the requests. Don't send all requests in a single prompt. With 100+ requests and deep enrichment fields, the combined payload can approach context limits. Split into batches of 25 and merge results after all batches complete.

Inject the agent's own username. Extract the agent's username from the JWT token's ousr claim and pass it to the model as explicit context. Without this, the model sees a user with potentially hundreds of requests in the lookback window and flags it as anomalous, skewing the analysis for everyone else. With it, the model knows to treat that user as expected automated activity.

parts   = jwt_token.split(".")
padded  = parts[1] + "=" * (4 - len(parts[1]) % 4)
payload = json.loads(base64.b64decode(padded).decode("utf-8"))
agent_user = payload.get("ousr", "")

Ask for a per-request action comment. Rather than generating a static approval message from a config file, ask the model to produce a brief action-oriented comment for each request (e.g. one sentence, maximum 20 words) describing what it found. This comment is submitted as the reason when approving or rejecting, and appears in the vault audit log. It makes automated decisions legible to reviewers.

Validate the response. After parsing, normalize the risk field to lowercase and check it against {low, medium, high, critical}. Default to medium for any invalid value. If the model returns the comment field as a JSON object {"comment": "..."} rather than a plain string, unwrap it. These normalization steps make the pipeline resilient across model versions.


Building a Safe Action Engine

The action engine is where incorrect design has real consequences. A few principles worth following:

Gate on status in code, not just in configuration. Don't rely on a configuration flag to prevent approving a Completed request, but check the status explicitly and skip if it's wrong. The configuration flags (AUTO_APPROVE_LOW_RISK, etc.) control whether the action fires at all; the status check ensures it only fires on requests in the right state.

Check reject before approve. A request can't be both approved and rejected. Evaluate the reject condition first and use continue to skip the approve check if rejection fires.

Use the correct content type for approve and reject. This is a significant gotcha. The vault's approve and reject endpoints expect a plain UTF-8 string body, not a JSON object:

# Correct
response = session.put(url, data=comment.encode("utf-8"),
                       headers={"Content-Type": "text/plain"})

# Wrong — vault will display '{"comment": "..."}' as the reason text
response = session.put(url, json={"comment": comment})

This distinction only becomes visible when you look at the audit log in the vault UI. The request will succeed with HTTP 200 either way, the difference is whether the reason text is user friendly or shows raw JSON.

Use risk level comparison, not string comparison. Define a numeric order for risk levels and compare numerically. String comparison between "medium" and "high" is not reliable.

RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}

Scheduling and Preventing Duplicate Actions

The agent is designed to run on a schedule like every few hours, continuously. This creates a specific problem: the same request should not be approved or rejected twice.

The naive solution is to skip requests created before the last run timestamp. This is wrong for a subtle but important reason. A request created before the last run but skipped, because of a temporary anomaly flag, or because it didn't meet the risk threshold at the time, will be permanently excluded from future action consideration. An off-hours flag that later clears, or a threshold that is adjusted, should make that request eligible again.

The correct solution is to track which request IDs were actually actioned. Store these in a state file alongside the last run timestamp:

{
  "last_run": "2026-04-29T20:05:41.787668+00:00",
  "actioned_ids": ["471a8ae1-...", "eb834a97-..."]
}

On each run, skip a request only if its ID is in actioned_ids. Requests that were evaluated but not actioned, for any reason, remain eligible every run. The actioned_ids set grows over time and is merged on each save.

Crucially, keep analysis and action on separate windows. The analysis step should always use the full lookback window. This is what gives the model the historical context it needs for frequency analysis, pattern detection, and accurate risk scoring. Only the action step uses the actioned_ids filter.


Report Design

A per-run markdown report saved with a timestamp in the filename gives you a persistent record of every analysis cycle without requiring a database. Structure it with a summary first (total counts, risk breakdown, anomaly frequency table) followed by per-request detail sorted critical to low.

Include the AI-generated summary and action comment in the per-request section. When someone reviews a report days later and sees that a request was auto-approved, the comment tells them specifically what the agent found and not just that the agent ran and made a decision.

If deep analysis is enabled, include the asset type, container path, and taxonomy tags in the per-request detail. This is the context that justified the risk score and it should be visible in the record.


Email Alerting

The agent sends one HTML email per run utilizing the Microsoft Graph API. One email per run, regardless of how many trigger conditions fire, is designed to reduce inbox flooding and per-request noise.

Why Graph API for Email (Not Teams)

Microsoft Teams ChannelMessage.Send is only available as a delegated permission which requires a logged-in user context and cannot be used by a daemon service. Mail.Send is available as an application permission, making it the right choice for unattended agent scenarios. The client credentials flow (tenant ID + client ID + client secret) produces an OAuth token with no user interaction required.

Note

This was a design decision for this guide. Integration and alerting with Teams, Slack or other message platforms is achievable.

Azure App Registration Requirements

  1. Register an app in Azure Active Directory > App registrations
  2. Add Microsoft Graph > Application permissions > Mail.Send
  3. Grant admin consent for the tenant because application permissions require it
  4. Generate a client secret under Certificates & secrets
  5. Store the client secret in the PAM vault (Password field of a Secret or Windows Host asset)
  6. Note the tenant ID, client ID, and vault asset UUID

The tenant ID and client ID are not secrets and should therefore go in .env. The client secret is stored in the vault and retrieved at runtime using the same credential retrieval pattern as the OpenAI key.

Three Alert Triggers

Flag Default Behaviour
ALERT_ON_RUN_SUMMARY true Always include action counts in the email
ALERT_ON_HIGH_RISK true Include high/critical request detail if any found
ALERT_ON_ANOMALY true Include anomaly flag frequency table if any found

At least one trigger must be enabled for an email to be sent. With all three enabled, one email is sent per run containing whichever sections have content.

Email Sections

The email is assembled from these sections, each conditionally included:

Risk Summary — always rendered. Counts by risk level (Critical, High, Medium, Low) with color coding.

Action Summary — rendered when ALERT_ON_RUN_SUMMARY=true. Approved, rejected, closed, skipped, and error counts for the run.

Actions Taken This Run — always rendered when any action was taken. Per-action detail table showing asset name, requestor, risk score, and the AI-generated comment that was submitted to the vault as the approve/reject reason. This gives the recipient a complete record of every automated decision made this run including asset, who requested it, why it was actioned, and what was commented.

High / Critical Risk Requests — rendered when ALERT_ON_HIGH_RISK=true and findings exist. Table of up to 15 requests with asset name, requestor, AI summary, and anomaly flags.

Anomaly Flags — rendered when ALERT_ON_ANOMALY=true and findings exist. Frequency table of anomaly flag types across all flagged requests.

Template-Driven Design

The email's visual design including colors, fonts, layout, spacing, and section structure is fully controlled by prompts/email_template.html. Python injects data content into named placeholders:

{{RUN_META}}        — run timestamp, lookback days, request count
{{RISK_SUMMARY}}    — risk level table rows
{{ACTION_SUMMARY}}  — action count list items
{{ACTIONS_TAKEN}}   — per-action detail table rows
{{HIGH_RISK}}       — high/critical request table rows
{{ANOMALIES}}       — anomaly flag frequency rows
{{FOOTER_NOTE}}     — footer timestamp

To restyle the email simply edit email_template.html only. No code changes required.

Token Acquisition

The agent acquires a Graph API OAuth token at runtime using the client credentials flow:

response = requests.post(
    f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
    data={
        "grant_type":    "client_credentials",
        "client_id":     client_id,
        "client_secret": client_secret,
        "scope":         "https://graph.microsoft.com/.default",
    }
)
token = response.json()["access_token"]

The token is held in memory for the duration of the run and never written to disk. The sendMail call uses POST /users/{sender}/sendMail, not /me/sendMail, which only works with delegated permissions.

Multiple Recipients

ALERT_RECIPIENT_EMAILS accepts a comma-separated list of addresses. A distribution list address is the recommended production setup, one entry in .env, because the mail system handles fan-out, adding or removing recipients and requires no agent changes.

Required .env Keys

ALERT_TENANT_ID=          # Azure AD tenant ID (not a secret)
ALERT_CLIENT_ID=          # App registration client ID (not a secret)
ALERT_SECRET_ASSET_ID=    # UUID of vault asset storing the client secret
ALERT_SENDER_EMAIL=       # Licensed mailbox to send from
ALERT_RECIPIENT_EMAILS=   # Comma-separated recipient list

Key Configuration Parameters

Parameter Purpose Notes
REQUEST_LOOKBACK_DAYS Analysis window Always use the full window; don't narrow for scheduling
OFF_HOURS_START / OFF_HOURS_END Off-hours detection (UTC hours) Set to match your business hours
DEEP_ANALYSIS Asset enrichment per request 2 extra API calls per request; consider volume
OPENAI_ANALYSIS_BATCH_SIZE Requests per OpenAI call Reduce if hitting context limits
RISK_THRESHOLD_APPROVE Max risk for auto-approve Start conservative: low
RISK_THRESHOLD_REJECT Min risk for auto-reject Start conservative: critical
STALE_REQUEST_DAYS Stale close threshold Only applies to Approved status
AUTONOMOUS_MODE Suppress interactive prompts Required for scheduled/unattended runs

Common Pitfalls

Approve/reject comment format. The vault returns HTTP 200 whether you send a plain string or a JSON object; this is not an error. The difference only appears in the vault audit log UI where the raw JSON becomes the visible reason text. Always use Content-Type: text/plain with a plain string body.

Status confusion: Approved vs Completed. These are distinct states. A request is Approved after someone approves it. It is Completed after it is explicitly closed (manually or automatically via expiration). Stale close applies to Approved, not Completed.

Agent user frequency inflation. If the agent has been running for a month against the same vault, it may have hundreds of requests in the lookback window. Without injecting the agent username, the model will flag every other user as low frequency by comparison. Always extract and pass ousr from the JWT.

Off-hours miscalibration. All timestamp comparisons use UTC. If your team works US Eastern hours and you leave the default window as-is, requests submitted at 5 PM ET will be flagged as off-hours (they fall outside the UTC window). Calibrate OFF_HOURS_START and OFF_HOURS_END to your timezone before enabling automated actions.