Commit 0d5cfcb
committed
azrepos: rebuild binding manager around scopes and home account id
The pre-pivot binding manager had one stored shape — `(orgName,
userName)` — and one verb pair to manipulate it (`bind`/`unbind`
with `--local`). It couldn't distinguish "use account A for any
org in tenant T" from "use account A for org O", and the stored
value was a UPN, so a rename outside GCM silently broke the
binding.
Rebuild it around three changes:
- **Scope union.** A binding now lives at one of two scopes:
`Tenant(id)` ("any organization backed by this Microsoft Entra
tenant") or `Org(name)` ("this specific organization"). Each
case carries an `IsLocal` flag that picks per-clone vs.
machine-wide storage. Resolution is most-specific-first: local
Org → global Org → local Tenant → global Tenant → nothing.
- **`IMicrosoftAccount`-typed API.** The manager traffics in
`IMicrosoftAccount` rather than opaque strings, so the storage
boundary owns the UPN-vs-HomeAccountId distinction and the
provider doesn't have to encode-then-translate at the cache
boundary. `Bind` takes an account with at least one of
`HomeAccountId` or `UserName` populated; `GetAccount` returns
whichever fields the stored binding has.
- **Dual-key storage.** `.accountid` always holds the MSAL
`HomeAccountId`, `.username` always holds the UPN. Both are
written atomically when both are known; either alone is
written when only one is available (legacy `.username`-only
bindings continue to read back as a UPN-only account). A
HomeAccountId never changes when an account is renamed or its
UPN suffix shifts, so credential lookups survive identity
drift; the colocated UPN gives `list-bindings` something to
display when MSAL hasn't seen the account recently. No
format detection, no drift warning: with atomic dual-key
writes both keys present is the normal state for a
fully-resolved binding.
User-typed identifiers (CLI `<account>` arguments, URL/credential
user names) classify themselves through a new factory on
`MicrosoftAccount`:
MicrosoftAccount.FromIdentifier(string)
`FromIdentifier` recognises an MSAL Entra `HomeAccountId` shape
positively — an `<object-id>.<tenant-id>` pair split on the LAST
`.` (mirroring `Microsoft.Identity.Client.AccountId.ParseFromString`,
which permits an object id to contain dots in B2C / guest
scenarios) where the tenant-id suffix is a well-formed GUID. The
object-id prefix is only required to be non-empty, since MSAL
itself doesn't validate it as a GUID and exotic-but-real object
ids exist. Anything else (including bare words and ADFS-shape
single tokens, which are indistinguishable from a username)
routes into the `UserName` slot. Keeping the heuristic on the
account type lets the provider stay out of the classification
business and gives future consumers (eg. a picker UI) one place
to reuse. Tests on the factory cover both classic Entra
(GUID.GUID) and non-GUID-object-id shapes, the ambiguous-as-UPN
cases, malformed-tenant rejection, and null/whitespace rejection.
`bind`/`unbind` are reshaped to match:
azure-repos bind <account> (--tenant <id> | --org <name>) [--local]
azure-repos unbind (--tenant <id> | --org <name>) [--local]
`<account>` is resolved against the MSAL cache: if a cached
account matches by UPN or HomeAccountId, we persist both fields
of that cached account; otherwise we warn and persist the value
classified by `MicrosoftAccount.FromIdentifier`. That keeps a
working `login`-then-`bind` flow precise while still letting
users pre-stage bindings before the matching login happens.
On the credential paths:
- `get` derives a tenant id from the resolved Microsoft
authentication authority (parsing the
`login.microsoftonline.com/<tenant>` URL form) and feeds it
into `ResolveAccountBinding`. The returned `IMicrosoftAccount`
is passed directly to `IMicrosoftAuthentication.GetTokenForUserAsync`,
whose HomeAccountId-first / UPN-fallback resolution handles
either shape natively. When no binding matches the result is
`null` — the existing interactive sign-in path handles it from
there. Inline URL/credential-supplied user names go through
`MicrosoftAccount.FromIdentifier` so a user can pin a clone
to a specific MSAL account by either UPN or HomeAccountId. A
future PR (state[]/continue) will layer optimistic
auto-routing on top, with a safe loop-breaking story.
- `store` matches the incoming user name against the MSAL cache
by either UPN or HomeAccountId and binds the resolved account;
on a cache miss it falls back to a single-field binding
classified the same way.
- `erase` clears whichever level (local first, else global) had
a binding for the org. Tenant bindings are left alone — they
cover orgs other than the failing one.
`list-bindings` learns the new shape: bindings group under a
heading derived from the scope (`dev.azure.com/<org>` for Org,
`login.microsoftonline.com/<tenant-id>` for Tenant) with a
`(global)`/`(local)` row each. The bound account is displayed
UPN-first, falling back to HomeAccountId for HomeAccountId-only
bindings. The `--show-remotes` and `--verbose` flags keep their
existing semantics.
The `NoInherit` sentinel from the old binding manager is dropped.
It was a workaround for "I want this clone to never inherit my
global binding"; with bindings now resolved by an explicit
hierarchy, a per-clone "always prompt" toggle is better delivered
through the picker UI work rather than as a magic empty string.
Tests cover the new storage shape (HomeAccountId-only, UPN-only,
both fields), legacy `.username`-only read fallback at both
Tenant and Org scopes, scope/level resolution precedence, and
local-vs-global enumeration boundaries. The existing
`GetCredentialAsync` tests are updated for the new mock surface
(`GetAccount(scope)` returning an `IMicrosoftAccount` instead of
`GetBinding(orgName)` returning a string), and a new
`DevAzureUrlHomeAccountId` test pins the URL-user classification.
Assisted-by: Claude Opus 4.7
Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>1 parent cdbd055 commit 0d5cfcb
8 files changed
Lines changed: 936 additions & 1190 deletions
File tree
Lines changed: 65 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
73 | 138 | | |
Lines changed: 49 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
184 | 184 | | |
185 | 185 | | |
186 | 186 | | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
187 | 236 | | |
188 | 237 | | |
189 | 238 | | |
| |||
0 commit comments