Skip to content

feat(microsoft): withhold tenancy instead of failing sign-in on directory error - #90

Merged
peteski22 merged 4 commits into
mainfrom
feature/microsoft-tenancy-graceful-degradation
Jul 21, 2026
Merged

feat(microsoft): withhold tenancy instead of failing sign-in on directory error#90
peteski22 merged 4 commits into
mainfrom
feature/microsoft-tenancy-graceful-degradation

Conversation

@peteski22

@peteski22 peteski22 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Closes #74

What

Two related changes.

The Graph organization lookup no longer fails the sign-in when it errors. Every failure mode degrades to tenancies=() plus a logged warning.

The library no longer writes to a consumer's stderr uninvited, and its logging contract is documented for the first time.

Why the degradation

The lookup only enriches an identity that the userinfo call has already established. Raising IdentityFetchError escalated a third-party problem — a tenant that never consented, a throttle, a transient outage — into a total sign-in failure for a user whose identity was never in doubt.

Degrading is fail-closed for authorization: withholding the assertion makes owns_domain() return False, which refuses a domain-gated grant rather than opening one. () was already the value for personal accounts, B2B guests, and failed chain-of-trust checks, so consumers necessarily handled it. Only the userinfo call, which establishes identity itself, still raises.

Why the logging fix

The package attached no handler anywhere on its logger chain, so logging.lastResort wrote WARNING and above straight to the consumer's stderr whenever the application had configured no logging. That was pre-existing, but the degradation work made it materially worse — the Microsoft handler went from one warning path to five, and one of them (an unconsented tenant returning 403) fires on every sign-in for that deployment.

A NullHandler on the apron_auth logger fixes it: records find a handler, the fallback stays out of it, and any handler the application configures still receives them.

The README now documents what was previously undiscoverable — logger names, the two levels and what each means, and that configuring apron_auth reaches the whole library. It also states there is deliberately no logger parameter to inject: the logging hierarchy is the injection point, and a second configuration surface would only compete with it.

It further states what is never logged — credentials, token bytes, and exception values — as a contract consumers can rely on and report against, rather than a property they had to infer from the source.

Notes on #74's acceptance criteria

Most of #74 had already landed via #80 (which closed #79) — chain-of-trust validation, per-verified-domain TenancyContext emission, can_assert_domain_ownership=True, and the capability table. Two criteria were outstanding:

Graceful degradation — this PR. test_organization_call_failure_raises had asserted the opposite behavior; it is replaced by TestMicrosoftOrganizationDegradation.

The open scope question — resolved, and the answer is better than the issue assumed. #74 proposed GET /v1.0/domains, whose least-privileged delegated permission is Domain.Read.All (admin consent). #80 instead used GET /v1.0/organization, whose least-privileged delegated permission is User.Read — already in BASE_SCOPES. Microsoft's docs state an app with only User.Read reads exactly id, displayName, and verifiedDomains, which is precisely what the check needs. No additional consent is required. Verified against Microsoft Learn graph/api/organization-get and graph/api/domain-list, 2026-07.

Structure

Retrieval moves to _fetch_organization, so degradation logging stays separate from tenant binding and domain mapping. Both functions document that they never raise.

The exception value is never logged — the status code and exception class are the actionable facts, and a test asserts the bearer token stays out of the logs.

Testing

  • 557 passed, 14 skipped; lint and ty clean. Verified per-commit, not just at the tip.
  • Degradation: 403/status, transport error, unparseable body, and empty organization collection — each asserting a usable IdentityProfile, tenancies == (), a warning, and that owns_domain() refuses the user's own email domain.
  • Logging: NullHandler present, no other configuration applied, and a subprocess check that an unconfigured application sees empty stdout and stderr. Confirmed these fail without the fix.

Not verified live. The degradation paths are exercised against mocks only. tests/integration/test_microsoft.py covers token refresh, not identity fetch. That an unconsented tenant returns 403 specifically is reasoned from documentation rather than observed — the handler degrades on any status, so the behavior does not depend on that guess being right.

Rebase note

Rebased onto #89, which removed domain_owning_tenancy(). The degradation test was adapted to owns_domain() / domain_owning_tenancies(), and now asserts the gate refuses rather than merely that the old helper returned None.

Summary by CodeRabbit

  • Documentation

    • Clarified that Microsoft directory lookups may temporarily withhold tenancy assertions without preventing sign-in.
    • Added guidance on re-resolving withheld assertions and documented logging behaviour and sensitive-data protections.
  • Bug Fixes

    • Improved resilience when Microsoft organisation or verified-domain data is unavailable or invalid.
    • Identity details remain available while affected tenancy assertions are safely omitted.
  • Logging

    • Added silent-by-default logging with warnings available when applications opt in.
    • Ensured tokens, client secrets and ID-token payloads are never logged.

…tory error

The Graph organization lookup only enriches an identity that the userinfo
call has already established, but any failure of it — a 403 from a tenant
that has not consented, a transient outage, an unparseable body — raised
IdentityFetchError and blocked the sign-in outright.

Degrade every one of those paths to tenancies=() with a logged warning.
This is fail-closed for authorization: withholding the assertion makes
domain_owning_tenancy() return None, which refuses a domain-gated grant
rather than opening one, and it joins the existing "no assertion" return
already used for personal accounts, B2B guests, and failed chain-of-trust
checks. Only the userinfo call, which establishes identity itself, still
raises.

The exception value is never logged. The status code and exception class
are the actionable facts for separating a permanent consent problem from
a transient one; a test asserts the bearer token stays out of the logs.

Retrieval moves to _fetch_organization so the degradation logging stays
separate from tenant binding and domain mapping.

Closes #74
…siently

