Skip to content

feat(auth): dual-auth — static key + Microsoft Entra (az login) across hook, query tools, upload CLI#46

Open
colombod wants to merge 5 commits into
feat/layered-behaviours-opt-in-skill-syncfrom
feat/dual-auth-entra
Open

feat(auth): dual-auth — static key + Microsoft Entra (az login) across hook, query tools, upload CLI#46
colombod wants to merge 5 commits into
feat/layered-behaviours-opt-in-skill-syncfrom
feat/dual-auth-entra

Conversation

@colombod

Copy link
Copy Markdown
Collaborator

Summary

Adds Microsoft Entra ID (az login) bearer authentication as a per-target, opt-in alternative to the existing static API key, across all three surfaces — the hook (write path), the query tools (read path), and the upload CLI. The static-key path is the default and unchanged (backward compatible).

⚠️ Stacked on #45

Base is feat/layered-behaviours-opt-in-skill-sync (#45), not main. This builds on #45's layered-behaviours refactor. Merge after #45; GitHub will retarget the base to main automatically when #45 merges.

What's included (4 commits)

  • f670983 slice 1 — shared context_intelligence/auth.py (AuthStrategy/ApiKeyAuth/EntraTokenAuth/build_auth_strategy) + upload CLI wiring + azure-identity dep
  • c969c9f slice 2 — hook + query-tools wiring; per-target XOR; fail-loud mount guards; fixes a latent ${VAR} env-substitution regex bug; auth_resource ${VAR}-substitutable on all surfaces
  • a3b54a5 docs — README + upload README: config shape, V1 scope, operator notes
  • ef65cb7 perf — minimal-impact expiry-aware token cache (see Performance)

Config shape

Per target (hook destinations, query sources): auth_mode: static|entra (default static) + auth_resource: api://<server-app-client-id> (required for entra), ${VAR}-substitutable. Upload CLI: --auth-mode/--auth-resource + env. Each target chooses independently (XOR — never both).

Server prerequisite (proven, not assumed)

Validated against the PR #29 server (microsoft/amplifier-context-intelligence): it accepts delegated scp=access_as_user tokens (what az login mints) and rejects app/roles tokens. Proven end-to-end in an isolated Digital Twin against the PR-#29 server build: valid token → 202; missing data.timestamp → 400; garbage/expired → 401.

V1 scope

Entra = az login (delegated user) only, via AzureCliCredential explicitly (not DefaultAzureCredential, which would auto-select app credentials in CI/cloud and get rejected by the server). Non-interactive credentials (managed identity / workload identity / service principal) are deferred — they mint roles tokens the V1 server rejects, and require a coordinated server-side change.

Performance (hot-path safe)

mount() runs per session and per in-process subsession, so auth is on the hot path. Measured against real az: AzureCliCredential.get_token() shells out to a subprocess (~487–913 ms) with no in-memory cache. Added an expiry-aware in-memory token cache + a process-level singleton credential: mount() ~free, a cached headers() is a lock-free dict read (~0.002 ms), and N in-process subsessions share one az spawn per ~67-min token lifetime. Concurrency via threading.Lock (loop-agnostic), fail-loud on refresh failure, reset() seam for az account switch + tests.

Testing

Root bundle 723+ tests green; new test_auth_cache.py (18) covers expiry boundary, clock skew, concurrency, exception-not-cached, and reset() isolation. The previously-failing 12 behaviour-YAML fixtures are fixed by #45's latest update (now green on this rebased branch). All end-to-end Entra validation was run in DTUs, never against a live server.

colombod and others added 5 commits June 28, 2026 19:23
…ureCliCredential (V1)

Adds the thinnest dual-auth slice to the upload CLI, stacked on pr-45
(layered-behaviours branch). Gate 0 proved the server contract end-to-end
against the pr-29 DTU before this line was written.

What this adds
--------------
context_intelligence/auth.py (NEW — shared by all surfaces)
  - AuthStrategy Protocol: headers() -> dict[str,str]
  - ApiKeyAuth: wraps a static Bearer key
  - EntraTokenAuth: calls credential.get_token('<resource>/.default')
  - build_auth_strategy(): dispatcher; FAIL LOUD, no silent fallback
  - _make_cli_credential(): lazy AzureCliCredential factory, isolated
    so unit tests can patch it without needing azure-identity installed

context_intelligence/config.py
  - resolve_config() gains optional auth_mode='static' param
  - In 'entra' mode, api_key is NOT required (empty string returned)
  - All other behaviour + return type unchanged (backward-compatible)

modules/tool-context-intelligence-upload/
  uploader.py
    - run_upload() gains optional auth_strategy kwarg (keyword-only)
    - When None, derives ApiKeyAuth(api_key) — all existing tests GREEN
    - headers = auth_strategy.headers() replaces the literal header dict

  cli.py
    - Adds --auth-mode (choices: static, entra; default: static)
    - Adds --auth-resource (default None, also from env AMPLIFIER_CONTEXT_INTELLIGENCE_AUTH_RESOURCE)
    - Reads AMPLIFIER_CONTEXT_INTELLIGENCE_AUTH_MODE from env
    - Calls build_auth_strategy() → passes auth_strategy to run_upload()

  pyproject.toml
    - Adds azure-identity>=1.19 to dependencies

Tests
-----
tests/test_auth.py (NEW, 16 tests)
  - ApiKeyAuth.headers() shape
  - EntraTokenAuth.headers() with FakeCredential
  - build_auth_strategy guards (entra+no resource, static+no key, unknown mode)
  - Static mode succeeds even when azure.identity is unavailable

modules/.../tests/test_auth_wiring.py (NEW, 12 tests)
  - run_upload() with injected auth_strategy uses strategy headers
  - run_upload() without auth_strategy derives ApiKeyAuth (backward compat)
  - resolve_config(auth_mode='entra') does NOT SystemExit on missing api_key
  - --auth-mode / --auth-resource CLI flags (accepted/defaults/validation)
  - main() entra mode passes EntraTokenAuth to run_upload
  - main() static mode passes ApiKeyAuth to run_upload

Results
-------
Root bundle:  692 passed
Upload module: 174 passed (PYTHONPATH=bundle root)
ruff:  all changed files clean
pyright: 0 errors on upload module; 1 expected reportMissingImports on
         azure.identity in root bundle (azure-identity not in root dev deps
         — correct by design; lazy import only executes at runtime)

V1 rule honoured
----------------
EntraTokenAuth only acquires tokens via AzureCliCredential (az login rung).
DefaultAzureCredential, managed identity, workload identity, env creds —
all deferred. No bundle-side token cache.

Next slice: wire the same auth_strategy into hook (logging_handler.py) and
query tools (client.py / AsyncCIClient).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…nder; auth_resource on all surfaces

A. FIX _PLACEHOLDER_RE REGEX (config.py)
  - Trailing '\\}' required a literal backslash before '}' — never matched.
  - Fix: drop the extra backslash → r"\$\{([^}:]+)(?::([^}]*))?}"
  - ${VAR}, ${VAR:}, ${VAR:default}, api://${X}/y all now expand correctly.
  - Confirmed via 10 new tests in tests/test_placeholder_expander.py.

B. auth_resource ${VAR}-SUBSTITUTABLE ON ALL THREE SURFACES
  - Hook destinations: app-cli pre-expands; auth_resource parsed verbatim ✓
  - Query-tool sources: _expand() now applied to auth_mode + auth_resource in
    ToolConfigResolver.sources property (+ existing api_key, url).
    Tested in tests/test_auth_resource_expansion.py (3 tests).
  - Upload CLI: _expand_env_placeholders() applied to resolved auth_resource,
    server_url, and api_key after resolve_config() in cli.py main().

C. HOOK WRITE PATH — ENTRA AUTH WIRED (async, per-request token)
  - Destination dataclass: + auth_mode (default 'static') + auth_resource.
  - destinations property: parses auth_mode/auth_resource from config dict.
  - validate_destinations(): per-target XOR — entra→auth_resource required,
    static→api_key required, unknown mode→ValueError (naming the dest).
  - _DestinationDispatcher.__init__: + auth_mode/auth_resource params;
    builds AuthStrategy via build_auth_strategy() ONCE at construction.
  - _DestinationDispatcher._post: NO header baked into httpx.AsyncClient
    constructor; strategy.headers() called PER-REQUEST so Entra SDK can
    refresh tokens on long-lived dispatchers.
  - __init__.py on_session_ready: threads auth_mode/auth_resource from
    each Destination to its _DestinationDispatcher.
  - 15 new tests in modules/hook-context-intelligence/tests/test_hook_auth.py.

D. QUERY TOOLS READ PATH — ENTRA AUTH WIRED (per-request token)
  - Source NamedTuple: + auth_mode (default 'static') + auth_resource.
  - sources property: _expand() applied to auth_mode + auth_resource.
  - ToolConfigResolver.validate_sources(): XOR validation mirrors hook.
  - resolve_query_auth_strategy(hook_resolver, tool_resolver, api_key)
    added to tool_resolver.py — returns ApiKeyAuth or EntraTokenAuth
    following same source→hook-dest→env priority chain as endpoint.
  - CIClient + AsyncCIClient: + auth_strategy: AuthStrategy | None param;
    defaults to ApiKeyAuth(api_key) for backward compat; all header
    lookups use self._strategy.headers() PER-REQUEST.
  - graph_query_tool.py + blob_read_tool.py: resolve auth strategy and
    pass to AsyncCIClient. __init__.py mount() calls validate_sources().
  - 18 new tests in modules/tool-context-intelligence-query/tests/test_query_auth.py.
  - Updated 2 existing test_blob_read_tool.py assertions to accept new kwarg.

TEST TOTALS (all passing):
  Root bundle:         705 passed  (was 692; +13 new tests)
  Hook module:         328 passed  (12 pre-existing test_bundle/test_module_loading
                       failures from PR#45 YAML refactor; NOT introduced here)
  Query tool module:   194 passed, 2 skipped  (was 174+2; +18 new, -2 fixed)
  Upload module:       174 passed  (unchanged)

ruff format+lint: CLEAN on all 11 modified source files.
pyright: 0 errors on root bundle (1 pre-existing warning on requests import).

Stacked on pr-45 (feat/layered-behaviours-opt-in-skill-sync).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Add comprehensive documentation for the new per-target authentication mode:

- README.md: Add 'Authentication — auth_mode / auth_resource' section
  documenting static (default) and Microsoft Entra bearer token modes,
  per-target configuration on hook destinations and query-tool sources,
  ${VAR} substitution, fail-loud mount behaviour, and auth scope notes
  (Entra-V1 = interactive 'az login' parity only; CI/cloud non-interactive
  stays on static key until follow-up; server-side validation prerequisite).

- modules/tool-context-intelligence-upload/README.md: Document
  --auth-mode/--auth-resource CLI flags, environment variable
  alternatives, and matching scope constraints.

Relates to: feat/dual-auth-entra (commits 146c8ea, b0f82e4)

Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Process-level singleton AzureCliCredential with lock-free in-memory cache
keyed by scope. Cache strategy:
- Single TokenCredential per process (no repeated az subprocess overhead)
- Dict read on cache hit (~0.002 ms), threading.Lock only on refresh
- Fail-loud on token refresh; reset() seam for test/switch scenarios
- AMPLIFIER_CONTEXT_INTELLIGENCE_TOKEN_REFRESH_MARGIN_S env var for TTL tuning

Eliminates ~487-913 ms-per-request az subprocess cost on hot path.
Measured on real EntraTokenAuth against live az:
  - mount / build_auth_strategy(entra) x8: first 63.9 ms, rest 0.0015 ms
  - headers() hot path: first 914 ms (real az), cached 0.0017 ms (~500,000x)
  - 8 in-process subsessions sharing singleton: ONE az spawn (437 ms) + 7
    cached (~0.0009 ms each) = 437 ms total (~9x over isolated, grows with N)
  - cached bearer token still validates (HTTP 202 from server)

Council-approved conditional GO; implements all five conditions:
  - Threading.Lock (loop-agnostic, not asyncio.Lock per COE)
  - Scope + tenant keying (TB)
  - Fail-loud on refresh (ROB + TB + UA)
  - reset() seam + fake-credential injection (UA)
  - Re-measured against real az (ROB's merge gate, GREEN)

Stale 'SDK refreshes' comment replaced with justification: measurement
proves the reversal necessary, and future engineers have evidence.

Relates to 146c8ea (dual auth design), b0f82e4 (entra wiring),
fc964eb (PR #29 server validation)

Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Document the Entra token cache for operators (in-memory/process-scoped;
lives ~token lifetime ~67min; auto-refresh at expires_on - margin;
AMPLIFIER_CONTEXT_INTELLIGENCE_TOKEN_REFRESH_MARGIN_S knob; fail-loud;
az-account-switch behavior), and make the auth flow + token cache visible
in the bundle's .dot diagrams:
- NEW docs/auth-flow.dot - dedicated diagram centered on the token-cache
  lane (static vs entra; cache hit lock-free vs miss -> az refresh;
  expiry/margin; fail-loud; reset; server scp=access_as_user -> 202/200)
- context/config-resolution.dot - auth_mode/auth_resource on Destination/Source
- docs/dispatch-circuit-breaker.dot - auth-header build before POST
All diagrams render-checked (readable/right-altitude/grounded).
Full bundle validation passed in full mode.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant