Audience: operators standing up the server with
auth_mode=entra, and developers calling the API with an Entra bearer token.Secret hygiene — read first. This document uses placeholder identifiers only (e.g.
aaaaaaaa-0000-0000-0000-000000000001). Theentra_identitiesmap contains Azure Object IDs (oid) tied to real people. Never commit real oids, client IDs, or tenant IDs to a shared or public repo. Inject them via environment variables / a secret store, or a git-ignored config file. See §2 (PII warning).
To add/remove users at runtime without redeploying, see docs/identity-management.md.
For a concise cross-mode reference — the two identity maps, how admin is authorized in each mode, what gates data vs.
/admin/*routes, and the empty-map bootstrap sequence — see docs/auth-flows.md.
The Context Intelligence Server authenticates every ingest request (POST /events)
and stamps the writer's identity onto the graph as a write-once created_by
provenance field. Two authentication modes exist, selected by the single
auth_mode setting:
auth_mode |
Credential | What the server checks |
|---|---|---|
static (default) |
Pre-shared bearer tokens | sha256(token) looked up in the api_keys keystore → contributor id |
entra |
Microsoft Entra JWT | RS256 signature (via Entra JWKS) + audience/issuer/tenant/scope, then oid → contributor id |
In entra mode the chain is:
az access token → server validates the JWT → extracts the oid claim
→ oid → contributor (your entra_identities map) → created_by = <contributor>
So a real person's Azure identity is attributed to a stable contributor name on
every node they write. Entra mode authenticates two kinds of token along
separate paths — delegated user tokens (the chain above) and service
(app / managed-identity) tokens authorized by an Entra App Role — see
Authentication model below
for the full dual-path model. The two auth modes (static/entra) remain
mutually exclusive — exactly one resolver is active at a time. Switching is a
one-line config change (auth_mode: static → auth_mode: entra) plus the
supporting fields below.
All config is read by Pydantic Settings (config.py): environment variables
(prefix AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_) take precedence over the YAML
config file, which takes precedence over defaults.
Read this before the operator steps below — it explains the model the rest of the document configures. The operator and developer guides (§2, §3) are the concrete steps; this section is the why they take the shape they do.
Canonical statement. This is the single source of truth for "which Entra tokens the server accepts." Other docs (README, AGENTS.md) point here rather than restating it.
In entra mode the server authenticates two kinds of token along separate
paths inside EntraResolver.resolve() (auth.py). After the shared JWT
validation (signature / audience / issuer / tid, below), a single
discriminator picks the path from the token's scp and idtyp claims:
| Token shape | Path | How it is authorized | created_by |
|---|---|---|---|
scp present (and idtyp != "app") |
User (delegated) — unchanged | scp must contain access_as_user; then oid → entra_identities map |
the mapped contributor id |
scp absent |
Service (app / daemon / managed-identity) — new | an App Role alone: roles must contain Contributor, Reader, or IdentityAdmin |
service_identities[oid] if mapped, else the stable appid |
scp present and idtyp == "app" |
— | anomalous (no legitimate Entra token is both) → 401, fail-closed | — |
-
User path — delegated, byte-for-byte unchanged. A token Entra issues in the context of a signed-in person carries
scp(always present on a delegated token). It must containaccess_as_user; theoidis then looked up inentra_identities. A valid user token whoseoidis not mapped is a 403. This path behaves exactly as it did before service tokens existed. -
Service path — authorized by an App Role alone. An app-only token (client-credentials flow, a managed identity, or a federated-OIDC workload) carries
roles, notscp. The server admits it iff itsrolesclaim contains a qualifying App Role:Contributor(defaultservice_data_role) — write + read.Reader(defaultreader_role) — read only:POST /cypherandGET /blobs/*(notPOST /events).IdentityAdmin(defaultentra_admin_role) — the/admin/*map API.
A service token with no qualifying role is a 403 whose body names the rejected principal (its
appid/oid) and the required roles. The role assignment is the authorization decision — there is no server-side allow-list of service principals and no pre-registration step.
service_identitiesis not an auth gate. It is an optional, staticoid → {id: <contributor>}map (env/YAML, same shape asentra_identities) that only supplies a friendlycreated_byname. An unmapped but role-bearing service is fully authorized; itscreated_bysimply falls back to the stableappid. There is no runtime/admin/servicesendpoint — service identities change only by editing config and redeploying.
Tenant policy note. In a locked-down tenant that blocks client secrets, service callers must obtain tokens via Managed Identity or federated OIDC, not a client secret/certificate. See docs/azure-deployment.md.
A single Entra App Registration backs the whole flow. It plays two roles at once: it is the protected API (the resource the server validates tokens for) and the registration whose Application ID URI the caller requests a token against.
| Registration property | Value (placeholder) | Maps to server setting |
|---|---|---|
| Application (client) ID | <AZURE_CLIENT_ID> |
azure_client_id |
| Directory (tenant) ID | <AZURE_TENANT_ID> |
azure_tenant_id |
| Application ID URI | api://<AZURE_CLIENT_ID> |
(the --resource callers request) |
Expose an API (Entra portal → Expose an API):
- Application ID URI:
api://<AZURE_CLIENT_ID>. - One delegated scope named
access_as_user, with "Who can consent: Admins and users." - The scope's GUID is auto-generated and internal to the registration. Callers
never reference the scope GUID directly — they request the scope by its
value (
access_as_user) or, via the SDK, the resource's/.defaultform (see below). The GUID is plumbing; it never appears in client code or in this doc.
What the server checks (every claim below is enforced in EntraResolver,
auth.py):
| Check | Value (placeholder) | Code |
|---|---|---|
| Signature algorithm | RS256 only (alg=none/HS256 rejected) |
algorithms=["RS256"] |
| Signing key | tenant JWKS, eager-fetched at startup | PyJWKClient(.../<AZURE_TENANT_ID>/discovery/v2.0/keys) |
Audience (aud) |
one of <AZURE_CLIENT_ID> or api://<AZURE_CLIENT_ID> |
audience=[client_id, "api://"+client_id] |
Issuer (iss) |
https://login.microsoftonline.com/<AZURE_TENANT_ID>/v2.0 |
issuer=... |
Tenant (tid) |
must equal <AZURE_TENANT_ID> (explicit, defense-in-depth) |
claims["tid"] == tenant_id |
| Required claims | exp, iss, aud must be present |
options={"require": ["exp","iss","aud"]} |
The next checks are path-specific — selected by the scp/idtyp
discriminator (see the model table above):
| Path | Check | Value | Code |
|---|---|---|---|
| Discriminator | Token type | scp present → user; scp absent → service; scp + idtyp=="app" → 401 |
has_scp / idtyp |
| User | Scope (scp) |
must contain access_as_user |
"access_as_user" in scp.split() |
| User | Object ID (oid) |
looked up in entra_identities → created_by |
identity_map[oid.lower()] |
| Service | App Role (roles) |
must contain Contributor, Reader, or IdentityAdmin |
service_data_role/reader_role/entra_admin_role in roles |
| Service | Identity (created_by) |
service_identities[oid] if mapped, else appid (never app_displayname) |
truthiness chain |
A valid user token whose oid is not in the map is a 403 (identity
unbound). A valid service token with no qualifying App Role is a 403
(named principal + required roles). Any other failure is a 401.
created_bylegend — a GUID means a machine. Whencreated_byis a GUID it is anappid(a service principal's application ID) — i.e. a machine identity, resolvable in Entra by that app id. A friendlycreated_byname appears only when the service'soidis present in the optionalservice_identitiesmap. (Delegated users always resolve to the friendly contributoridfromentra_identities.)
The token's roles claim (Entra App Role assignments) now drives two
things:
- Service data authorization (new). On the service path, a qualifying role —
Contributor(service_data_role, write+read) orReader(reader_role, read-only) — is the sole authorization gate. This has no effect on the user path: a delegated user token authorizes via its mappedoidregardless ofroles. - Admin authority (unchanged). A token whose
rolescontains the App Role named byentra_admin_role(defaultIdentityAdmin) may call/admin/*; any other valid token gets 403 there. This holds for both paths.
The server reads only roles — never groups (group membership can never
grant access). Full runtime onboarding/offboarding API and runbook:
identity-management.md.
The relied-on path is the Azure CLI as a signed-in user:
az login # sign in as a user
az account get-access-token --resource api://<AZURE_CLIENT_ID> \
--query accessToken -o tsv--resource api://<AZURE_CLIENT_ID> makes Entra mint an access token for this
API: a delegated token whose aud is the bare <AZURE_CLIENT_ID> and
whose scp contains access_as_user. Send it as a normal bearer header:
Authorization: Bearer <token>
Via the Azure SDK (DefaultAzureCredential) — request the /.default
scope, not the bare scope:
from azure.identity import DefaultAzureCredential
cred = DefaultAzureCredential()
token = cred.get_token("api://<AZURE_CLIENT_ID>/.default").token # NOT the bare scope/.default is required because the SDK credential chain does not request bare
delegated scopes uniformly. Which path you get depends on how the credential
resolves:
- User-context credential —
AzureCliCredential(the sameaz login), the VS Code sign-in, or an interactive browser login — yields a delegated token (scp=access_as_user) that takes the user path. - App-only credential — a Managed Identity, or
EnvironmentCredentialwith a client secret/cert — yields a token with the rightaudbut noscp(it carriesroles). This now takes the service path: with a qualifying App Role (Contributor/Reader/IdentityAdmin) it authenticates as a service; without any qualifying role it is a 403.
App-only credentials are now supported via the service path. A Managed Identity, a federated-OIDC workload, or (where the tenant allows) a service principal with a secret/cert produces a token carrying
roles, notscp. The server admits it iff itsrolesclaim holds a qualifying App Role —Contributor(write+read),Reader(read-only), orIdentityAdmin(admin). The App Role assignment is the authorization; there is no server-side allow-list and no pre-registration of the service principal.Behavior change (was 401, now 403). Before M2, any token with
rolesand noscpfailed theaccess_as_usercheck and was rejected with 401. Now such a token reaches the service path: with a qualifying role it is admitted; with no qualifying role it is a 403 that names the principal and the required roles. See What 401 vs 403 mean.Onboarding a service caller: see identity-management.md → service callers.
Goal: zero to a protected server in under 30 minutes.
When auth_mode=entra, all of these are required (the server refuses to start
otherwise — see §2.5):
| Field | YAML key | Env var (AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_ + …) |
Meaning |
|---|---|---|---|
| Auth mode | auth_mode |
AUTH_MODE |
Set to entra |
| Client ID | azure_client_id |
AZURE_CLIENT_ID |
App Registration (client) GUID |
| Tenant ID | azure_tenant_id |
AZURE_TENANT_ID |
Azure AD tenant GUID |
| Identity map | entra_identities |
ENTRA_IDENTITIES (JSON) |
oid → {id: <contributor>} (the user path) |
These four boot the user path. The service path needs no required settings — it works out of the box once an App Role is assigned in Entra. Its settings are all optional and have working defaults:
| Field | YAML key | Env var (AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_ + …) |
Default | Meaning |
|---|---|---|---|---|
| Service data role | service_data_role |
SERVICE_DATA_ROLE |
Contributor |
App Role granting service write + read. ""/null disables it. |
| Reader role | reader_role |
READER_ROLE |
Reader |
App Role granting service read-only (POST /cypher, GET /blobs/*). ""/null disables it. |
| Service identities | service_identities |
SERVICE_IDENTITIES (JSON) |
(unset) | Optional oid → {id: <contributor>} map — a friendly created_by override only, not an auth gate. Unmapped services still authorize (via App Role); their created_by falls back to appid. No runtime CRUD — edit config and redeploy. |
service_identitiesis validated with the same GUID-key / non-empty-idrules asentra_identities, but it is never required for boot and never participates in the entra startup validator. Omit it entirely if you don't need friendly machine names.
# server-config.yaml
auth_mode: entra
azure_client_id: "<AZURE_CLIENT_ID>" # App Registration client GUID
azure_tenant_id: "<AZURE_TENANT_ID>" # Azure AD tenant GUID
# oid → { id: <contributor> }. The value carries ONLY `id` — no email, no name.
# Keys are Azure Object IDs (GUIDs); the `id` becomes `created_by` on the graph.
entra_identities:
"aaaaaaaa-0000-0000-0000-000000000001":
id: alice
"aaaaaaaa-0000-0000-0000-000000000002":
id: bob
# Many oids → one contributor is fine: give each oid the same `id`
# (e.g. a person with two AD identities).
"aaaaaaaa-0000-0000-0000-000000000003":
id: aliceThe map is supplied as a JSON string in the env var. This is the path to prefer when you do not want oids written to a config file on disk:
export AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_AUTH_MODE=entra
export AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_AZURE_CLIENT_ID='<AZURE_CLIENT_ID>'
export AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_AZURE_TENANT_ID='<AZURE_TENANT_ID>'
export AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_ENTRA_IDENTITIES='{
"aaaaaaaa-0000-0000-0000-000000000001": {"id": "alice"},
"aaaaaaaa-0000-0000-0000-000000000002": {"id": "bob"}
}'Env vars override YAML, so you can keep a placeholder
entra_identitiesout of config entirely and supply the real map only at runtime via the env var or a secret store.
Ask each developer to run az ad signed-in-user show --query id -o tsv (§3.2) and
send you the value. To look it up yourself from their UPN:
az ad user show --id <user@tenant> --query id -o tsvThat GUID is the map key; you choose the contributor value (id).
⚠️ PII / secret-hygiene warning. Anoidis a persistent personal identifier for a real person. Theentra_identitiesmap is therefore sensitive — treat it like a secret:
- Never commit real oids to a shared/public repo. Git history has no erasure path.
- Prefer env-var / secret-store injection (§2.3) or a
.gitignore-d config file.- This product repo's docs and samples use placeholder GUIDs only (
aaaaaaaa-…).
The config validators (config.py) enforce the map shape at startup. Required
fields are still hard-required, and a malformed map is still fatal — there is no
silent fail-open. But an empty or omitted identity map is NOT an error: it is a
supported bootstrap state (see §2.5.1).
azure_client_id and azure_tenant_id are still REQUIRED in auth_mode=entra.
Their absence is a hard startup error.
On success: the server starts normally and entra auth is active. (No special log line — a clean boot means the validators passed. If the effective identity map is empty, the server logs a loud bootstrap WARNING; see §2.5.1.)
On misconfiguration, the server raises and exits with one of these (the message names both the env var and the YAML key):
-
Missing required field(s) — a single combined message (note: only
azure_client_id/azure_tenant_idappear here now;entra_identitiesis no longer required):Entra auth misconfiguration (startup refused): azure_client_id is required when auth_mode='entra'; set AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_AZURE_CLIENT_ID or azure_client_id in the config file; azure_tenant_id is required when auth_mode='entra'; set AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_AZURE_TENANT_ID or azure_tenant_id in the config file
The following map-shape violations are still fatal (a present-but-malformed entry is a mistake, never a bootstrap state):
-
A malformed oid key:
entra_identities key '<value>' must be a valid GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) -
The all-zeros placeholder GUID (a stray template value must never authorize anyone):
entra_identities key '00000000-0000-0000-0000-000000000000' must not be the all-zeros GUID; use the real oid from 'az ad signed-in-user show --query id -o tsv' -
A bad value (
idmissing/empty/whitespace):entra_identities['<oid>']['id'] must be a non-empty, non-whitespace string, got <value>
Fail-closed by default.
allow_unauthenticateddefaults tofalse. A server with no identity map configured still boots — but fail-CLOSED: it logs a loud startup WARNING and every delegated (human) token receives a 403 until identities are onboarded (see §2.5.1). The only way to make the server pass every request through unauthenticated is the explicitallow_unauthenticated=trueopt-out (which itself logs a loud "WIDE OPEN" warning) — it exists only for the test harness / local dev, never set it in production. Note: inauth_mode=entrathe opt-out has no effect — entra is always authentication-enabled, so an empty map fail-closes regardless.
An empty or omitted entra_identities map is a first-class bootstrap state,
not an error. On a fresh /data volume the server boots and serves, and logs:
entra identity map is EMPTY at startup (0 bound oids) — server is UP and serving,
but every delegated (human) token will receive 403 until identities are onboarded.
Bind the first user with an IdentityAdmin-role token via
PUT /admin/identities/{oid} (store=/data/identity/entra-identities.json).
This is expected on a fresh /data volume.
While the map is empty:
- Data/non-admin routes (e.g.
POST /events) are hard-gated by map membership — a valid delegated token whose oid is unmapped gets 403. /admin/*routes are NOT gated by map membership. They are authorized by role (theIdentityAdminApp Role in the token'srolesclaim). This is the admin-path bootstrap exemption: anIdentityAdminrole-holder can callPUT /admin/identities/{oid}to bind the first identity even when their own oid is not yet in the map. No token-authenticity check is relaxed — only the oid→id map-membership lookup, and only on/admin/*.
So the day-zero sequence is: boot empty → an IdentityAdmin token calls
PUT /admin/identities/{oid} → the first identity is bound → that user (and any
others onboarded the same way) can now use the data API. A config-file seed is
optional (see §2.6, step 3).
When a developer with a valid token whose oid is not in your map calls the API, they get a 403 whose body names the unbound oid:
{
"detail": "Identity not authorized: oid 'aaaaaaaa-0000-0000-0000-000000000009' is not in the identity map; contact the server administrator to add this identity (tenant '<AZURE_TENANT_ID>')"
}To bind them:
- Copy the oid out of the 403 body (or have them run
az ad signed-in-user show --query id -o tsv). - Verify the oid maps to the right person (
az ad user show --id <oid> --query userPrincipalName -o tsv) before adding it — see §4.1 (write-once is permanent). - Bind it. Two paths:
- Runtime, no restart (preferred — and the primary bootstrap path):
PUT /admin/identities/{oid}with anIdentityAdmintoken — effective on the user's next request. This works even on a completely empty map: anIdentityAdminrole-holder can onboard the first identity even when their own oid is not yet mapped (the/admin-path bootstrap exemption). Full runbook: identity-management.md. - Config + restart (OPTIONAL seed): add
"<oid>": {id: <contributor>}toentra_identitiesand restart. A config seed is no longer required — it only pre-populates an initially-empty store. The primary path is boot-empty + the/adminAPI above.
- Runtime, no restart (preferred — and the primary bootstrap path):
The next call from that user → created_by = <contributor>.
Goal: from zero to a first authenticated call in under 15 minutes.
As a human developer you use the user path: a delegated token
carrying the scope access_as_user on the resource
api://<AZURE_CLIENT_ID>. The az commands below are the relied-on,
out-of-the-box path. (Headless service callers use a different path — an
App-Role-bearing app token with no scope; see
Authentication model and
service callers.) See
Authentication model & Entra App Registration
for the full dual-path model and the DefaultAzureCredential /.default caveat.
az ad signed-in-user show --query id -o tsvSend that GUID to the operator so they can bind it (§2.6). Until they do, you'll get a 403.
az account get-access-token --resource api://<AZURE_CLIENT_ID> --query accessToken -o tsvThe token is a short-lived JWT (typically ~60–90 min). Re-acquire when it expires.
The token goes in the Authorization: Bearer header. The event body needs a
data.timestamp (ISO-8601) for the graph write:
TOKEN=$(az account get-access-token --resource api://<AZURE_CLIENT_ID> --query accessToken -o tsv)
curl -sS -X POST https://<server>/events \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event": "session_start",
"workspace": "my-workspace",
"data": {
"timestamp": "2026-06-27T12:00:00Z",
"session_id": "demo-session-1"
}
}'
# → 202 { "status": "queued", ... }On success the event is queued and the resulting graph nodes are stamped
created_by = <your contributor id> (the id the operator mapped your oid to).
Health/monitoring and API-docs paths are exempt from auth and need no token. The server is headless (API-only), so there is a single fixed exempt set —
{/status, /version, /docs, /openapi.json}— a single fixed set with no exempt prefixes./docs(Swagger UI) and/openapi.jsonare always on. See the_EXEMPT_PATHSfrozenset inauth.py.
401 is always a token problem; 403 is always an authorization problem
(the token is valid, but you lack the binding/role). Note that "missing
access_as_user" is no longer a universal 401 cause — it only applies on the
user path (a token that has scp). A token with no scp is routed to
the service path instead, where the question is App Roles, not scope.
| Status | Path | Meaning | What to do |
|---|---|---|---|
| 401 | both | Token problem. Missing / expired / wrong audience / wrong tenant / missing oid; or a user token whose scp lacks access_as_user; or an ambiguous token (scp and idtyp=="app"). |
Re-acquire the token (§3.3). Confirm --resource api://<AZURE_CLIENT_ID>. |
| 403 | user | Identity not bound. Your delegated token is valid, but your oid isn't in the operator's map. The body names your oid. |
Send your oid (§3.2) to the operator to be added (§2.6). |
| 403 | service | No qualifying App Role. Your app/service token is valid, but its roles has none of Contributor/Reader/IdentityAdmin. The body names your appid/oid and the required roles. |
Have an admin assign the App Role in Entra (service callers). |
Behavior change (M2): 401 → 403 for role-less app tokens. Before M2, an app-only token (with
roles, noscp) failed theaccess_as_usercheck and got 401. It now reaches the service path: with a qualifying role it is admitted; with no qualifying role it is a 403 (named principal + required roles) instead of a 401. If you previously saw 401 for a daemon/MI caller, expect 403 now — and the fix is an App Role assignment, not a token re-acquire.
created_by is write-once at write time in Neo4j — once a node is written with
a contributor, it is not rewritten. A wrong oid → contributor mapping
therefore mislabels attribution permanently and is not self-healing in the
graph.
- Always verify the oid before adding it:
az ad user show --id <oid> --query userPrincipalName -o tsvand confirm it's the right person. - Recovery if a wrong mapping already wrote data: fix the map + restart and
manually correct the already-written
created_byvalues in the graph (a data operation — there is no automatic backfill).
The server validates token signatures against Entra's published signing keys
(JWKS), fetched once eagerly at startup (a server with an unreachable JWKS
endpoint refuses to start — fail-closed) and cached thereafter (PyJWKClient,
~5-minute lifespan). Entra rotates signing keys roughly every 6 weeks.
- If a wave of 401s appears immediately after a key rotation, the cache is briefly stale. Wait one cache cycle (~5 min) or restart once, cleanly.
- Do not restart repeatedly in a tight loop — that hammers the JWKS endpoint and doesn't speed recovery. One clean restart refreshes the keys.
Two distinct, greppable tags separate a normal token rejection from an unexpected internal error. The raw bearer token is never logged (credential hygiene).
| Log tag | Level | Meaning | Grep |
|---|---|---|---|
auth_event=auth_denied |
INFO | A normal token rejection (401 or 403) — bad/expired/unbound token. Expected, not a server fault. The line includes the reason and status code. | grep 'auth_event=auth_denied' |
auth_event=resolver_unexpected_exception |
ERROR | An unexpected error inside resolver.resolve() (e.g. a transient library bug). The request is still denied fail-closed (401, never a 500) and a full stack trace is logged for investigation. |
grep 'auth_event=resolver_unexpected_exception' |
A steady trickle of auth_denied is normal. Any resolver_unexpected_exception is
worth investigating — it means the resolver hit something it did not expect.
Every ingested event must carry a data.timestamp (a non-empty ISO-8601 string).
This is ingest payload validation, not auth: post_events calls
_validate_data_timestamp(request.data) before queuing, so a missing, empty,
non-string, or non-ISO-8601 data.timestamp is rejected immediately with HTTP 400
— the event is never accepted (no 202), never queued, and never dead-lettered. The
response body names the field:
{ "detail": "data.timestamp is required and must be a non-empty ISO-8601 string" }{ "detail": "data.timestamp must be a valid ISO-8601 string; got '<value>'" }Real Amplifier clients always send data.timestamp (verified: 224,530 events on
disk, 0 missing), so this 400 only ever catches hand-rolled / curl test payloads —
it cannot reject legitimate traffic. As defense-in-depth, the graph drainer's
make_node_id additionally re-raises a named error if an unparseable timestamp
ever reaches it, so anything that somehow bypasses the ingest check dead-letters
legibly instead of as a bare Invalid isoformat string: ''.
Operator action: when smoke-testing with curl, include a valid data.timestamp
(see §3.4). A 400 here means a malformed payload, not an auth problem.
- Config fields & validators:
context_intelligence_server/config.py(auth_mode,azure_client_id,azure_tenant_id,entra_identities,service_identities,service_data_role,reader_role,entra_admin_role,allow_unauthenticated,build_identity_map(),build_service_identity_map()). - JWT validation:
context_intelligence_server/auth.py(EntraResolver) — RS256 pinned, audience[<client_id>, api://<client_id>], issuerhttps://login.microsoftonline.com/<tenant_id>/v2.0, explicittid; then the dual-path discriminator onscp/idtyp:- User (
scppresent):scpmust containaccess_as_user,oid→ contributor; 403 when theoidis unbound. - Service (
scpabsent):rolesmust containContributor/Reader/IdentityAdmin;created_by=service_identities[oid]→appid→azp→oid(neverapp_displayname); 403 when no qualifying role. scp+idtyp=="app"→ 401 (ambiguous, fail-closed).- 401 otherwise = invalid/expired/missing/wrong-audience/wrong-tenant token.
- User (
- Per-route capability gates:
context_intelligence_server/authz.py(require_writeonPOST /events;require_readonPOST /cypher,GET /blobs/*). - Static-key mode:
docs/managing-api-keys.md.