KRUTRIM EKAMAgent-identity control plane

Developer documentation

Provision agent identities, broker short-lived delegated tokens, and verify them offline. Base URL https://ekam.olakrutrim.com.

↓ Download the Postman collection

Introduction

Krutrim Ekam is an OAuth 2.1 / OIDC authorization server for agents and humans. An owner mints an agent from a blueprint; Ekam brokers a short-lived, audience-bound, scoped, delegated token; your AI gateway verifies it offline against the published JWKS. Revocation is instant via introspection.

Tokens are ES256 JWTs. Verifiers should validate iss, aud (RFC 8707), exp, signature (JWKS) and — for live revocation — call introspection.
Open beta. Sign in with any Google account — no invite required. Usage is metered; billing is off. Onboarding is human-rooted: a person signs in and creates agents; there is no anonymous agent signup. Agents and tools can read /llms.txt and /llms-full.txt to self-serve under a human's authority.

Create agents · human-rooted

Every agent is owned by a human — that root of authority is what makes an agent attributable and revocable, and what makes DPDP access/erasure reach a person's whole footprint. Ekam has no anonymous agent signup. The self-serve path:

  1. Sign in at /account with any Google account (open beta) or your org's IdP.
  2. Create a workspace and mint a workspace key (ekam_sk_…) — this key represents you, the owner.
  3. Create agents with that key via blueprints + agents, then broker tokens.
BASE=https://ekam.olakrutrim.com
# OWNER_KEY = your workspace key (ekam_sk_…) from /account after signing in.

# 1. Define a blueprint (scopes, audience, TTL)
curl -s $BASE/v1/blueprints -H "authorization: Bearer $OWNER_KEY" \
  -H 'content-type: application/json' \
  -d '{"name":"chat-bot","scopes":["models:invoke"],"allowedAudiences":["https://your-gateway.example"],"tokenTtlSeconds":900}'

# 2. Mint an agent from it (owned by you)
curl -s $BASE/v1/agents -H "authorization: Bearer $OWNER_KEY" \
  -H 'content-type: application/json' -d '{"blueprintId":"bp_…","name":"support-bot"}'

# 3. Broker a short-lived, scoped token for the agent
curl -s $BASE/oauth/token -H "authorization: Bearer $OWNER_KEY" \
  -H 'content-type: application/json' \
  -d '{"grant_type":"urn:ietf:params:oauth:grant-type:token-exchange","agent_id":"agt_…","resource":"https://your-gateway.example","scope":"models:invoke"}'
Agents fanning out sub-agents: an agent acts under its owner's authority and can delegate to sub-agents via RFC 8693 token-exchange — the act chain records agent → owner → human, so the human root is preserved end to end. Need higher scopes, your own tenant, or SSO/SCIM? Request a dedicated tenant.

Quickstart · with an owner key

From an owner API key to a verified token in five calls.

BASE=https://ekam.olakrutrim.com

# 1. Create a blueprint (scopes, audience, TTL) — owner key
curl -s $BASE/v1/blueprints -H "authorization: Bearer $OWNER_KEY" \
  -H 'content-type: application/json' -d '{
    "name": "chat-bot",
    "scopes": ["models:invoke"],
    "allowedAudiences": ["https://your-gateway.example"],
    "tokenTtlSeconds": 900
  }'

# 2. Mint an agent from the blueprint
curl -s $BASE/v1/agents -H "authorization: Bearer $OWNER_KEY" \
  -H 'content-type: application/json' -d '{"blueprintId":"bp_123","name":"support-bot"}'

# 3. Broker a token for the agent (resource-bound + scoped)
curl -s $BASE/oauth/token -H "authorization: Bearer $OWNER_KEY" \
  -H 'content-type: application/json' \
  -d '{"grant_type":"urn:ietf:params:oauth:grant-type:token-exchange","agent_id":"agt_123","resource":"https://your-gateway.example","scope":"models:invoke"}'
# -> { "access_token": "<ES256 JWT>", "token_type": "Bearer", "expires_in": 900 }

# 4. Use it against your gateway; the gateway verifies offline via JWKS.
# 5. Revoke instantly if needed:
curl -s -X POST $BASE/v1/agents/agt_123/revoke -H "authorization: Bearer $OWNER_KEY"

Core concepts

ObjectIdWhat it is
Tenantten_… / olaA customer. Holds the set of legal entities and verified email domains.
EntitystringA legal-entity boundary within a tenant (tenant-scoped, sourced from your directory/IdP — not a global enum).
Ownerown_…The human/org that owns agents and is billed. Authenticates with an Ekam API key.
Blueprintbp_…A template agents are minted from: scopes, allowed audiences, TTL, budget, classification, cost-center.
Agentagt_…A first-class, owned, revocable identity — not an API key.
TokenES256 JWTShort-lived, aud-bound, scoped, delegated (act = the owner). The thing your gateway verifies.

Organization vs entity — don't conflate them

Organization (tenant):   ola                      <- what /setup lists; one per company
  Entities (legal):      olacabs_india, ...       <- boundaries WITHIN the org
    Humans:              each carries ONE entity  <- the pill on My Apps
    Apps:                target entity / entities[] / orgWide

The organization (tenant) is the company; entities are legal-entity boundaries inside it. A human belongs to one entity (resolved from their login domain or SCIM); an app registration declares which entities may use it — one (entity), several (entities), or the whole org (orgWide). The My Apps launcher shows a person exactly the apps in their tenant that admit their entity. So an entity like olacabs_india never appears in the organizations list — it appears as the eligibility pill and on app records.

Authentication

Five credential types:

  • Admin bearer (EKAM_ADMIN_TOKEN) — platform administration (/v1/tenants, /v1/owners). Bootstrap + machine paths only — for people, prefer delegated roles.
  • Tenant-admin API key (ekam_ak_…) — a long-lived machine credential bound to one delegated admin in one tenant; works on every org-admin API, audited as that human (delegated admin).
  • Owner API key (ekam_sk_…) — owner-scoped operations: blueprints, agents, and the token broker.
  • Human session — a type:human token from SSO (Human SSO).
  • SCIM service token — per-tenant static bearer for the /scim/v2 lifecycle APIs, minted at POST /v1/tenants/:id/scim-token.
# Admin
curl $BASE/v1/owners -H "authorization: Bearer $EKAM_ADMIN_TOKEN"
# Owner
curl $BASE/v1/agents -H "authorization: Bearer $OWNER_KEY"

Rate limits

Ekam rate-limits per principal and returns a standard 429 Too Many Requests with a Retry-After header when a caller exceeds its budget — no latency is ever injected into your request. Limits are generous (default 100 req/s sustained per principal, burst 500) and set well above real usage, so a legitimate human or agent never trips them; only a runaway or abusive credential does, and it degrades only itself. Keyed per credential (or per IP when unauthenticated).

Every response carries X-RateLimit-Limit (req/s) and X-RateLimit-Remaining; a 429 additionally carries Retry-After (seconds) and X-RateLimit-Reset (unix seconds).

Good citizen behavior: on a 429, honor Retry-After and back off with jitter — do not retry aggressively.

Errors

Errors follow the OAuth shape with an HTTP status:

{ "error": "invalid_request", "error_description": "audience not allowed by blueprint" }
StatuserrorWhen
400invalid_requestmissing/invalid field
401invalid_tokenmissing/expired/revoked credential
403access_deniedscope/audience/entity not permitted
404not_foundunknown object

Discovery

GET/.well-known/oauth-authorization-server
GET/.well-known/openid-configuration
GET/.well-known/jwks.json
GET/.well-known/oauth-protected-resource
GET/healthz

Metadata per RFC 8414 / RFC 9728, the public JWKS for offline verification, and a health probe.

curl $BASE/.well-known/jwks.json
# { "keys": [ { "kty":"EC","crv":"P-256","kid":"ekam-2026-06","alg":"ES256", ... } ] }

Tenants · admin

POST/v1/tenants
GET/v1/tenants
curl $BASE/v1/tenants -H "authorization: Bearer $EKAM_ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{
    "id":"acme","name":"Acme Corp",
    "entities":["acme_in"],
    "domains":{"acme.com":"acme_in"}
  }'

Owners · admin

POST/v1/owners
GET/v1/owners
GET/v1/me

Create an owner within a tenant + entity; the response includes the one-time API key. /v1/me returns the caller's principal.

curl $BASE/v1/owners -H "authorization: Bearer $EKAM_ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{"name":"Payments team","tenantId":"ola","entity":"olaelectric_india"}'

Blueprints

POST/v1/blueprints
GET/v1/blueprints
FieldTypeNotes
namestringrequired
scopesstring[]e.g. ["models:invoke"]
allowedAudiencesstring[]RFC 8707 resources the agent may target
tokenTtlSecondsnumberdefault 900
budgetRefstring?gateway budget id
maxClassificationstring?highest sensitivity tier
costCenterstring?cost center charged

