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.
iss, aud
(RFC 8707), exp, signature (JWKS) and — for live revocation — call introspection.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:
- Sign in at /account with any Google account (open beta) or your org's IdP.
- Create a workspace and mint a workspace key (
ekam_sk_…) — this key represents you, the owner. - 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"}'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
| Object | Id | What it is |
|---|---|---|
| Tenant | ten_… / ola | A customer. Holds the set of legal entities and verified email domains. |
| Entity | string | A legal-entity boundary within a tenant (tenant-scoped, sourced from your directory/IdP — not a global enum). |
| Owner | own_… | The human/org that owns agents and is billed. Authenticates with an Ekam API key. |
| Blueprint | bp_… | A template agents are minted from: scopes, allowed audiences, TTL, budget, classification, cost-center. |
| Agent | agt_… | A first-class, owned, revocable identity — not an API key. |
| Token | ES256 JWT | Short-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[] / orgWideThe 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:humantoken from SSO (Human SSO). - SCIM service token — per-tenant static bearer for the
/scim/v2lifecycle APIs, minted atPOST /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).
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" }| Status | error | When |
|---|---|---|
| 400 | invalid_request | missing/invalid field |
| 401 | invalid_token | missing/expired/revoked credential |
| 403 | access_denied | scope/audience/entity not permitted |
| 404 | not_found | unknown object |
Discovery
/.well-known/oauth-authorization-server/.well-known/openid-configuration/.well-known/jwks.json/.well-known/oauth-protected-resource/healthzMetadata 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
/v1/tenants/v1/tenantscurl $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
/v1/owners/v1/owners/v1/meCreate 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
/v1/blueprints/v1/blueprints| Field | Type | Notes |
|---|---|---|
name | string | required |
scopes | string[] | e.g. ["models:invoke"] |
allowedAudiences | string[] | RFC 8707 resources the agent may target |
tokenTtlSeconds | number | default 900 |
budgetRef | string? | gateway budget id |
maxClassification | string? | highest sensitivity tier |
costCenter | string? | cost center charged |
Agents
/v1/agents/v1/agents/v1/agents/:id/revokeMint 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
/oauth/tokenBrokers 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
/oauth/introspectRFC 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)
/v1/access-requests/v1/access-requests/v1/access-requests/:id/approve/v1/access-requests/:id/denyRequest → 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
/v1/elevation-requests/v1/elevation-requests/v1/elevation-requests/:id/approve/v1/elevation-requests/:id/denyRequest 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)
/.well-known/ssf-configuration/ssf/streams/ssf/streams/ssf/streams/:id/verify/ssf/streams/:idSubscribe 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
/scim/v2/Users/scim/v2/Users/:id/scim/v2/Users/:id/admin/birthright-policy/admin/birthright-policy/admin/webhooksZero-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)
/scim/v2/Groups/scim/v2/Groups/:id/scim/v2/Groups/scim/v2/Groups/:idA 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
/v1/tenants/:id/scim-tokenYour 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
/scim/v2/Users/:id/scim/v2/Users/:id/scim/v2/Users/:idThe 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"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
/v1/persons/resolve/v1/persons/:id/v1/persons/:id/merge/v1/persons/:id/export/v1/persons/:id/eraseOne 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
/auth/login/auth/callback/oauth/federate/googleBrowser 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).
/register/authorize/oauth/token/userinfo/logout1 · 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 chooserEkam 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/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
/v1/tenants/:id/idps/v1/tenants/:id/idps/v1/idps/:id/oauth/federate/oidc/auth/saml/acs/auth/saml/metadata/auth/github/loginEkam 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)/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.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 oid — never 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
/auth/microsoft/login/auth/microsoft/callbackSend 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 failure | Status / error |
|---|---|
| state cookie missing / mismatched (CSRF) | 400 bad_state |
no code on the redirect | 400 missing_code |
| network failure reaching Microsoft | 502 upstream_unreachable (+ request_id) |
| Microsoft rejects the code exchange | 401 token_exchange_failed |
id_token has no tid/oid, or (personal path) no verified email | 401 invalid_microsoft_token / email_unverified |
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
/v1/tenants/:id/entra/v1/tenants/:id/entra/v1/entra/:idRegistering 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.
| Field | Type | Notes |
|---|---|---|
azureTid | string (GUID) | the Entra directory id that may auto-join this tenant |
entity | string | default entity — must be one of the tenant's entities (else 400 invalid_entity) |
verifiedDomains | string[]? | 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).
/v1/me/contexts/v1/me/contexts/switchList 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"} }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.
/v1/tenants/:id/nhi-discovery/ingest/v1/tenants/:id/nhi-discovery/v1/tenants/:id/nhi-discovery/report/v1/nhi-discovery/:id/ownerIngest an inventory
Body is { items: [...] }, 1–1000 rows. Each row:
| Field | Type | Notes |
|---|---|---|
source | string | required — where it lives (github, aws, manual, …) |
kind | string | required — service_account, api_key, pat, oauth_grant, … |
externalId | string | required — id in the source; upsert key is (tenant, source, externalId) |
ownerRef | string? | owning human/team — null ⇒ ownerless |
privileges | string[]? | scopes/roles as reported by the source |
lastUsedAt, lastRotatedAt, sourceCreatedAt | string? | ISO timestamps (nullable) |
status | active|disabled | default active |
name, meta | string? / 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):
| Flag | Fires when |
|---|---|
ownerless | no ownerRef |
stale | active and lastUsedAt older than 90 days (STALE_DAYS_DEFAULT) |
no_rotation | active and lastRotatedAt older than 180 days (NO_ROTATION_DAYS_DEFAULT) or never rotated |
over_privileged | a privilege string reads as admin/wildcard — *, admin, root, superuser, owner |
disabled_present | disabled 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" } }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:
/v1/tenants/:id/nhi-discovery/connectors/v1/tenants/:id/nhi-discovery/connectors/:source/run/v1/tenants/:id/nhi-discovery/runscurl $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":"…" }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
/v1/tenants/:id/attestation-issuers/v1/tenants/:id/attestation-issuers/v1/tenants/:id/attestation-issuers/:aid| Field | Type | Notes |
|---|---|---|
issuer | string | required — the iss the attestation JWT must carry |
jwksUri | string (url) | required — where the issuer's public keys are fetched |
kind | enum | k8s_sa | cloud_oidc | spiffe | generic (default generic) — how the workload id is read out of the verified claims |
label | string? | 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":… }| Outcome | Result |
|---|---|
| verified + fresh | token minted with the att claim; metered attestation_verified |
| untrusted issuer / bad signature | 400 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)
/v1/tenants/:id/attestation-policy/v1/tenants/:id/attestation-policyThe 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.
/v1/tenants/:id/findings/v1/findings/:idList
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) | |
|---|---|
open → | acknowledged · remediating · resolved · risk_accepted |
acknowledged → | open · remediating · resolved · risk_accepted |
remediating → | resolved · risk_accepted · open |
resolved / risk_accepted → | open · 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" }| Error | When |
|---|---|
409 illegal_transition | the from→to pair is not legal (no self-loops) |
422 reason_required | risk_accepted without a non-empty reason |
404 not_found | unknown finding id |
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.
/v1/tenants/:id/shadow-agents/v1/tenants/:id/shadow-agents/report/v1/tenants/:id/shadow-agents/sources/v1/tenants/:id/shadow-agents/scan/:sourceGET /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.
| Flag | Fires when |
|---|---|
ungoverned | no resolved Ekam agent (ekamAgentRef null) — the core signal |
risky_tool | a tool name matches shell, exec, code_interpreter, admin, delete, payment, wire, sudo |
over_tooled | more than 8 tools (OVER_TOOLED_DEFAULT) — broad blast radius |
stale | lastSeenAt older than 90 days (SHADOW_STALE_DAYS_DEFAULT) |
disabled_present | disabled 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.
/v1/tenants/:id/agents/posture/v1/tenants/:id/agents/posture/report/v1/owners/:id/offboard/v1/owners/:id/reinstatescoreTenantAgents() 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.
| Flag | Meaning |
|---|---|
orphaned | the agent's owner has been offboarded |
never_used | minted but never issued a real token |
stale | no recent token activity |
over_privileged | the blueprint grants admin/wildcard scope |
attestation_stale | last workload proof is older than the freshness window (P3) |
retired_present | a 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 capability | Ekam equivalent | Where |
|---|---|---|
| Provisioning / Directory Sync (Okta → downstream app SCIM) | SCIM outbound targets + push | SCIM outbound |
| Lifecycle — Mover (attribute change re-grants/revokes) | SCIM-inbound re-evaluation + identity.moved | JML MOVER |
| Access Certification campaigns | Certification campaigns + item review | Access certification |
| Delegated / custom admin roles | Per-human tenant admin roles | Delegated admin |
| SAML app — sign Response vs. Assertion | Per-SP signResponse flag | SAML Response signing |
| Adaptive / risk-based MFA (ThreatInsight) | Login-event risk scoring + step-up | Adaptive risk |
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.
/v1/tenants/:id/scim-targets/v1/tenants/:id/scim-targets/v1/tenants/:id/scim-targets/:targetId/v1/tenants/:id/scim-targets/:targetId/v1/tenants/:id/scim-targets/:targetId/push| Field | Type | Notes |
|---|---|---|
name | string | required — label for the downstream app |
base_url | string | the app's SCIM 2.0 base (e.g. https://app.example/scim/v2) |
token | string | bearer token for the downstream SCIM API — sealed at rest, redacted on read |
active | boolean | pause/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.
/scim/v2/Users/:idA move fires an identity.moved event (in addition to the birthright
identity.provisioned re-grant) carrying the before/after attributes and
previous_scope → scope, 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.
/v1/tenants/:id/certification-campaigns/v1/tenants/:id/certification-campaigns/v1/tenants/:id/certification-campaigns/:cid/items/v1/tenants/:id/certification-campaigns/:cid/items/:itemId/approve/v1/tenants/:id/certification-campaigns/:cid/items/:itemId/deny/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.
/v1/tenants/:id/admins/v1/tenants/:id/admins/v1/tenants/:id/admins/:target/role/v1/tenants/:id/admins/:target/role| Role | Can do |
|---|---|
admin | everything in the tenant, incl. granting/revoking admin roles |
app-admin | manage apps — SAML/OIDC SPs, assignments, SCIM targets; optionally scoped to specific apps |
user-admin | manage people — identities, groups, certification reviews |
help-desk | low-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.
/v1/tenants/:id/admin-keys/v1/tenants/:id/admin-keys/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.
/v1/me/apps/v1/me/apps/:slug/favorite/v1/me/apps/:slug/favorite/v1/me/apps/:slug/launchedHuman-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
/v1/saml-apps/:id/require-assignment/v1/saml-apps/:id/assignments/v1/saml-apps/:id/assignments/v1/saml-apps/:id/assignments/:aid/v1/saml-apps/:id/access-requestsBy 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
/v1/tenants/:id/branding/v1/tenants/:id/brandingPer-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.
/v1/tenants/:id/saml-apps/v1/tenants/:id/saml-apps| Field | Type | Notes |
|---|---|---|
slug | string | app identifier within the tenant |
spEntityId | string | the SP's SAML entityID (audience) |
acsUrl | string | AssertionConsumerService POST target |
signResponse | boolean | default 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.
/v1/tenants/:id/login-events| Env | Effect |
|---|---|
EKAM_ADAPTIVE_RISK | unset/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":"…" }
# ] }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.ownerIdFull 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.