Skip to content

Harden account identity normalisation and enterprise dispatch - #62

Merged
maiphucgiang merged 1 commit into
maiphucgiang:mainfrom
moshouhot:fix/account-identity-hardening
Oct 14, 2026
Merged

maiphucgiang merged 1 commit into
maiphucgiang:mainfrom
moshouhot:fix/account-identity-hardening

Conversation

@moshouhot

@moshouhot moshouhot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #60 (issue #59). Addresses the Sourcery review findings that held up; two of its ten findings were false positives and are documented below.

A — Unresolvable tenant fell back to the personal endpoint

A credential that is enterprise by type alone (historical backups) needs one tenant lookup. When that lookup failed, resolve_enterprise_id() returned None and or "" passed an empty id, so the enterprise branch was skipped and the personal endpoint answered with an empty package list — exactly the "no credits available" misreport issue #59 was about.

_enterprise_dispatch_id() now raises a readable error when the tenant cannot be confirmed. Personal accounts never call the lookup, so their path is byte-for-byte unchanged (verified across all 10 personal credentials: zero lookup calls).

B — Identity normalisation

  • Numeric uid collapsed identities. isinstance(uid, str) treated a numeric uid as missing, so 12345 and 67890 both fell back to "" and produced the same account_key. They now normalise through one shared helper: numeric and string forms hash identically, different uids never collide.
  • The email fallback was dead code. validate_cred_data() rejected uid-less credentials before _credential_identity() could reach it. Both now use the same canonical helper, so a uid-less credential with a real email imports and identifies correctly.
  • Placeholders still rejected. unknown, 手动添加, 未命名账号, non-email values, and values that merely repeat the nickname or uid are refused, so unrelated accounts never merge. A credential with no determinable identity is rejected rather than silently merged.
  • Sync no longer rebuilds the identity from headers. _sync_credits() derived its key from X-User-Id while the pool hashed the credential data; for a uid-less account those diverged and sync would be rejected forever. It now reads summary()["account_key"].
  • summary()["uid"] is the canonical identity value, so duplicate detection and durability gates no longer skip uid-less accounts (reload() previously admitted them twice).

C — Response codes

The enterprise allowance and the console-accounts helper in the same module disagreed ((0, None) vs (0, 200, None)). Both now accept 0 and 200, matching the existing precedent in auth_oauth.py. Failure codes (400/401/403/500/10085), HTTP failures, non-JSON bodies and transport errors all produce a readable enterprise error — previously a ConnectError escaped raw.

Compatibility

account_key is unchanged for every existing credential. Evidence:

11-credential fixture through _credential_identity    → all byte-identical (before/after)
Persisted credits-ledger.json identities re-derived   → 11 matched, 0 mismatched

No migration required. Personal accounts keep using RESOURCE_PATH; enterprise accounts keep using ENTERPRISE_USAGE_PATH.

Verification

Check Result
New tests (reproduced red before the fix) 17 passed, 11 subtests
account_key for the 11-credential fixture all byte-identical
Existing suite vs baseline no new failures
GUI suite 79 passed
Enterprise credential, live 1995.93 (= 2000 − 4.07)

Independent audit of identity collisions (13-case exhaustive matrix), historical ledger compatibility, and personal/enterprise endpoint isolation all pass.

Findings that did not hold up

  • "Enterprise check-in omits tenant headers." Measured against a live enterprise credential: the check-in status endpoint returns HTTP 200 code=0 both with and without the tenant headers. Not a defect.
  • "Existing account state becomes orphaned." All 11 real credentials keep their exact account_key; the persisted ledger reproduces every historical identity. The only theoretical divergence is enterprise_id-only credentials, of which the fixture has none.

Both are called out so the review record is accurate rather than silently dropped.

Summary by Sourcery

Harden enterprise credit dispatch and account identity handling while preserving existing account keys and personal-account behavior.

Bug Fixes:

  • Prevent enterprise accounts with unresolved tenants from falling back to the personal credits endpoint and reporting incorrect balances.
  • Preserve distinct account identities for numeric UIDs and enable consistent email-based identity fallback for UID-less credentials.
  • Keep identity derivation consistent across validation, deduplication, summaries, and credit synchronisation.
  • Standardize enterprise response handling to accept valid success codes and report HTTP, transport, and payload failures as readable errors.

Enhancements:

  • Centralize account identity normalization and reject placeholder or ambiguous identity values.
  • Maintain compatibility with existing account keys and personal-account request behavior.

Tests:

  • Add regression coverage for enterprise dispatch failures, identity collisions and fallbacks, response-code handling, and network error reporting.

Follow-up to maiphucgiang#60, addressing review findings on the enterprise dispatch and the
identity rules it relies on.

Enterprise dispatch (finding A): a credential that is enterprise by type alone
needs one tenant lookup. If that lookup cannot confirm the tenant, the sync now
raises a readable error instead of passing an empty id, which fell through to the
personal endpoint and reported an enterprise account as having no credits at all.
Personal accounts never trigger the lookup, so their path is unchanged.

Identity normalisation (finding B): a numeric uid is a real identity. It is now
normalised through one shared helper instead of being treated as missing, so two
different numeric uids can no longer collapse onto a single account key and a
numeric uid hashes exactly like its string form. The email fallback moved to the
same helper and is now reachable from import validation, which previously rejected
uid-less credentials before the fallback could run - it was effectively dead code.
Placeholders ("unknown", "手动添加", "未命名账号") and values that merely repeat the
nickname or uid are still rejected so unrelated accounts never merge, and
_sync_credits reads the identity from the credential itself rather than rebuilding
it from request headers, which would have diverged for a uid-less account.

Response codes (finding C): the enterprise allowance and the console-accounts
helper now agree on accepting 0 and 200, matching the existing precedent in
auth_oauth. Failure codes, HTTP failures, non-JSON bodies and transport errors all
produce a readable enterprise error rather than leaking a raw network exception.

account_key is unchanged for every existing credential: the 11-credential fixture
and the persisted credit ledger reproduce their historical identities byte for
byte, so no migration is required. 17 new tests in tests/test_identity_hardening.py
reproduce each defect before the fix; the pre-existing suite shows no new failures
and the GUI suite stays green at 79.
@sourcery-ai

sourcery-ai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

This PR hardens account identity handling with a shared canonicalization path, prevents unresolved enterprise credentials from being billed through the personal endpoint, and makes enterprise response/error handling consistent and user-readable, with regression tests covering identity collisions, fallback validation, dispatch isolation, and response failures.

Sequence diagram for enterprise credit dispatch and failure isolation

sequenceDiagram
    participant Sync as CreditSync
    participant Dispatch as _enterprise_dispatch_id
    participant Resolver as resolve_enterprise_id
    participant Credits as fetch_credits
    participant Personal as RESOURCE_PATH
    participant Enterprise as ENTERPRISE_USAGE_PATH

    Sync->>Dispatch: _enterprise_dispatch_id(token, account, uid, domain)
    alt Stored enterprise_id
        Dispatch-->>Sync: enterprise_id
    else Enterprise account without tenant id
        Dispatch->>Resolver: resolve_enterprise_id(token, uid, domain)
        alt Tenant resolved
            Resolver-->>Dispatch: enterprise_id
            Dispatch-->>Sync: enterprise_id
        else Tenant unresolved
            Resolver-->>Dispatch: None
            Dispatch-->>Sync: RuntimeError
        end
    else Personal account
        Dispatch-->>Sync: empty dispatch id
    end
    Sync->>Credits: fetch_credits(..., enterprise_id)
    alt Enterprise dispatch
        Credits->>Enterprise: POST enterprise usage request
    else Personal dispatch
        Credits->>Personal: POST personal usage request
    end
Loading

Flow diagram for canonical identity derivation

flowchart TD
    A[Credential account data] --> B[normalize_uid]
    B -->|valid uid| E[canonical_uid]
    B -->|missing or invalid uid| C[identity_email]
    C -->|valid email| E
    C -->|placeholder or invalid email| D[Reject credential]
    E --> F[account_key]
    F --> G[summary uid and sync identity]
Loading

File-Level Changes

Change Details Files
Centralize account identity normalization and apply it consistently across validation, hashing, deduplication, summaries, and credit synchronization.
  • Normalize numeric and string UIDs to the same canonical value while preventing invalid or ambiguous identities.
  • Use validated email as a fallback for UID-less credentials and reject placeholders or repeated display fields.
  • Expose the canonical identity in summaries and reuse the stored account key during synchronization.
  • Preserve existing account keys for historical credentials.
app/client_profiles.py
app/auth_oauth.py
converter.py
tests/test_identity_hardening.py
Make enterprise credit dispatch fail safely instead of falling back to the personal endpoint when the tenant cannot be established.
  • Use stored enterprise IDs directly and resolve missing IDs only for enterprise-by-type credentials.
  • Raise a readable error when tenant resolution fails, while leaving personal accounts on the unchanged path.
  • Keep enterprise and personal endpoint selection isolated.
converter.py
tests/test_identity_hardening.py
Harden enterprise credit response handling and normalize success/error behavior.
  • Accept response codes 0 and 200 for enterprise usage responses.
  • Convert transport, HTTP, malformed JSON, and API failure responses into readable enterprise errors while preserving authentication-expiry handling.
  • Add regression coverage for success codes, failure codes, transport failures, and HTTP failures.
app/credits.py
tests/test_identity_hardening.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@moshouhot

Copy link
Copy Markdown
Contributor Author

Follow-up to #60, addressing the review findings from Sourcery that held up. Relates to #59.

Findings 1, 3, 4, 7, 8, 9 are fixed. Finding 2 (unlimited allowances dropped by the ledger) was already fixed upstream in 15bb2fd before the merge. Findings 5 and 10 were verified as false positives — the measurement evidence is in the PR description.

account_key is unchanged for every existing credential: the 11-credential fixture and the persisted credits-ledger.json reproduce their historical identities byte for byte, so no migration is required.

@sourcery-ai sourcery-ai Bot 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.

Hey - I've reviewed your changes and they look great!

Sourcery assessment

Needs a human reviewer. A uid-less credential now uses its email as the account key, and enterprise credit lookups are routed by the resolved tenant ID. If identity normalization or dispatch is wrong, credit-sync records could be associated with the wrong account or show an incorrect balance; reverting restores the prior behavior, and those records can be reconciled.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

@maiphucgiang
maiphucgiang merged commit 79440d1 into maiphucgiang:main Oct 14, 2026
8 checks passed
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.

2 participants