Agents

POST/v1/agents
GET/v1/agents
POST/v1/agents/:id/revoke

Mint an agent from a blueprint, list agents, and kill-switch one. Revocation propagates to introspection within seconds.

curl $BASE/v1/agents -H "authorization: Bearer $OWNER_KEY" \
  -H 'content-type: application/json' -d '{"blueprintId":"bp_123","name":"support-bot"}'

curl -X POST $BASE/v1/agents/agt_123/revoke -H "authorization: Bearer $OWNER_KEY"

Token broker

POST/oauth/token

Brokers a short-lived ES256 token for an agent, bound to one resource and a subset of the blueprint's scopes. The token carries act.sub = the owner (RFC 8693 delegation).

curl $BASE/oauth/token -H "authorization: Bearer $OWNER_KEY" \
  -H 'content-type: application/json' -d '{
    "grant_type":"urn:ietf:params:oauth:grant-type:token-exchange",
    "agent_id":"agt_123",
    "resource":"https://your-gateway.example",
    "scope":"models:invoke"
  }'
{ "access_token": "<ES256 JWT>", "token_type": "Bearer", "expires_in": 900, "scope": "models:invoke" }

Introspection

POST/oauth/introspect

RFC 7662. Returns { "active": false } the moment an agent or token is revoked — the live kill-switch.

curl $BASE/oauth/introspect -H 'content-type: application/json' -d '{"token":"<JWT>"}'
# { "active": true, "sub": "agt_123", "aud": "https://your-gateway.example", "scope": "models:invoke", "exp": ... }

Access requests (IGA)

POST/v1/access-requests
GET/v1/access-requests
POST/v1/access-requests/:id/approve
POST/v1/access-requests/:id/deny

Request → approve/deny → grant. On approval the agent is created (the grant) and linked back to the request.

curl $BASE/v1/access-requests -H "authorization: Bearer $OWNER_KEY" \
  -H 'content-type: application/json' -d '{"blueprintId":"bp_123","agentName":"support-bot","reason":"Q3 support automation"}'

JIT-PAM · one-time privileged elevation

POST/v1/elevation-requests
GET/v1/elevation-requests
POST/v1/elevation-requests/:id/approve
POST/v1/elevation-requests/:id/deny

Request scope beyond an agent's blueprint for a single task. An approver (admin or a workspace admin — separation of duties) approves; the broker then mints a single-use, short-TTL token that auto-expires when the window elapses. Zero standing privilege.

# 1) request elevation — scope BEYOND the blueprint, for one task
curl $BASE/v1/elevation-requests -H "authorization: Bearer $OWNER_KEY" -H 'content-type: application/json' \
  -d '{"agentId":"agt_123","resource":"https://your-gateway.example","scopes":["admin:write"],"reason":"rotate prod secret","ttlSeconds":300,"windowSeconds":600,"maxUses":1}'

# 2) an approver decides (admin token, or a workspace admin)
curl -X POST $BASE/v1/elevation-requests/elr_123/approve -H "authorization: Bearer $ADMIN_TOKEN"

# 3) redeem ONCE at the broker -> a single-use, auto-expiring elevated token
curl $BASE/oauth/token -H "authorization: Bearer $OWNER_KEY" -H 'content-type: application/json' \
  -d '{"grant_type":"urn:ietf:params:oauth:grant-type:token-exchange","agent_id":"agt_123","resource":"https://your-gateway.example","elevation_id":"elr_123"}'

The minted token carries an elevation claim (request_id + expires_at). A second redemption, a denied request, or an elapsed window are all rejected.

CAEP push · real-time revocation (SSF)

GET/.well-known/ssf-configuration
POST/ssf/streams
GET/ssf/streams
POST/ssf/streams/:id/verify
DELETE/ssf/streams/:id

Subscribe a gateway to pushed security events. When an agent is revoked, Ekam POSTs a signed Security Event Token (RFC 8417) to your endpoint carrying a CAEP session-revoked event — so you evict its tokens immediately, instead of waiting to poll introspection. Verify the SET against the JWKS.

# register a push receiver (defaults to the session-revoked event)
curl $BASE/ssf/streams -H "authorization: Bearer $OWNER_KEY" -H 'content-type: application/json' \
  -d '{"endpoint_url":"https://your-gateway.example/secevents"}'

# test connectivity — pushes a signed verification SET to your endpoint
curl -X POST $BASE/ssf/streams/ssf_123/verify -H "authorization: Bearer $OWNER_KEY"

SETs are application/secevent+jwt, ES256-signed (header typ: secevent+jwt), aud = your stream id. Delivery is RFC 8935 push.

Birthright access · SCIM 2.0 + provisioning webhooks

POST/scim/v2/Users
PATCH/scim/v2/Users/:id
DELETE/scim/v2/Users/:id
GET/admin/birthright-policy
PUT/admin/birthright-policy
POST/admin/webhooks

Zero-click day-one access. Your HR/IdP (Workday, Google Workspace) pushes an identity over SCIM 2.0; Ekam maps its attributes to claims via a birthright policy, then fires a signed provisioning webhook so a downstream platform pre-grants the connector OAuth — no manual "Connect" dance. Ekam keeps only the policy-relevant attributes; it is not a system of record.

1) SCIM receiver (thin adapter, authenticated with a long-lived service token). The Ola extension carries the HR attributes that drive policy.

curl $BASE/scim/v2/Users -H "authorization: Bearer $SCIM_TOKEN" -H 'content-type: application/json' \
  -d '{
    "schemas":["urn:ietf:params:scim:schemas:core:2.0:User","urn:ietf:params:scim:schemas:extension:ola:2.0:User"],
    "userName":"ankit@olaelectric.com","displayName":"Ankit Sharma","active":true,
    "urn:ietf:params:scim:schemas:extension:ola:2.0:User":{
      "jobLevel":"L5","department":"platform-engineering","entity":"ola-electric","costCenter":"platform-eng"
    }
  }'

2) Birthright policy — a rule fires on match (all keys) or match_any (any key); scopes from all firing rules are unioned and max_classification takes the highest. Hot-updatable, no deploy:

curl -X PUT $BASE/admin/birthright-policy -H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"version":"1","mappings":[
    {"name":"engineering-lead","match":{"department":["platform-engineering"],"jobLevel":["L5","L6","L7"]},
     "claims":{"max_classification":"bu-confidential","scope":["mcp:github.read","mcp:sheets.read"]}},
    {"name":"default","match":{},"claims":{"max_classification":"company-internal","scope":["mcp:slack.read"]}}
  ]}'

3) Provisioning webhook — register a receiver; Ekam POSTs identity.provisioned / identity.deactivated as JSON, signed with X-Ekam-Signature: sha256=<hmac> (HMAC-SHA256 of the body). The payload carries the computed scope + previous_scope so you can diff and add/revoke grants.

curl $BASE/admin/webhooks -H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"name":"connector-provisioner","url":"https://your-portal.olakrutrim.com/hooks/ekam",
       "events":["identity.provisioned","identity.deactivated"],"secret":"<hmac-shared-secret>"}'

Verify the signature: HMAC_SHA256(secret, rawBody) must equal the X-Ekam-Signature hex. Receiver URLs are pinned to the trusted suffix. Delivery is at-least-once (idempotency key = event_id); failed deliveries are logged at GET /admin/webhooks/:id/events and replayable via POST /admin/webhooks/:id/replay.

Group-scoped scopes (SCIM Groups)

POST/scim/v2/Groups
PATCH/scim/v2/Groups/:id
GET/scim/v2/Groups
DELETE/scim/v2/Groups/:id

A SCIM Group (Ola extension urn:ietf:params:scim:schemas:extension:ola:2.0:Group) carries scopes and an optional max_classification_override. A member's effective scope = birthright-policy scopes ∪ every group's scopes; max_classification is the highest across sources. Adding/removing a member or editing the group re-provisions the affected users (a fresh identity.provisioned with previous_scope) — so a manager extends a team's access by adding them to a group, no per-user policy change.

curl $BASE/scim/v2/Groups -H "authorization: Bearer $SCIM_TOKEN" -H 'content-type: application/json' \
  -d '{"displayName":"github-power-users",
       "urn:ietf:params:scim:schemas:extension:ola:2.0:Group":{"scopes":["mcp:github.read","mcp:github.metadata"]},
       "members":[{"value":"ankit@olaelectric.com"}]}'

The SCIM service token — the SoR credential

POST/v1/tenants/:id/scim-token

Your system of record (HRIS, people portal) authenticates to /scim/v2 with a per-tenant SCIM service token — a static, long-lived bearer purpose-built for this integration. An org admin mints it once; it is shown only once, and re-issuing rotates the previous token atomically. So the credential strategy is simple: store it in your secret store; to rotate, call this endpoint again and update the secret — no JWT minting, no expiry clock.

curl -X POST $BASE/v1/tenants/ola/scim-token -H "authorization: Bearer $ADMIN_TOKEN"
# → { "tenant":"ola", "scim_token":"…", "scim_base":"https://ekam.olakrutrim.com/scim/v2",
#     "note":"store this now — it is shown only once; re-issue rotates it" }

Leaver — deactivation

PATCH/scim/v2/Users/:id
PUT/scim/v2/Users/:id
DELETE/scim/v2/Users/:id

The L in Joiner-Mover-Leaver. Setting active:false (PATCH or PUT), or calling DELETE, soft-deactivates the identity — Ekam never hard-deletes. Deactivation fires the signed identity.deactivated webhook and propagates active:false to every SCIM-outbound target, so downstream apps drop the leaver in the same sweep.

# leaver: mark inactive (PatchOp or simplified body both work)
curl -X PATCH $BASE/scim/v2/Users/usr_123 -H "authorization: Bearer $SCIM_TOKEN" \
  -H 'content-type: application/json' -d '{"active": false}'
# or: curl -X DELETE $BASE/scim/v2/Users/usr_123 -H "authorization: Bearer $SCIM_TOKEN"
What deactivation does — and doesn't. It marks the identity inactive, fires identity.deactivated, and pushes active:false downstream. It does not by itself terminate the person's upstream SSO account — disable the account at the source IdP (e.g. Google Workspace) as part of the same leaver runbook.

Identity graph · canonical Person + DPDP rights

POST/v1/persons/resolve
GET/v1/persons/:id
POST/v1/persons/:id/merge
GET/v1/persons/:id/export
POST/v1/persons/:id/erase

One human holds many accounts — a Human (SSO) per email domain, an Owner, one or more SCIM identities across entities. Ekam links them into a single canonical Person via shared keys (employee id, personal email, phone, national id, corporate emails), so access and erasure act on the whole person, not a fragment. Linking is automatic on SSO sign-in and SCIM provisioning.

Resolve a person from any identifier (employeeId, email, personalEmail, phone, or nationalId):

curl $BASE/v1/persons/resolve -H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"employeeId":"E-12345"}'

DPDP §11 — right to access. Export everything Ekam holds about the person (the national id is returned redacted). The person themselves (a linked, signed-in human) or an admin may call it:

curl $BASE/v1/persons/per_123/export -H "authorization: Bearer $ADMIN_TOKEN"

DPDP §12 — right to erasure. Anonymize the Person and cascade: SCIM identities are deactivated (the deactivation webhook drives connector-grant revocation downstream) and human auth records are tombstoned.

curl -X POST $BASE/v1/persons/per_123/erase -H "authorization: Bearer $ADMIN_TOKEN"

National ids are stored only as a sha256 hash, never the raw value. Owners/agents are not person-scoped in this layer. See the DPDP compliance statement.

Human SSO

GET/auth/login
GET/auth/callback
POST/oauth/federate/google

Browser login: redirect a human to /auth/login → Google → /auth/callback mints a type:human token. Restricted to the tenant's verified domains. Programmatic federation exchanges a Google id_token directly.

curl $BASE/oauth/federate/google -H 'content-type: application/json' \
  -d '{"id_token":"<google id_token>","audience":"https://your-gateway.example","scope":"models:invoke"}'

App login + logout (OIDC RP)

Use Ekam as your app's login provider. This is the reverse of federation: here Ekam is the OIDC IdP and your app (travel.ola.in, an oauth2-proxy, any web app) is the relying party. Standard OIDC authorization-code + PKCE — any compliant OIDC library or oauth2-proxy works. Everything below is discoverable at $BASE/.well-known/openid-configuration ($BASE = https://ekam.olakrutrim.com).

POST/register
GET/authorize
POST/oauth/token
GET/userinfo
GET/logout

1 · Register your app (once)

Get a client_id. Ekam issues public PKCE clients (no secret to leak — PKCE is the proof); an unregistered redirect_uri is refused.

curl -X POST $BASE/register -H 'content-type: application/json' -d '{
  "client_name": "travel.ola.in",
  "redirect_uris": ["https://travel.ola.in/oauth2/callback"]
}'
# → { "client_id": "clt_…", "token_endpoint_auth_method": "none",
#     "grant_types": ["authorization_code"], "response_types": ["code"] }

2 · Sign in — redirect to /authorize

Send the browser to /authorize with a PKCE challenge. Add prompt=login to force a fresh login / account chooser — this is how a user your app later rejects can pick a different account instead of looping (see Human SSO).

# (one URL — shown wrapped)
$BASE/authorize?response_type=code
  &client_id=clt_…
  &redirect_uri=https://travel.ola.in/oauth2/callback
  &scope=openid%20email%20profile
  &code_challenge=<BASE64URL(SHA256(verifier))>
  &code_challenge_method=S256
  &state=<csrf>&nonce=<nonce>
  &prompt=login              # optional — force re-auth / account chooser

Ekam signs the human in via its own SSO, then redirects back to your redirect_uri with ?code=…&state=…. prompt=none does silent auth (returns error=login_required instead of a UI).

3 · Exchange the code for tokens

curl -X POST $BASE/oauth/token \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d grant_type=authorization_code \
  -d code=<code> -d code_verifier=<verifier> \
  -d client_id=clt_… -d redirect_uri=https://travel.ola.in/oauth2/callback
# → { "access_token": "…", "id_token": "…", "token_type": "Bearer", "expires_in": … }

The id_token is an ES256 JWT — verify it against the JWKS (iss=$BASE, aud=client_id, echoed nonce). It carries sub, email, email_verified, tenant, entity. A confidential client (oauth2-proxy with a secret) may authenticate via HTTP Basic — Ekam accepts client_secret_basic too.

4 · Who is this? — /userinfo

curl $BASE/userinfo -H 'authorization: Bearer <access_token>'
# → { "sub": "hum_…", "email": "…@olaelectric.com", "email_verified": true,
#     "tenant": "ola", "entity": "olaelectric_india" }

5 · Sign out — /logout (end_session_endpoint)

Don't skip this — dropping your app's own session is not enough. Ekam's ekam_session SSO cookie would silently re-sign-in the user on their next click. Point your app's Log out button at Ekam's end_session_endpoint so they truly sign out:

# clears the ekam_session SSO cookie, then returns the user to your app
$BASE/logout?rd=https://travel.ola.in/

rd (post-logout redirect) is validated to an Ola-owned https host (*.ola.in, *.olakrutrim.com, *.olaelectric.com, *.olacabs.com); anything else falls back to /.

oauth2-proxy — drop-in

The exact pattern developer.ola.in / people.ola.in / KCRH use — point oauth2-proxy at Ekam's OIDC discovery:

provider          = "oidc"
oidc_issuer_url   = "https://ekam.olakrutrim.com"
client_id         = "clt_…"
client_secret     = "…"                       # only if you registered a confidential client
redirect_url      = "https://travel.ola.in/oauth2/callback"
code_challenge_method = "S256"                 # PKCE
scope             = "openid email profile"
prompt            = "login"                     # force the account chooser — avoids the not-allowed-account loop
email_domains     = ["olaelectric.com", "olacabs.com", "olakrutrim.com"]   # authorize by domain
# Sign-out → https://ekam.olakrutrim.com/logout?rd=https://travel.ola.in/
Authorization vs. the loop. email_domains / allowed_groups decide who gets in; if a rejected user is stuck re-logging as the same account, the fix is prompt=login (above), not a config change. Note Ekam's tokens carry email + tenant + entity but no groups claim — gate on email_domains, not groups.

External IdP federation

POST/v1/tenants/:id/idps
GET/v1/tenants/:id/idps
DELETE/v1/idps/:id
POST/oauth/federate/oidc
POST/auth/saml/acs
GET/auth/saml/metadata
GET/auth/github/login

Ekam isn't Google-only. A tenant registers its own IdP — any standard OIDC provider (Okta, Microsoft Entra, Auth0, …), a classic SAML 2.0 IdP (ADFS, OneLogin, Shibboleth, …), or GitHub — and its people sign in with it. The registration is the trust + tenant binding: identities an IdP asserts become members of that tenant. Federated humans fold into the same Person graph, so DPDP export/erasure reach them too.

1 · Register an OIDC IdP (admin)

curl $BASE/v1/tenants/<tenant>/idps -H "authorization: Bearer $ADMIN" \
  -H 'content-type: application/json' -d '{
    "kind": "oidc",
    "name": "Acme Okta",
    "issuer": "https://acme.okta.com",
    "jwksUri": "https://acme.okta.com/oauth2/v1/keys",
    "audience": "ekam-client"
  }'