The assertion resolves from a live directory call, so a consumer can see
it absent for reasons unrelated to the tenant's actual domains. Record
that it is withheld rather than fabricated on failure, and that a
consumer persisting the result should re-resolve rather than treat one
absent assertion as durable.

Also record that the lookup needs no consent beyond the User.Read scope
already in BASE_SCOPES, resolving the open scope question from #74.
@peteski22
peteski22 requested a review from Copilot July 20, 2026 19:35
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f1a3b372-51d1-43ff-9a31-812ee1e21559

📥 Commits

Reviewing files that changed from the base of the PR and between 3107dff and 8a972f4.

📒 Files selected for processing (3)
  • README.md
  • src/apron_auth/providers/microsoft.py
  • tests/providers/test_microsoft.py

Walkthrough

Changes

Microsoft organisation lookup failures now degrade to empty tenancy assertions with warnings instead of failing identity retrieval. The package also configures a default NullHandler, adds logging contract documentation, and tests logging capture, silence, and API preservation.

Identity and logging behaviour

Layer / File(s) Summary
Microsoft tenancy degradation
src/apron_auth/providers/microsoft.py, tests/providers/test_microsoft.py, README.md
Organisation lookup, parsing, and response-shape failures now preserve identity results while withholding verified tenancy assertions and emitting warnings; documentation and tests describe the behaviour.
Package logging contract
src/apron_auth/__init__.py, tests/test_logging.py, README.md
The package installs a NullHandler without changing logger levels, supports application capture, and documents logger names, severity semantics, and excluded sensitive values.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main Microsoft change: directory lookup failures now withhold tenancy instead of failing sign-in.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/microsoft-tenancy-graceful-degradation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Microsoft identity provider to gracefully degrade when the Microsoft Graph /v1.0/organization lookup fails, so sign-in succeeds but domain-ownership tenancy assertions are withheld (tenancies=()), with a warning logged instead of raising.

Changes:

  • Refactors Graph organization retrieval into _fetch_organization() that never raises and logs actionable warnings without leaking bearer tokens.
  • Updates tenancy resolution to treat any organization-lookup failure/untrusted response as no asserted tenancies rather than a sign-in failure.
  • Replaces the prior “organization call failure raises” test with a degradation-focused suite covering HTTP status errors, transport errors, non-JSON responses, and empty collections; README is updated to document the behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
src/apron_auth/providers/microsoft.py Introduces non-raising organization fetch + warning logs; _fetch_verified_tenancies() now degrades to () on directory failures.
tests/providers/test_microsoft.py Replaces the raising test with TestMicrosoftOrganizationDegradation asserting usable identity + tenancies=() + warning logs without token leakage.
README.md Documents that Microsoft’s domain-ownership assertion may be transiently withheld and should be re-resolved if persisted.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

The package attached no handler anywhere on its logger chain, so
logging.lastResort wrote WARNING and above straight to the consumer's
stderr whenever the application had configured no logging. A library has
no standing to emit that output uninvited, and the preceding commit made
it materially worse by taking the Microsoft handler from one warning path
to five — one of which, an unconsented tenant returning 403, fires on
every sign-in for that deployment.

Attach a NullHandler to the apron_auth logger. Records then find a
handler and the fallback stays out of it, while any handler the
application configures continues to receive them.

Document the contract in the README, since none of it was discoverable
before: the logger names, the two levels in use and what each means, and
that configuring the apron_auth logger reaches the whole library. State
outright that there is no logger parameter to inject, because the
hierarchy is the injection point and a second configuration surface would
only compete with it.

Also state what is never logged — credentials, token bytes, and exception
values — as a contract consumers can rely on and report against, rather
than a property they have to infer by reading the source.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Comment thread src/apron_auth/providers/microsoft.py
Comment on lines 225 to +229
body = response.json()
except ValueError as exc:
raise IdentityFetchError(f"Failed to parse Microsoft organization response: {exc}") from exc
except ValueError:
logger.warning(
"microsoft directory lookup could not parse the tenant's verified domains; "
"not asserting domain ownership"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reasoning as the HTTP branch, and also not changing.

response.json() raises json.JSONDecodeError essentially universally here, so type(exc).__name__ would be a near-constant. The message already says the parse failed; "(JSONDecodeError)" adds no information a reader does not already have.

The README wording that implied otherwise is corrected in 8a972f4.

Comment thread src/apron_auth/providers/microsoft.py
…main

Every path that withholds domain ownership logged a warning except this
one: an organization whose verifiedDomains is absent, malformed, or holds
no entry with a non-empty name returned an empty tuple silently. The
consumer-visible symptom is identical to a failed lookup — domain-gated
access refuses — so leaving it unobservable gave an operator nothing to
search for in the one case the response looked superficially fine.

Every Entra tenant carries at least an *.onmicrosoft.com domain, so an
organization naming none is anomalous rather than a tenant legitimately
owning nothing, and is worth surfacing.

Fold the shape check into the name extraction so both the malformed and
the empty case reach one warning, rather than two branches that must be
kept in step.

Also correct the README's logging contract, which claimed the exception
class is logged alongside the status code for HTTP failures. It is not,
deliberately: httpx.HTTPStatusError has no subclasses, so its class name
is a constant that would add noise to every degraded sign-in, and
response.json() raises JSONDecodeError near-universally. The class is
logged for RequestError, where the subclass distinguishes a connect
failure from a timeout. State the guarantee — never the exception value —
and let the diagnostic carried vary, rather than specifying a format the
code has no reason to follow uniformly.
@peteski22
peteski22 merged commit fbd4d86 into main Jul 21, 2026
8 checks passed
@peteski22
peteski22 deleted the feature/microsoft-tenancy-graceful-degradation branch July 21, 2026 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants