Lite: shared credential profiles — #1038 Phase B#1043
Merged
Conversation
A named credential profile (shared SQL login / Azure service principal /
managed identity) that many server entries reference, so one identity covers
a whole fleet without per-server secret entry.
Resolution is fail-closed and atomic-tuple: a server's auth comes ENTIRELY
from its profile or ENTIRELY from its own inline fields, never mixed. A
dangling/missing profile id THROWS "credential profile '{id}' not found"
rather than silently connecting under the server's own stale inline auth.
The CredentialService instance overloads on ServerConnection were removed so
every connection-string call site fails to compile until routed through the
new CredentialResolver (compiler-enforced sweep, not grep).
Acyclicity seam: IProfileLookup (implemented by ProfileManager) is late-injected
into ServerManager so CheckConnectionAsync resolves through the same fail-closed
static. Coupling stays one-way: ServerManager -> IProfileLookup <- ProfileManager,
and separately ProfileManager -> ServerManager (referential-integrity query).
Secret handling: profile secrets live ONLY in Windows Credential Manager under
profile_{id}; CredentialProfile has no secret property and profiles.json never
stores one. No new NuGet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What shipped
Phase B of #1038: a named credential profile (a shared SQL login, an Azure service principal, or a managed identity) that many server entries reference, so one identity covers a whole fleet without per-server secret entry. Composing Phase A's SP auth + a shared profile is the #1036 ask ("one identity across all my Azure SQL instances"). Lite only, no new NuGet, additive + backward compatible (profiles are opt-in;
CredentialProfileId == null= today's per-server behavior).New:
CredentialProfilemodel (Lite/Models/CredentialProfile.cs) — Id, Name, AuthType (SqlServer/ServicePrincipal/ManagedIdentity only), non-secret fields. No secret property.ProfileManager(Lite/Services/ProfileManager.cs) — shared-dirprofiles.jsonmirroringServerManager(WriteIndented,.bakrestore-on-corruption, lock, CRUD), secret viaprofile_{id},ImportProfilesFromFile, referential-integrity (in-use) query, implementsIProfileLookup.IProfileLookup(Lite/Services/IProfileLookup.cs),CredentialResolver(Lite/Services/CredentialResolver.cs).ManageCredentialProfilesDialog+EditCredentialProfileDialog(CRUD UI), launched from a "Credential Profiles…" button inManageServersWindow.Modified:
ServerConnection(resolution refactor + removed overloads + profile-awareHasStoredCredentials); the call-site sweep;ServerManager(IProfileLookupseam);MainWindow(two-phase wiring + import);AddServerDialog(profile picker + M-3 scrub + profile-aware edit-load); the migrated tests.Fail-closed, atomic-tuple resolution
Resolution produces ONE immutable tuple
(authType, username, password, azureClientId, miClientId)sourced entirely from the profile OR entirely from the server — never field-by-field mixed (so a profile secret can never combine with the server's staleAzureClientIdto build the wrong SP identity). WhenCredentialProfileId != null:GetCredential("profile_{id}"));"credential profile '{id}' not found"in a closed branch with NO fall-through to the server's ownAuthenticationType/GetCredential(Id)/this.AzureClientId/this.ManagedIdentityClientId. A dangling profile never silently connects under server-self auth.This is the real safety net behind the best-effort delete-constraint (the shared
%ProgramData%dir is multi-user-writable, so referential integrity is advisory).The acyclicity seam (IProfileLookup)
ServerManager.CheckConnectionAsyncbuilds its string insideServerManagerand can't depend on the resolver/ProfileManager(that recreates a cycle). InsteadServerManagerholds a late-injectedIProfileLookup(default null = today's behavior) and resolves through the SAME fail-closed static. Two-phase wiring inMainWindow.xaml.cs: buildServerManager→new ProfileManager(serverManager)→serverManager.ProfileLookup = profileManager. Coupling stays one-way:ServerManager → IProfileLookup ← ProfileManager, and separatelyProfileManager → ServerManager(for the in-use query).Compiler-enforced sweep
The
ServerConnection.GetConnectionString(CredentialService)/GetUtilityConnectionString(CredentialService)instance overloads were removed, so every connection-string call site failed to compile until routed throughCredentialResolver. All sites acrossSqlPlanFetcher,McpPlanTools,FinOpsTab,ExcludedDatabasesDialog,ServerTab.*(15),RemoteCollectorService, plusServerManager.CheckConnectionAsyncnow resolve through the same profile-or-self/fail-closed path. The downstreamnew SqlConnectionStringBuilder(connStr){…}re-parsers were left alone (they only override catalog/timeout, not auth). The MCP/SqlPlanFetcher paths route viaserverManager.CredentialResolver(the singleton whoseProfileLookupis wired), so they pick up profiles without a static-signature change.Security note
Profile secrets live only in Windows Credential Manager under
profile_{id};CredentialProfilehas no secret property andprofiles.jsonnever stores one (verified by a test asserting the password is absent from the on-disk JSON). Theprofile_namespace can't collide with bare-GUID server keys or the SMTP/webhook literals. Assigning a profile to a server unconditionally scrubs the per-server identity (DeleteCredential(server.Id)+ null Azure/MI fields) regardless of auth type (M-3), so a later "switch back to inline" can't resurrect a stale secret.Build + test (real results)
dotnet build Lite/PerformanceMonitorLite.csproj -c Debug— Build succeeded, 0 Warning(s), 0 Error(s).dotnet test Lite.Tests— Passed! Failed: 0, Passed: 349, Skipped: 0, Total: 349. Includes the migrated existing tests (off the removed overload, onto the resolver, keeping the SP-secret / MI-no-secret assertions) and 13 new profile tests, notably:Resolution_DanglingProfile_Throws_AndNeverFallsBackToServerSelfAuth(the HARD fail-closed test)Resolution_ProfileServicePrincipal_UsesProfileClientId_NotServerStaleAzureClientId(B-3)Resolution_ProfileLessServer_IsRegressionIdenticalHasStoredCredentials_ProfileBacked_ReflectsProfileSecret_NotServerKey/_DanglingProfile_IsFalseProfileManager_Add_Get_Persists_AndStoresSecretUnderProfileKey(asserts secret absent from JSON)ProfileManager_Delete_BlockedWhileServerReferences_AllowedAfterReassignSwitchToProfile_DeletesOrphanedPerServerSecretImport_RoundTrips_AndBuildTolerates_SecretAbsentImportedProfileMaintainer E2E (real Azure — gate, not run in CI)
profiles.jsonfrom a previous install; confirm secrets must be re-entered once via the profile editor.🤖 Generated with Claude Code