Ekam routes an incoming id_token to the registered IdP by its iss, verifies the signature against the provider's JWKS, pins aud when set, then mints a type:human Ekam token via POST /oauth/federate/oidc.

2 · SAML 2.0 (enterprise IdPs)

Many enterprise IdPs speak SAML, not OIDC: they issue a signed assertion, not an id_token. Register the IdP with its entityId + signing certificate, hand the IdP Ekam's SP metadata (/auth/saml/metadata), and the IdP POSTs its SAMLResponse to Ekam's ACS. Ekam validates the XML signature against the registered cert, then mints the type:human token.

curl $BASE/v1/tenants/<tenant>/idps -H "authorization: Bearer $ADMIN" \
  -H 'content-type: application/json' -d '{
    "kind": "saml",
    "name": "Acme SSO",
    "entityId": "https://idp.acme.com/saml/metadata",
    "ssoUrl": "https://idp.acme.com/sso/saml",
    "x509cert": "-----BEGIN CERTIFICATE-----\n…\n-----END CERTIFICATE-----"
  }'

# The IdP then POSTs its assertion to the ACS (browser form post):
#   POST $BASE/auth/saml/acs   (SAMLResponse=<base64>&RelayState=/console)
GitHub login is a browser flow: send a person to /auth/github/login → GitHub → /auth/github/callback. For OIDC and SAML alike, an unregistered issuer / entityID is refused with 403 idp_not_registered — there is no implicit trust.
Forcing a fresh login — avoid the "can't switch account" loop. Ekam honours the standard re-authentication signals. An OIDC relying party (e.g. oauth2-proxy) that sends prompt=login (or select_account) makes Ekam ignore the existing session and show the account chooser; prompt=none is silent auth (returns login_required instead of a UI). The SAML equivalents on the AuthnRequest are ForceAuthn="true" and IsPassive="true". If a user your app rejects (403) is stuck re-logging in as the same account, set prompt=login on your proxy — Ekam then lets them pick an allowed one instead of silently replaying the rejected session.

ID-JAG · cross-app delegation

Identity Assertion Authorization Grant (draft-ietf-oauth-identity-assertion-authz-grant). Issue an ID-JAG via token-exchange, then redeem it for an access token — preserving tenant + entity across apps.

# Issue (token-exchange)
curl $BASE/oauth/token -H 'content-type: application/json' -d '{
  "grant_type":"urn:ietf:params:oauth:grant-type:token-exchange",
  "requested_token_type":"urn:ietf:params:oauth:token-type:id-jag",
  "subject_token":"<self-issued token>",
  "audience":"https://app-b.example"
}'

# Redeem (jwt-bearer) at the target app's Ekam
curl $BASE/oauth/token -H 'content-type: application/json' -d '{
  "grant_type":"urn:ietf:params:oauth:grant-type:jwt-bearer",
  "assertion":"<id-jag>"
}'

Sign in with Microsoft / Entra · tid-gated

Let people sign in with their Microsoft / Entra (Azure AD) account. Identity is anchored on the immutable directory id tid + object id oidnever email (a token missing tid/oid is rejected 401). A tenant registers its Entra directory (its azureTid); a sign-in from that directory auto-joins the org, while every other directory lands in an isolated personal workspace — so a spoofed customer email can never merge into a real org identity.

Browser login

GET/auth/microsoft/login
GET/auth/microsoft/callback

Send a person to /auth/microsoft/login → Microsoft → /auth/microsoft/callback, which hands back a type:human Ekam token. The login endpoint returns 503 microsoft_login_unconfigured unless MS_CLIENT_ID + MS_CLIENT_SECRET are set; it sets an ekam_ms_state CSRF cookie and redirects to login.microsoftonline.com/{MS_TENANT}/oauth2/v2.0/authorize (MS_TENANT defaults to common) with scope=openid email profile and prompt=select_account.

Callback failureStatus / error
state cookie missing / mismatched (CSRF)400 bad_state
no code on the redirect400 missing_code
network failure reaching Microsoft502 upstream_unreachable (+ request_id)
Microsoft rejects the code exchange401 token_exchange_failed
id_token has no tid/oid, or (personal path) no verified email401 invalid_microsoft_token / email_unverified
The callback first verifies the id_token against the configured authority (common in open beta) to get a trustworthy tid/oid. If that tid is a registered Entra binding it re-verifies with the issuer pinned to the registered azureTid (single-tenant) and domain-auto-joins the org only when the tid and the email domain both map to that same tenant. Otherwise it falls through to the fail-closed personal path (requires email_verified===true + OPEN_SIGNUP) keyed on a synthetic ms:<tid>:<oid> principal.

Register a tenant's Entra directory · org admin

POST/v1/tenants/:id/entra
GET/v1/tenants/:id/entra
DELETE/v1/entra/:id

Registering the directory is the trust + org-join anchor. It requires an unscoped tenant app-admin (an app-scoped admin is forbidden); DELETE /v1/entra/:id is platform-admin only.

FieldTypeNotes
azureTidstring (GUID)the Entra directory id that may auto-join this tenant
entitystringdefault entity — must be one of the tenant's entities (else 400 invalid_entity)
verifiedDomainsstring[]?optional email domains asserted for the binding
curl $BASE/v1/tenants/ola/entra -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{
    "azureTid":"11111111-2222-3333-4444-555555555555",
    "entity":"olacabs_india",
    "verifiedDomains":["olacabs.com"]
  }'
# → 201 { "id":"…","tenantId":"ola","entity":"olacabs_india","azureTid":"1111…","verifiedDomains":["olacabs.com"] }

Dual-context · org + personal workspace · human session

A person holds their org-tenant identity and an isolated personal workspace (tenant tnt_personal_<personId>) — they can list and switch between them without re-authenticating. Explicit BharatRouter parity. Both endpoints need a type:human SSO token (401 otherwise).

GET/v1/me/contexts
POST/v1/me/contexts/switch

List lazily links the caller into the Person graph and ensures the personal context exists, then returns the switchable set:

curl $BASE/v1/me/contexts -H "authorization: Bearer $HUMAN_TOKEN"
# → { "activeContextId":"hum_123",
#     "contexts":[
#       { "contextId":"hum_123","kind":"org","tenant":"ola","entity":"olacabs_india","email":"ankit@olacabs.com","active":true },
#       { "contextId":"hum_p_9","kind":"personal","tenant":"tnt_personal_per_9","entity":null,"email":"ankit@olacabs.com","active":false } ] }

Switch mints a token re-scoped to the target context — a different sub + tenant, so a token is never valid across contexts:

curl -X POST $BASE/v1/me/contexts/switch -H "authorization: Bearer $HUMAN_TOKEN" \
  -H 'content-type: application/json' -d '{"to":"hum_p_9"}'
# → { "access_token":"<ES256 JWT>","token_type":"Bearer","expires_in":…,
#     "principal":{"id":"hum_p_9","type":"human","tenant":"tnt_personal_per_9","entity":null,"email":"ankit@olacabs.com"},
#     "context":{"contextId":"hum_p_9","kind":"personal"} }
Entity-bounded, never an escalation. The switchable set is exactly the caller's own org identity (same tenant AND entity) plus their personal context — never a sibling entity/tenant the Person also spans. A to outside that set is refused 403 context_forbidden. Switching into personal stamps the origin org identity so the round-trip back is bounded to exactly that identity.

NHI discovery + posture (ISPM) · org admin

Find and score the non-human identities you don't govern. Post an inventory of service accounts, API keys, PATs, and OAuth grants (or let a connector fetch it), and Ekam scores each one for the standing-credential risks an ISPM tool surfaces — ownerless, dormant, never-rotated, over-privileged. Every route is tenant-admin gated.

POST/v1/tenants/:id/nhi-discovery/ingest
GET/v1/tenants/:id/nhi-discovery
GET/v1/tenants/:id/nhi-discovery/report
PUT/v1/nhi-discovery/:id/owner

Ingest an inventory

Body is { items: [...] }, 1–1000 rows. Each row:

FieldTypeNotes
sourcestringrequired — where it lives (github, aws, manual, …)
kindstringrequired — service_account, api_key, pat, oauth_grant, …
externalIdstringrequired — id in the source; upsert key is (tenant, source, externalId)
ownerRefstring?owning human/team — null ⇒ ownerless
privilegesstring[]?scopes/roles as reported by the source
lastUsedAt, lastRotatedAt, sourceCreatedAtstring?ISO timestamps (nullable)
statusactive|disableddefault active
name, metastring? / object?label + free-form metadata
curl $BASE/v1/tenants/ola/nhi-discovery/ingest -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{
    "items":[
      {"source":"aws","kind":"iam_user","externalId":"AIDAEXAMPLE","name":"ci-deployer",
       "privileges":["AdministratorAccess"],"lastUsedAt":"2026-07-30T10:00:00Z",
       "lastRotatedAt":"2025-01-01T00:00:00Z","status":"active"},
      {"source":"github","kind":"pat","externalId":"pat_9f2","name":"legacy-bot","ownerRef":null}
    ]}'
# → 201 { "ingested":2, "items":[
#     { "id":"dnhi_…","risk":"high","flags":["over_privileged","no_rotation"] },
#     { "id":"dnhi_…","risk":"medium","flags":["ownerless","no_rotation"] } ] }

Posture scoring

Every row is scored purely by assessPosture() — the same function on ingest, connector-run, and every list/report read (nothing is snapshotted):

FlagFires when
ownerlessno ownerRef
staleactive and lastUsedAt older than 90 days (STALE_DAYS_DEFAULT)
no_rotationactive and lastRotatedAt older than 180 days (NO_ROTATION_DAYS_DEFAULT) or never rotated
over_privilegeda privilege string reads as admin/wildcard — *, admin, root, superuser, owner
disabled_presentdisabled at the source but still inventoried (informational; suppresses stale / no_rotation)

Risk = HIGH iff over_privileged OR (stale AND no_rotation); MEDIUM for any other substantive flag (ownerless alone is MEDIUM); LOW when clean. A missing or unparseable timestamp is treated as unknown, not stale (fails to false) so "no data" never masquerades as the dormant signal.

List & report

GET /nhi-discovery returns the scored inventory with per-row posture:{flags,risk}; filter by source, kind, risk (low|medium|high), or flag. GET /nhi-discovery/report is the ISPM aggregate.

curl "$BASE/v1/tenants/ola/nhi-discovery?risk=high" -H "authorization: Bearer $ADMIN_TOKEN"
# → { "total":1, "items":[ { …DiscoveredNhi, "posture":{ "flags":["over_privileged","no_rotation"], "risk":"high" } } ] }

curl $BASE/v1/tenants/ola/nhi-discovery/report -H "authorization: Bearer $ADMIN_TOKEN"
# → { "total":42, "byRisk":{"low":30,"medium":9,"high":3},
#     "byFlag":{"ownerless":7,"stale":5,"no_rotation":6,"over_privileged":3,"disabled_present":2},
#     "bySource":{"aws":20,"github":22}, "byKind":{"iam_user":20,"pat":22} }

Remediate: assign an owner

PUT /v1/nhi-discovery/:id/owner takes a bare inventory id, loads the NHI, then gates on its tenant. Setting ownerRef clears the ownerless flag and returns the row with re-scored posture.

curl -X PUT $BASE/v1/nhi-discovery/dnhi_123/owner -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{"ownerRef":"team:platform-eng"}'
# → { …DiscoveredNhi, "posture":{ "flags":["no_rotation"], "risk":"medium" } }
Any row that scores medium|high automatically enters the finding remediation lifecycle (create-or-auto-reopen). The hook is fail-closed and non-fatal — a lifecycle write can never make a scan return 5xx.

Discovery connectors · org admin

Turn discovery from "POST your own inventory" into "Ekam goes and finds it." A connector fetches the ungoverned identities in a source and upserts them into the same inventory + posture path as manual ingest. Eight connectors ship today:

GET/v1/tenants/:id/nhi-discovery/connectors
POST/v1/tenants/:id/nhi-discovery/connectors/:source/run
GET/v1/tenants/:id/nhi-discovery/runs
curl $BASE/v1/tenants/ola/nhi-discovery/connectors -H "authorization: Bearer $ADMIN_TOKEN"
# → { "connectors":["github","m365","okta","gcp","aws","snowflake","slack","datadog"] }

Run a scan. The body is the per-connector config, including the source token — supplied per-run and NOT persisted. Each connector validates its own shape (e.g. GitHub needs org + token; Okta needs domain + token).

curl -X POST $BASE/v1/tenants/ola/nhi-discovery/connectors/github/run \
  -H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"org":"ola-silicon","token":"ghp_…"}'
# → 201 (status ok) DiscoveryRun:
#   { "id":"drun_…","source":"github","status":"ok","discovered":18,"upserted":18,
#     "error":null,"startedAt":"…","finishedAt":"…" }
A source-side failure never 500s. A bad token / missing scope / rate limit becomes a 200 with a status:"error" DiscoveryRun body (the run is the audited outcome) — discovered is 0 and error carries the reason. Only a clean scan returns 201. Every run is retained; read the audited history at GET /nhi-discovery/runs{ total, runs[] }.

Workload attestation · P1 verify · P2 require · P3 freshness

Bind an agent's token to the workload it actually runs as. An agent presents a signed workload_attestation — a Kubernetes ServiceAccount projected token, a cloud instance-identity OIDC token, or a SPIFFE SVID — when it mints. Ekam verifies it against a trusted issuer, records the proven workload as the token's att claim, and (optionally) refuses to mint without it. This is the Aembit-style workload wedge.

1 · Register trusted issuers · org admin

POST/v1/tenants/:id/attestation-issuers
GET/v1/tenants/:id/attestation-issuers
DELETE/v1/tenants/:id/attestation-issuers/:aid
FieldTypeNotes
issuerstringrequired — the iss the attestation JWT must carry
jwksUristring (url)required — where the issuer's public keys are fetched
kindenumk8s_sa | cloud_oidc | spiffe | generic (default generic) — how the workload id is read out of the verified claims
labelstring?display label
curl $BASE/v1/tenants/ola/attestation-issuers -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{
    "issuer":"https://kubernetes.default.svc",
    "jwksUri":"https://kubernetes.default.svc/openid/v1/jwks",
    "kind":"k8s_sa","label":"prod cluster"
  }'
# → 201 { "id":"atti_…","tenantId":"ola","issuer":"https://kubernetes.default.svc",
#         "jwksUri":"…","kind":"k8s_sa","label":"prod cluster","createdAt":"…" }

2 · Present it at mint (P1 verify)

Add workload_attestation to any agent-token seam — the default broker, the OBO subject_token exchange, multi-hop delegation, and CIBA approval on POST /oauth/token, plus the POST /nhi/token hand-off. Ekam resolves the credential's iss, finds the tenant's matching registered issuer, verifies the signature against that issuer's JWKS, and derives a stable workload id per kind: k8s:<ns>/<sa>, spiffe://…, cloud:<sub>, or the raw sub.

curl $BASE/oauth/token -H "authorization: Bearer $OWNER_KEY" -H 'content-type: application/json' -d '{
  "grant_type":"urn:ietf:params:oauth:grant-type:token-exchange",
  "agent_id":"agt_123","resource":"https://your-gateway.example","scope":"models:invoke",
  "workload_attestation":"<signed k8s SA / cloud OIDC / SPIFFE JWT>"
}'
# The minted token carries an att claim:
#   "att": { "workload":"k8s:payments/deployer","method":"k8s_sa",
#            "issuer":"https://kubernetes.default.svc","iat":…, "exp":… }
OutcomeResult
verified + freshtoken minted with the att claim; metered attestation_verified
untrusted issuer / bad signature400 invalid_attestation; metered attestation_rejected
verified but stale (P3)400 attestation_stale — even for a non-required blueprint; metered attestation_stale
blueprint requires it, none presented (P2)400 attestation_required; metered attestation_rejected

3 · Require it (P2) — a blueprint field

A blueprint with requireAttestation:true refuses an un-attested (or no-surviving-proof) mint at every seam with 400 attestation_required. Un-required blueprints stay byte-for-byte the old un-attested mint when no credential is presented.

4 · Freshness window & continuous re-attestation (P3)

GET/v1/tenants/:id/attestation-policy
PUT/v1/tenants/:id/attestation-policy

The credential's own iat must fall inside a freshness window resolved as blueprint.attFreshnessSeconds ?? tenant policy ?? 3600 (DEFAULT_ATT_FRESHNESS_SECONDS). A missing iat is treated as stale (fail-closed). The att claim records exp = iat + window. Because agents have no refresh grant, they re-hit /oauth/token every TTL and re-clear the gate — continuous re-attestation for free; a cached / stale proof is rejected.

curl $BASE/v1/tenants/ola/attestation-policy -H "authorization: Bearer $ADMIN_TOKEN"
# → { "tenantId":"ola","freshnessSeconds":3600,"updatedAt":null }   (default when unset)

curl -X PUT $BASE/v1/tenants/ola/attestation-policy -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{"freshnessSeconds":900}'
# → { "tenantId":"ola","freshnessSeconds":900 }   (audited attestation.policy.set)

Finding remediation lifecycle · org admin

The status + audit layer over every flagged identity. Findings are auto-created, never POSTed: the NHI ingest, connector-run, and shadow-scan write paths open (or auto-reopen) a finding for each medium|high-scored identity. These two routes are the human/agent remediation surface — list with an effective status + count-by-status, and transition a finding through a validated state machine. The human remediation queue is the /posture pane.

