Skip to content

Lite: shared credential profiles — #1038 Phase B#1043

Merged
erikdarlingdata merged 1 commit into
devfrom
feature/1038-phase-b-credential-profiles
Jun 2, 2026
Merged

Lite: shared credential profiles — #1038 Phase B#1043
erikdarlingdata merged 1 commit into
devfrom
feature/1038-phase-b-credential-profiles

Conversation

@erikdarlingdata
Copy link
Copy Markdown
Owner

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:

  • CredentialProfile model (Lite/Models/CredentialProfile.cs) — Id, Name, AuthType (SqlServer/ServicePrincipal/ManagedIdentity only), non-secret fields. No secret property.
  • ProfileManager (Lite/Services/ProfileManager.cs) — shared-dir profiles.json mirroring ServerManager (WriteIndented, .bak restore-on-corruption, lock, CRUD), secret via profile_{id}, ImportProfilesFromFile, referential-integrity (in-use) query, implements IProfileLookup.
  • IProfileLookup (Lite/Services/IProfileLookup.cs), CredentialResolver (Lite/Services/CredentialResolver.cs).
  • ManageCredentialProfilesDialog + EditCredentialProfileDialog (CRUD UI), launched from a "Credential Profiles…" button in ManageServersWindow.

Modified: ServerConnection (resolution refactor + removed overloads + profile-aware HasStoredCredentials); the call-site sweep; ServerManager (IProfileLookup seam); 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 stale AzureClientId to build the wrong SP identity). When CredentialProfileId != null:

  • profile found → tuple entirely from the profile (its AuthType, its non-secret fields, secret from GetCredential("profile_{id}"));
  • profile missingthrows "credential profile '{id}' not found" in a closed branch with NO fall-through to the server's own AuthenticationType/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.CheckConnectionAsync builds its string inside ServerManager and can't depend on the resolver/ProfileManager (that recreates a cycle). Instead ServerManager holds a late-injected IProfileLookup (default null = today's behavior) and resolves through the SAME fail-closed static. Two-phase wiring in MainWindow.xaml.cs: build ServerManagernew ProfileManager(serverManager)serverManager.ProfileLookup = profileManager. Coupling stays one-way: ServerManager → IProfileLookup ← ProfileManager, and separately ProfileManager → 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 through CredentialResolver. All sites across SqlPlanFetcher, McpPlanTools, FinOpsTab, ExcludedDatabasesDialog, ServerTab.* (15), RemoteCollectorService, plus ServerManager.CheckConnectionAsync now resolve through the same profile-or-self/fail-closed path. The downstream new SqlConnectionStringBuilder(connStr){…} re-parsers were left alone (they only override catalog/timeout, not auth). The MCP/SqlPlanFetcher paths route via serverManager.CredentialResolver (the singleton whose ProfileLookup is wired), so they pick up profiles without a static-signature change.

Security note

Profile secrets live only in Windows Credential Manager under profile_{id}; CredentialProfile has no secret property and profiles.json never stores one (verified by a test asserting the password is absent from the on-disk JSON). The profile_ 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 DebugBuild succeeded, 0 Warning(s), 0 Error(s).

dotnet test Lite.TestsPassed! 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_IsRegressionIdentical
  • HasStoredCredentials_ProfileBacked_ReflectsProfileSecret_NotServerKey / _DanglingProfile_IsFalse
  • ProfileManager_Add_Get_Persists_AndStoresSecretUnderProfileKey (asserts secret absent from JSON)
  • ProfileManager_Delete_BlockedWhileServerReferences_AllowedAfterReassign
  • SwitchToProfile_DeletesOrphanedPerServerSecret
  • Import_RoundTrips_AndBuildTolerates_SecretAbsentImportedProfile

Maintainer E2E (real Azure — gate, not run in CI)

🤖 Generated with Claude Code

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>
@erikdarlingdata erikdarlingdata merged commit 0f1d577 into dev Jun 2, 2026
2 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.

1 participant