Spec: Entra/AAD Identity-Alias Connector¶
File: .ai/specs/integration/2026-07-04-entra-identity-connector.md Status: Approved (review 2026-07-04 by J. Porter — Slack-alias ownership condition incorporated in v0.4; OQ stances ratified) Owners: integration lane; reviewers: app-layer lane (RBAC/person consumers), workers lane (connector runtime) Related: temporal-clarification spec (OQ-3 resolution names Entra the canonical identity source; routing and binding consume PERSON aliases), source-connector-framework spec (content connectors create placeholder persons this connector merges), chat-delivery-surface spec (delivery aliases), job-queue-leases spec (sync job chain), gdpr-erasure spec (person erasure).
1. Overview¶
Defines how Microsoft Entra ID (Azure AD) becomes the canonical source of person identity inside a Telha tenant: PERSON and GROUP entities, verified aliases (email, UPN, AAD object id), group membership, and manager edges, kept current by delta sync. This spec owns the normative identity rules that the clarification loop and RBAC-adjacent features depend on: which aliases are evidence-grade, how chat identities attach, how placeholder persons from content connectors merge into canonical ones, and how identity resolution stays tenant-scoped.
2. Problem Statement¶
The temporal-clarification loop binds corrections to named, authorised people. Without a canonical identity source: (a) a Slack display name or workspace username becomes de-facto binding authority, which is too weak as evidence; (b) the same human appears as three unrelated identities (SharePoint UPN, Slack user id, email string in a CRM field), so authority ranking and per-person caps are computed against fragments; (c) departed or disabled employees keep receiving clarification questions because nothing revokes delivery eligibility; (d) content connectors that only know an author's email create person records that later conflict with IdP truth; (e) identity resolved globally instead of per tenant leaks people across tenant boundaries.
3. Proposed Solution¶
Normative decisions:
- Entra is canonical. A PERSON's binding identity comes from the corporate IdP; source systems contribute authorship/ownership facts; chat surfaces contribute delivery aliases only.
- Only IdP-verified attributes create evidence-grade aliases:
aad_oid,upn, and addresses from Entramail/proxyAddresses. An email address seen only in a chat profile or a document byline never creates binding authority. - Chat aliases attach through verification: Teams identities map directly via
aadObjectId; Slack identities attach only when the Slack profile email matches an Entra-verified address for that tenant. Ownership of the match (approval condition, 2026-07-04): the Slack match is performed lazily by the clarify-bot, at first delivery or callback involving that Slack identity — the Entra connector never calls Slack. When the bot resolves a Slack profile email, it compares it against tenant-scoped Entra-verified PERSON aliases; if exactly ONE person matches, the bot requests attachment through a dedicated, audited core endpoint (POST /v1/person-aliases/verified-match, §6). Core records the alias only when the request carries verified-match evidence, tenant scope matches, and no ambiguity exists (two matching persons = reject). Slack aliases remain delivery aliases only; authority always resolves through the canonical Entra-verified PERSON. - Identity resolution is tenant-scoped, always. One Entra tenant maps to one Telha tenant (config); guests (B2B) become PERSONs flagged
guest = truewith no default binding authority. - Sync is read-only against Entra and append-only inside Telha: identity changes are new versions; disabling or deleting an account updates
account_enabled(and delivery eligibility immediately) but never erases history. - Placeholder persons created by content connectors (email-only) are merged by alias, not overwritten: the placeholder gains an
ALIAS_OFedge to the canonical PERSON; readers resolve through it; nothing is deleted.
4. Architecture¶
Entra ID (Graph API)
/users/delta /groups/delta /users/{id}/manager
│ (app-only, read-only scopes, throttle-aware)
▼
entra connector (python, workers_common consumer, token audience "connector")
job chain: sync:entra ── complete ──▶ enqueue next (not_before = now+interval,
payload carries Graph delta links)
│ structured writes via SDK records API (no document parsing path)
▼
core graph: PERSON / GROUP versions, MEMBER_OF / REPORTS_TO / ALIAS_OF edges
▼
consumers: clarification routing (authority + delivery), chat callback binding,
per-person caps, steward escalation chains
5. Data Models¶
PERSON node (system-reserved label). uuidv5 namespace = RFC 4122 NAMESPACE_URL (6ba7b811-9dad-11d1-80b4-00c04fd430c8) for every convention in this spec — pinned as-built by PR-055 (email worker, email_worker/identity.py), whose placeholder-person ids MUST merge with this connector's. Logical id = uuidv5("person:" + aad_oid) when IdP-known. Properties: display_name, upn, mail, proxy_addresses: [..], aad_oid, account_enabled, guest, department, job_title, slack_user_id? (attached per §3.3). Placeholder persons (created by content connectors before IdP sync) use logical id = uuidv5("person-email:" + lowercase(email)) with placeholder = true; on verified-email match the connector writes an ALIAS_OF edge placeholder → canonical. Resolution rule (normative for all consumers): follow ALIAS_OF to the canonical PERSON before ranking, capping, delivering, or binding.
GROUP node. Logical id = uuidv5("group:" + aad_group_id); properties display_name, group_type; membership as MEMBER_OF edges (person → group). Manager as REPORTS_TO edge (person → person), consumed later by escalation chains.
Checkpoint. Graph delta links ride forward in the next job's payload ({users_delta, groups_delta, full_sync_completed_at}); there is no separate connector-state store. A missing/expired delta link (Graph 410) triggers a full resync in place.
Provenance. Identity versions carry Provenance { source: "connector:entra", .. } with no spans (structured API records, not parsed documents).
6. API Contracts¶
Graph (consumed). App registration per tenant, app-only, read-only: User.Read.All, Group.Read.All, GroupMember.Read.All. Endpoints: /users/delta (select: id, displayName, userPrincipalName, mail, proxyAddresses, accountEnabled, userType, department, jobTitle), /groups/delta, /users/{id}/manager. Throttling: honor Retry-After on 429; page via @odata.nextLink.
Telha (produced). Structured writes through the SDK records/relationships API under the connector's tenant scope. Job kind sync:entra; token audience "connector" maps to the sync: kind prefix and nothing else (third fence, alongside ingest: and clarify:). Alias attachment endpoint (approval condition): POST /v1/person-aliases/verified-match with body {person_id, alias_kind: "slack_user_id", alias_value, evidence: {source: "slack_profile", matched_email, matched_entra_alias}} — callable only by "clarify"-audience tokens (the component that observed the identity); core validates the evidence against the person's Entra-verified aliases, rejects ambiguous or cross-tenant matches, writes the alias as a new PERSON version, and emits an audit event. Failure taxonomy: 429/5xx/timeout → retryable (backoff within run); invalid_grant/consent revoked → permanent, dead-letter plus admin audit event CONNECTOR_AUTH_FAILED; per-user mapping failures skip the record with an audit event, never abort the run.
7. UI/UX¶
Operator CLI: telha connector ls|status|run entra (status shows last sync, delta age, counts, last error). Audit events: sync started/completed (with add/update/disable counts), auth failure, placeholder merges, guest creations. No end-user surface: identity sync is invisible except through better routing.
8. Configuration¶
[connectors.entra]: enabled (false), tenant_id, client_id, client_secret_env (name of env var; secrets never in config files), interval_minutes (60), full_resync_days (30), sync_groups (true), sync_manager (true). Deliberately NOT configurable: the verified-alias rule (§3.2), tenant scoping, read-only scopes, append-only writes.
9. Alternatives Considered¶
- SCIM provisioning (push from IdP). Rejected for v1: requires customer-side IdP configuration and an inbound provisioning endpoint; delta pull needs only an app registration. SCIM may be added later for IdPs without delta APIs.
- Chat profile as identity source. Rejected: display names and unverified workspace emails are not evidence-grade; this is the failure mode the connector exists to prevent.
- Identity in the app-layer Postgres instead of the graph. Rejected: identity is world state; the graph gives bitemporal history ("who was this person's manager in March"), tenancy, and provenance for free, and routing queries stay in one store.
- Overwriting placeholder persons on merge. Rejected: content-connector facts already reference the placeholder id;
ALIAS_OFpreserves those references append-only. - Global (cross-tenant) person resolution. Rejected: violates tenant isolation; explicitly prohibited (§3.4).
10. Implementation Approach¶
One PR (PR-062, build doc Phase 3b), sequenced before the clarify-bot PR-063 (its hard dependency): connector package under workers/entra_connector/ reusing workers_common consumer + SDK; sync:entra kind + "connector" audience fence in core (small core diff); fixtures = recorded Graph delta responses (no live tenant in CI). Crash behavior: the job chain is the checkpoint; a crashed run re-leases and replays its delta window idempotently (records API upserts by logical id).
11. Migration Path¶
Greenfield. Tenants that ran content connectors first accumulate placeholder persons; the first Entra sync merges them via ALIAS_OF (§5) with no data migration. Disabling the connector stops updates; existing identity versions remain queryable. Forward compatibility: new Graph attributes are additive properties.
12. Success Metrics¶
entra_delta_sync: add/update/disable across two delta rounds produce correct versions; disabled user immediately fails delivery-eligibility checks while history remains queryable.entra_alias_binding: Slack profile email matching a verified Entra address attachesslack_user_id; a non-matching Slack identity attaches nothing and cannot bind (clarification-spec attestation path).entra_placeholder_merge: content-connector placeholder person gainsALIAS_OF; routing/caps resolve to the canonical person; old references still read.entra_tenant_scope: two tenants syncing overlapping humans produce disjoint PERSON sets; no cross-tenant resolution.entra_throttle_backoff: 429 with Retry-After delays without failing the run; expired delta link (410) triggers clean full resync.entra_guest_default: B2B guest becomesguest = truewith no binding authority by default.entra_slack_alias_verified_match: Slack alias attaches only when exactly one Entra-verified PERSON matches; ambiguous email match rejected; unverified Slack email rejected; cross-tenant match rejected; successful attachment emits an audit event.
13. Open Questions¶
OQ-1: Nested group membership — flatten at sync or resolve at query time?Ratified 2026-07-04: direct membership only; consumers traverse via MEMBER_OF; revisit only if routing latency demands it.OQ-2: Non-Microsoft IdPs (Okta, Google Workspace)?Ratified 2026-07-04: same PERSON/alias model, separate connector specs later; §3 rules are IdP-agnostic.OQ-3: Profile photos or extended PII sync?Ratified 2026-07-04: no — data minimisation; sync only what routing and display need.
14. Changelog¶
- 2026-07-05 — v0.5: as-built record for PR-062 (status stays Approved). Deviations and refinements: (1) Writes ride the REST records API's new
upsert: trueper-record flag (framework spec v0.4 item 6) — create / identical→no-write / different→new version / never-resurrect gives §10's "idempotent delta replay" and a churn-free steady state. One record per call (item-level fault isolation over batching). Python SDK gainedupdate_recordandcreate_relationships(one edge per POST — the REST surface's shape). (2) Placeholder shape pinned to{address}ONLY (the PR-055 email worker convention wins over §5'splaceholder = trueproperty — adding it would version-churn upserts against email-worker persons; placeholder-ness is derivable from the id form + absentaad_oid). (3)proxyAddresses:smtp:/SMTP:prefixes stripped, lowercased, deduped, sorted; NON-smtp schemes (sip:, x500:) are DROPPED — they are not evidence-grade addresses and would mint garbage placeholder probes. (4) Group/membership@removedentries are audit-logged, not written: groups have no disable flag, and edge tombstoning needs an edge id the connector does not hold across runs (v1 limitation). GROUP properties gained additiveaad_group_id.@removedUSERS upsert{aad_oid, account_enabled: false}(Graph sends only the id). (5) Throttle handling is belt-and-braces: the sync engine honors Retry-After with an injectable sleep (offline-testable), and the HTTP Graph client additionally retries 429 internally before surfacing. 410 on a delta link → full resync within the same run. (6) Chain payload (camelCase):{v, usersDelta, groupsDelta, fullSyncCompletedAt, connectionId}; completion emits the framework sync-result JSON withdelaySecs = intervaland stats{users_upserted, groups_upserted, memberships, placeholders_merged, skipped}. ALIAS_OF merge edges are created unconditionally when a placeholder exists (duplicate edges are resolution-equivalent; cheaper than an expand query per verified address). (7) The verified-match endpoint (§6) landed core-side as pinned: clarify-audience only (403 ALIAS_ATTACH_FORBIDDEN), evidence re-verified against the person'smail/proxy_addressesANDaad_oidpresence (422 ALIAS_EVIDENCE_INVALID), exact-one-match tenant scan (409 ALIAS_AMBIGUOUS), idempotent replay, replacing an existing different alias → 409 ALIAS_CONFLICT (operator action), audit via structured tracing; >1000-person tenants need a cursor loop (recorded follow-up). Tests:entra_slack_alias_verified_match(core, tests/connector_e2e.rs incl. the cross-tenant leg) + 17 connector-side tests (delta rounds, placeholder merge, throttle/410, guest flag, sync-result contract, id-convention pins). - 2026-07-04 — v0.4: Approved (cross-spec review by J. Porter). Condition incorporated: Slack alias attachment ownership pinned — clarify-bot performs the match lazily, attachment mediated by the new audited
POST /v1/person-aliases/verified-matchendpoint (clarify-audience only, exact-one-match, tenant-scoped); testentra_slack_alias_verified_matchadded. OQ-1/2/3 stances ratified. - 2026-07-04 — v0.3 (namespace pin; spec remains Draft pending its full review): §5 now pins the uuidv5 namespace to RFC 4122 NAMESPACE_URL, established as-built by the PR-055 email worker's placeholder-person convention (
person-email:{lowercase address}) — PR-062 must use the same namespace so placeholder and connector ids agree directly. - 2026-07-04 — v0.2: PR number filled in from build doc v2.1 Phase 3b (PR-062); no design changes.
- 2026-07-04 — v0.1: initial draft for peer review.