GET/v1/tenants/:id/findings
PATCH/v1/findings/:id

List

Filter by status, type (nhi|shadow_agent|agent), risk, or surface (open|risk_accepted). byStatus is computed over all findings by effective status, independent of the filters.

curl "$BASE/v1/tenants/ola/findings?status=open" -H "authorization: Bearer $ADMIN_TOKEN"
# → { "total":4,
#     "byStatus":{"open":4,"acknowledged":1,"remediating":2,"resolved":9,"risk_accepted":1},
#     "items":[ { …FindingLifecycle, "effectiveStatus":"open" }, … ] }

Transition

PATCH /v1/findings/:id takes a bare id, loads the finding, then gates on its tenant. Body: { status, assignee?, note?, reason?, ackExpiresAt? }.

From → to (legal)
openacknowledged · remediating · resolved · risk_accepted
acknowledgedopen · remediating · resolved · risk_accepted
remediatingresolved · risk_accepted · open
resolved / risk_acceptedopen · remediating (closed states only reopen or re-work)
# acknowledge (auto-sets an ack window: now + 30 days unless ackExpiresAt given)
curl -X PATCH $BASE/v1/findings/fnd_123 -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{"status":"acknowledged","assignee":"ankit@olacabs.com"}'

# risk-accept (a NON-EMPTY reason is mandatory, else 422 reason_required)
curl -X PATCH $BASE/v1/findings/fnd_123 -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{"status":"risk_accepted","reason":"break-glass service account, compensating controls in place"}'
# → { …FindingLifecycle, "effectiveStatus":"risk_accepted" }
ErrorWhen
409 illegal_transitionthe from→to pair is not legal (no self-loops)
422 reason_requiredrisk_accepted without a non-empty reason
404 not_foundunknown finding id
Auto-reopen is fingerprint-keyed. A finding's identity is a stable fingerprint = sha256(source, kind, name) (first 32 hex chars), not the physical inventory row id — so when a later scan re-detects the same thing, an effectively-resolved/acknowledged finding flips back to open, reopenedCount increments, and one system:scan trail entry is appended. An expired acknowledgement reads as open at the read boundary (effectiveStatus) but is never persisted. risk_accepted is surfaced in its own bucket, out of the open queue.

Shadow-agent discovery · org admin

Find the agent runtimes Ekam did NOT mint. Scan an external agent platform, reconcile each runtime's claimed Ekam agent id against the tenant's real agents, and inventory the ungoverned ones — the agent-plane analog of NHI discovery.

GET/v1/tenants/:id/shadow-agents
GET/v1/tenants/:id/shadow-agents/report
GET/v1/tenants/:id/shadow-agents/sources
POST/v1/tenants/:id/shadow-agents/scan/:source

GET /shadow-agents/sources lists the registered scanners ({ "sources":["openai"] } today — OpenAI Assistants). A scan config carries the platform token and is not persisted.

curl -X POST $BASE/v1/tenants/ola/shadow-agents/scan/openai \
  -H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"token":"sk-…","org":"org-abc","maxPages":20}'
# → 201 { "source":"openai","status":"ok","discovered":12,"governed":3,"shadow":9 }
#   (source-side failure → 200 { "source":"openai","status":"error","error":"…","discovered":0 })

List with per-row posture:{flags,risk}; filter by risk, governed (true|false), or source. A claimed ekam_agent_id counts as governed only if it resolves to a real Ekam agent — a claim alone never governs.

FlagFires when
ungovernedno resolved Ekam agent (ekamAgentRef null) — the core signal
risky_toola tool name matches shell, exec, code_interpreter, admin, delete, payment, wire, sudo
over_tooledmore than 8 tools (OVER_TOOLED_DEFAULT) — broad blast radius
stalelastSeenAt older than 90 days (SHADOW_STALE_DAYS_DEFAULT)
disabled_presentdisabled at the source but still inventoried

Risk = HIGH iff ungoverned OR (risky_tool AND active). Medium|high rows feed the finding lifecycle. The report aggregates { total, governed, shadow, byRisk, byFlag{ungoverned,over_tooled,risky_tool,stale,disabled_present}, bySource }.

Agent posture + owner JML · org admin

The governance view of the agents Ekam did mint, plus the offboard cascade that closes the "agents outlive their owner" gap. Reuses the existing kill-switch + CAEP.

GET/v1/tenants/:id/agents/posture
GET/v1/tenants/:id/agents/posture/report
POST/v1/owners/:id/offboard
POST/v1/owners/:id/reinstate

scoreTenantAgents() assembles each agent's blueprint scopes (over-privilege) + real token activity (token_issued / introspection / elevation_consumed) + whether the owner is offboarded + last fresh attestation vs the freshness window. Filter by risk or flag.

FlagMeaning
orphanedthe agent's owner has been offboarded
never_usedminted but never issued a real token
staleno recent token activity
over_privilegedthe blueprint grants admin/wildcard scope
attestation_stalelast workload proof is older than the freshness window (P3)
retired_presenta retired agent still inventoried

Risk = HIGH iff orphaned OR over_privileged. The report adds an orphaned:[{id,name,ownerId}] roster.

curl "$BASE/v1/tenants/ola/agents/posture?flag=orphaned" -H "authorization: Bearer $ADMIN_TOKEN"
# → { "total":2, "items":[ { "id":"agt_…","name":"…","ownerId":"own_…","blueprintId":"bp_…",
#       "status":"retired","ownerOffboarded":true,"posture":{"flags":["orphaned"],"risk":"high"} }, … ] }

Offboard an owner (agent JML)

POST /v1/owners/:id/offboard retires + revokes every one of the owner's agents (kill-switch + a CAEP session-revoked push each), then records the offboarding so any surviving or re-created agent scores orphaned. Idempotent.

curl -X POST $BASE/v1/owners/own_123/offboard -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{"by":"hr-offboarding"}'
# → { "ownerId":"own_123","tenantId":"ola","offboardedBy":"hr-offboarding","revokedAgents":4, … }

# reinstate clears the orphaned flag — it does NOT un-revoke the agents (re-provision explicitly)
curl -X POST $BASE/v1/owners/own_123/reinstate -H "authorization: Bearer $ADMIN_TOKEN"
# → { "ownerId":"own_123","reinstated":true,"note":"agents were NOT un-revoked — …" }

Okta parity · workforce IdP capabilities

Ekam is a full workforce identity provider — the lifecycle and governance surface a CIDM / Okta deployment gives you, exposed as first-class APIs. Alongside SSO (OIDC + SAML), SCIM-inbound birthright, and the identity graph documented above, these features close the classic Okta feature set: outbound provisioning (Directory Sync), JML mover re-evaluation, access certification, delegated administration, SAML Response-level signing, and adaptive (risk-based) authentication.

Okta capabilityEkam equivalentWhere
Provisioning / Directory Sync (Okta → downstream app SCIM)SCIM outbound targets + pushSCIM outbound
Lifecycle — Mover (attribute change re-grants/revokes)SCIM-inbound re-evaluation + identity.movedJML MOVER
Access Certification campaignsCertification campaigns + item reviewAccess certification
Delegated / custom admin rolesPer-human tenant admin rolesDelegated admin
SAML app — sign Response vs. AssertionPer-SP signResponse flagSAML Response signing
Adaptive / risk-based MFA (ThreatInsight)Login-event risk scoring + step-upAdaptive risk
All of these are tenant-scoped and gated on org admin authority — an Ekam admin token, or a human who is a tenant admin (see delegated admin roles). Secrets (downstream SCIM bearer tokens, signing keys) are sealed at rest and never returned on read.

SCIM outbound · Directory Sync · org admin

Push identities from Ekam down to a SCIM-enabled app — the Okta "Provisioning / Directory Sync" direction. Where birthright SCIM is inbound (your HR/IdP pushes people into Ekam), a SCIM target is outbound: Ekam becomes the SCIM client and provisions your users into a downstream application's SCIM 2.0 endpoint (create / update / deactivate Users). Register a target per downstream app, then push — on demand or as part of lifecycle.

POST/v1/tenants/:id/scim-targets
GET/v1/tenants/:id/scim-targets
PATCH/v1/tenants/:id/scim-targets/:targetId
DELETE/v1/tenants/:id/scim-targets/:targetId
POST/v1/tenants/:id/scim-targets/:targetId/push
FieldTypeNotes
namestringrequired — label for the downstream app
base_urlstringthe app's SCIM 2.0 base (e.g. https://app.example/scim/v2)
tokenstringbearer token for the downstream SCIM API — sealed at rest, redacted on read
activebooleanpause/resume provisioning without deleting the target
# 1) register a downstream SCIM target (org admin)
curl $BASE/v1/tenants/ola/scim-targets -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{
    "name":"Salesforce prod",
    "base_url":"https://acme.my.salesforce.com/scim/v2",
    "token":"<downstream-scim-bearer>"
  }'
# → { "id":"sct_…","name":"Salesforce prod","base_url":"…","token":"••••redacted","active":true }

# 2) push — Ekam provisions users into the target's /Users (create/update/deactivate)
curl -X POST $BASE/v1/tenants/ola/scim-targets/sct_123/push -H "authorization: Bearer $ADMIN_TOKEN"
# → { "pushed": 128, "created": 3, "updated": 124, "deactivated": 1 }

The push maps each Ekam identity to a downstream SCIM User (userName, name, active, plus the mapped enterprise/Ola extension attributes). Deactivations in Ekam propagate as active:false downstream, so a leaver loses the downstream app in the same sweep.

JML MOVER · attribute-change re-evaluation

The M in Joiner-Mover-Leaver. When a person's attributes change — a promotion, a department transfer, a new cost-center — their access should change with them. Ekam re-evaluates on every SCIM-inbound update: a PATCH /scim/v2/Users/:id that alters policy-relevant attributes re-runs the birthright policy (+ group scopes), diffs the new effective scope against the old, and emits a signed webhook so downstream connectors add the newly-granted access and revoke what no longer applies — no re-onboarding, no manual ticket.

PATCH/scim/v2/Users/:id

A move fires an identity.moved event (in addition to the birthright identity.provisioned re-grant) carrying the before/after attributes and previous_scopescope, so a receiver can diff precisely:

# a MOVER: department + jobLevel change via SCIM inbound
curl -X PATCH $BASE/scim/v2/Users/usr_123 -H "authorization: Bearer $SCIM_TOKEN" \
  -H 'content-type: application/json' -d '{
    "schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations":[
      {"op":"replace","path":"urn:ietf:params:scim:schemas:extension:ola:2.0:User:department","value":"security-engineering"},
      {"op":"replace","path":"urn:ietf:params:scim:schemas:extension:ola:2.0:User:jobLevel","value":"L6"}
    ]
  }'
// POSTed to your registered webhook, signed X-Ekam-Signature: sha256=<hmac>
{
  "event": "identity.moved",
  "user": "ankit@olaelectric.com",
  "changed": { "department": ["platform-engineering","security-engineering"], "jobLevel": ["L5","L6"] },
  "previous_scope": ["mcp:github.read","mcp:sheets.read"],
  "scope": ["mcp:github.read","mcp:sheets.read","mcp:vault.read"]
}

Register the receiver exactly as for birthright (POST /admin/webhooks) and subscribe to identity.moved. Same HMAC verification, same at-least-once delivery + replay.

Access certification · org admin

Periodic access reviews (attestation). Open a certification campaign over a set of grants; each grant becomes an item a reviewer approves (keep) or denies (revoke). Closing the campaign applies the decisions — denied items are revoked — giving you the auditable "who still needs this?" sweep an auditor asks for.

POST/v1/tenants/:id/certification-campaigns
GET/v1/tenants/:id/certification-campaigns
GET/v1/tenants/:id/certification-campaigns/:cid/items
POST/v1/tenants/:id/certification-campaigns/:cid/items/:itemId/approve
POST/v1/tenants/:id/certification-campaigns/:cid/items/:itemId/deny
POST/v1/tenants/:id/certification-campaigns/:cid/close
# 1) open a campaign (org admin)
curl $BASE/v1/tenants/ola/certification-campaigns -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{"name":"Q3 access review","scope":"all"}'
# → { "id":"cmp_…","status":"open","item_count":128 }

# 2) list the items to review (one per grant)
curl $BASE/v1/tenants/ola/certification-campaigns/cmp_123/items -H "authorization: Bearer $ADMIN_TOKEN"

# 3) reviewer decides per item
curl -X POST $BASE/v1/tenants/ola/certification-campaigns/cmp_123/items/itm_9/approve -H "authorization: Bearer $ADMIN_TOKEN"
curl -X POST $BASE/v1/tenants/ola/certification-campaigns/cmp_123/items/itm_7/deny \
  -H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' -d '{"reason":"left the team"}'

# 4) close — applies decisions; denied grants are revoked
curl -X POST $BASE/v1/tenants/ola/certification-campaigns/cmp_123/close -H "authorization: Bearer $ADMIN_TOKEN"
# → { "id":"cmp_123","status":"closed","approved":126,"denied":2,"revoked":2 }

Each item records the reviewer, decision, reason, and timestamp — the attestation trail. An open campaign can be re-reviewed; only close is irreversible and enforcing.

Delegated admin roles · org admin

Scoped administration without handing out the master token. Grant a human a role within a tenant so they can run just the slice of admin they own — a help-desk agent resets MFA, a user-admin manages people, an app-admin manages SAML/OIDC apps — while only a full admin can grant roles or touch tenant config. This is what backs "org admin" on every parity endpoint: the tenantAdmin() check passes for the shared admin token or a human holding a qualifying role.

GET/v1/tenants/:id/admins
POST/v1/tenants/:id/admins
PUT/v1/tenants/:id/admins/:target/role
DELETE/v1/tenants/:id/admins/:target/role
RoleCan do
admineverything in the tenant, incl. granting/revoking admin roles
app-adminmanage apps — SAML/OIDC SPs, assignments, SCIM targets; optionally scoped to specific apps
user-adminmanage people — identities, groups, certification reviews
help-desklow-privilege support — reset MFA, read directory; no grants
# grant a human the app-admin role by email (full tenant admin only)
curl -X PUT $BASE/v1/tenants/ola/admins/app-owner%40olacabs.com/role -H "authorization: Bearer $HUMAN_TOKEN" \
  -H 'content-type: application/json' -d '{"role":"app-admin"}'

# …or scope the grant to specific apps (sp_ ids) — the admin manages ONLY those apps
curl -X PUT $BASE/v1/tenants/ola/admins/app-owner%40olacabs.com/role -H "authorization: Bearer $HUMAN_TOKEN" \
  -H 'content-type: application/json' -d '{"role":"app-admin","apps":["sp_123","sp_456"]}'

# list current tenant admins + their roles (scoped grants include their apps)
curl $BASE/v1/tenants/ola/admins -H "authorization: Bearer $HUMAN_TOKEN"
# → { "admins":[{"humanId":"hum_456","email":"support@ola…","role":"help-desk"}, …] }

# revoke the role by email or hum_ id
curl -X DELETE $BASE/v1/tenants/ola/admins/app-owner%40olacabs.com/role -H "authorization: Bearer $HUMAN_TOKEN"

# add an admin in one call (POST): email + role (+ optional apps scope for app-admin).
# The email's domain must belong to this organization; add doubles as re-role for an existing admin.
curl -X POST $BASE/v1/tenants/ola/admins -H "authorization: Bearer $HUMAN_TOKEN" \
  -H 'content-type: application/json' -d '{"email":"app-owner@olacabs.com","role":"app-admin","apps":["sp_123"]}'

Roles are per-tenant: the same human can be app-admin in one tenant and nothing in another. Only admin may change roles, and Ekam refuses to demote or remove the tenant's last full admin. An app-scoped app-admin manages (and lists) only the apps on their grant — creating new apps and managing tenant IdPs stay with unscoped app-admins and full admins. Omit apps for the classic all-apps grant; re-PUT the role to widen or narrow a scope.

Tenant-admin API keys (ekam_ak_…)

A machine credential for a delegated admin — no SSO session, no shared master token. A full admin mints a long-lived key bound to one admin in one tenant. The key's power is that human's live role (nothing is snapshotted): demote or remove the admin and the key follows instantly; every action it takes is audited as that human; revoking the key is one call. Use it to let an app owner register and manage applications entirely over the API.

POST/v1/tenants/:id/admin-keys
GET/v1/tenants/:id/admin-keys
DELETE/v1/tenants/:id/admin-keys/:kid
# 1) full admin mints a key bound to an app-admin (by email); the secret is shown ONCE
curl -X POST $BASE/v1/tenants/ola/admin-keys -H "authorization: Bearer $HUMAN_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"email":"app-owner@olacabs.com","name":"app-owner automation","expiresInDays":90}'
# → { "key": { "id":"adk_…", "role":"app-admin", … }, "api_key":"ekam_ak_…" }

# 2) the bound admin registers an application with the key — no login, fully audited as them
curl -X POST $BASE/v1/tenants/ola/saml-apps -H "authorization: Bearer ekam_ak_…" \
  -H 'content-type: application/json' -d '{"entity":"olacabs_india","slug":"my-app","name":"My App", …}'

# 3) revoke the key (or just revoke the human's role — the key follows the role, live)
curl -X DELETE $BASE/v1/tenants/ola/admin-keys/adk_… -H "authorization: Bearer $HUMAN_TOKEN"

App launcher & tiles · My Apps

The org's app registry drives the My Apps launcher. An app registered in a tenant (SAML or type:"link") becomes a tile for every human whose entity the app admits — entity, extra entities, or orgWide (see organization vs entity). Tile chrome comes from the same record: name, logoUrl, description, category. Note: OIDC clients self-registered at POST /register are login-only — they have no org mapping and no tile; register a link tile to put an OIDC app on the launcher.

GET/v1/me/apps
PUT/v1/me/apps/:slug/favorite
DELETE/v1/me/apps/:slug/favorite
POST/v1/me/apps/:slug/launched

Human-session APIs behind the launcher: the eligible-apps list (with type, ssoUrl/url, favorites, launch recency) and the per-user favorite toggle. launched records a link-tile open for recents (SAML launches are counted at the SSO endpoint itself).

curl $BASE/v1/me/apps -H "authorization: Bearer $HUMAN_TOKEN"
# → { "entity":"olacabs_india", "apps":[
#     { "slug":"bharatrouter","name":"BharatRouter","type":"saml","ssoUrl":"/saml/ola/bharatrouter/sso",
#       "category":"Platform","favorite":false,"lastLaunchedAt":"…","launchCount":12 },
#     { "slug":"vahini","name":"Vahini","type":"link","url":"https://vahini.ola.in", … } ] }

Per-user / group assignment

PUT/v1/saml-apps/:id/require-assignment
POST/v1/saml-apps/:id/assignments
GET/v1/saml-apps/:id/assignments
DELETE/v1/saml-apps/:id/assignments/:aid
POST/v1/saml-apps/:id/access-requests

By default an app admits its whole entity slice. Flip require-assignment on and only assigned users/groups see (and can launch) the tile — Okta's "assign to specific people". Unassigned users can raise an access request that an admin approves into an assignment.

# restrict the app to assigned people, then assign one user
curl -X PUT $BASE/v1/saml-apps/sp_123/require-assignment -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{"requireAssignment": true}'
curl -X POST $BASE/v1/saml-apps/sp_123/assignments -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{"kind":"user","value":"ankit@olaelectric.com"}'

Org branding · org admin

GET/v1/tenants/:id/branding
PUT/v1/tenants/:id/branding

Per-organization branding for Ekam-rendered surfaces (login/consent): displayName, logoUrl, accentColor (hex). Per-app logos are separate — the logoUrl on each app record. Writes are audited (branding.set); fields are nullable to clear.

curl -X PUT $BASE/v1/tenants/ola/branding -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"displayName":"Ola Group","logoUrl":"https://cdn.ola.in/brand/ola.png","accentColor":"#0FA958"}'
# → { "tenantId":"ola","displayName":"Ola Group","logoUrl":"…","accentColor":"#0FA958","updatedAt":"…" }

SAML Response-level signing · per SP

When Ekam acts as a SAML IdP to a downstream app (a registered SP / saml-app), some SPs require the whole <samlp:Response> envelope to be signed, not just the inner <Assertion> — Okta exposes this as "Sign Response / Sign Assertion". Ekam signs the assertion by default; set the per-SP signResponse flag to also sign the Response element so strict SPs (and those that don't validate the assertion signature independently) accept it.

POST/v1/tenants/:id/saml-apps
GET/v1/tenants/:id/saml-apps
FieldTypeNotes
slugstringapp identifier within the tenant
spEntityIdstringthe SP's SAML entityID (audience)
acsUrlstringAssertionConsumerService POST target
signResponsebooleandefault false. When true, the enclosing <Response> is signed in addition to the assertion.
# register / update a downstream SAML SP with Response-level signing on
curl $BASE/v1/tenants/ola/saml-apps -H "authorization: Bearer $ADMIN_TOKEN" \
  -H 'content-type: application/json' -d '{
    "slug":"tableau",
    "spEntityId":"https://tableau.internal.ola/saml/metadata",
    "acsUrl":"https://tableau.internal.ola/wg/saml/SSO/index.html",
    "signResponse": true
  }'

Both signatures use the tenant's SAML IdP signing key (see PUT /v1/tenants/:id/saml-idp-key). The private key is sealed at rest; hand the SP the IdP metadata / public cert from GET /saml/:tenant/metadata.

Link (OIDC) app tiles. Not every app speaks SAML — an app that does its own Ekam OIDC login (self-registered via POST /register) can still join the org launcher as a type:"link" tile: same entity scoping, per-user assignment, category/logo and audit as a SAML app, but the tile simply opens url — no SAML slice exists for it (/saml/:tenant/:slug/sso → 404). SAML-only fields are rejected on link apps; omitting type keeps today's SAML behaviour exactly.

# add an OIDC/plain-URL app to the launcher (works with an ekam_ak_ admin key too)
curl $BASE/v1/tenants/ola/saml-apps -H "authorization: Bearer $ADMIN_TOKEN"   -H 'content-type: application/json' -d '{
    "type":"link", "slug":"vahini", "name":"Vahini",
    "url":"https://vahini.ola.in",
    "entity":"olacabs_india", "orgWide": true,
    "description":"Agent-fleet command plane", "category":"Platform"
  }'

Adaptive risk + step-up · env-gated

Risk-based authentication. Ekam records a login event per human sign-in — IP, user agent, ge/ASN, timing — and scores it against the person's recent history. When EKAM_ADAPTIVE_RISK is enabled, a high-risk sign-in (new device, impossible travel, anomalous ASN) triggers a step-up MFA challenge before a session is minted; low-risk logins pass straight through. This is Ekam's answer to Okta ThreatInsight / adaptive MFA.

GET/v1/tenants/:id/login-events
EnvEffect
EKAM_ADAPTIVE_RISKunset/false → events are still recorded (auditable) but never force step-up. true → high-risk logins must step up before a session is issued.
# audit the recent login-event risk stream for a tenant (org admin)
curl $BASE/v1/tenants/ola/login-events -H "authorization: Bearer $ADMIN_TOKEN"
# → { "events":[
#      { "id":"lev_…","human":"ankit@ola…","ip":"49.x.x.x","asn":"AS55836",
#        "risk":"high","reason":"new-device+geo-velocity","step_up":true,"ts":"2026-07-23T…" },
#      { "id":"lev_…","human":"…","risk":"low","step_up":false,"ts":"…" }
#    ] }
Recording is always on; enforcement is gated. Turn EKAM_ADAPTIVE_RISK on once you've watched the login-events stream and are comfortable with the scoring — you get the audit trail either way, and enforcement flips a flag, not a redeploy of policy.

@krutrim/ekam-verify

The SDK your gateway imports to verify Ekam tokens offline (JWKS-cached) with optional live revocation.

import { createEkamVerifier } from "@krutrim/ekam-verify";

const verify = createEkamVerifier({
  issuer: "https://ekam.olakrutrim.com",
  jwksUri: "https://ekam.olakrutrim.com/.well-known/jwks.json",
  audience: "https://your-gateway.example",
  introspectUrl: "https://ekam.olakrutrim.com/oauth/introspect", // optional: live kill-switch
});

const agent = await verify(bearerToken);
// -> { agentId, ownerId, scopes, audience, tenant, entity, budgetRef, ... }
// enforce agent.scopes -> route -> meter -> bill agent.ownerId

Full documentation · human + LLM reference

This page is the developer quickstart. For exhaustive, code-accurate detail there is a set of companion reference documents that ship in the Ekam source tree under docs/. They are written to be equally usable by a human reading them and by an LLM support/onboarding agent (mirrored for machines at /llms.txt and /llms-full.txt).

  • API reference (docs/API-REFERENCE.md) — every HTTP endpoint: method, path, auth gate, params/body from the zod schemas, success shape, the error codes it can return, and curl examples.
  • Error codes (docs/ERROR-CODES.md) — a machine-readable catalogue (markdown table and JSON) of every error code with its HTTP status, meaning, likely cause, and fix.
  • Observability (docs/OBSERVABILITY.md) — what Ekam logs (pino + request-id correlation), the audit / usage / login-event streams and webhook delivery logs, the Loki/Grafana/Alloy setup, and a debugging playbook.
  • LLM support kit (docs/LLM-SUPPORT.md) — a grounding contract, structured FAQ, "how do I…" recipes, and a troubleshooting decision tree for an LLM answering support and onboarding questions.
  • Onboarding (personas) (docs/ONBOARDING-PERSONAS.md) — numbered end-to-end walkthroughs for the agent developer, enterprise admin, and operator personas.
  • Tutorials (docs/TUTORIALS.md) — step-by-step tutorials for the flagship flows, plus video scripts and Playwright screen-capture authoring guides.
Krutrim Cloud · DR enabled · open beta  ·  Home · Blog · Cookbook · llms.txt · Privacy · Terms · Report a bug