Skip to content

Commit 475e103

Browse files
committed
Add db auth via app reg
1 parent d1ac44b commit 475e103

8 files changed

Lines changed: 478 additions & 26 deletions

Makefile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
run:
22
$(MAKE) -j2 run-api run-frontend
33

4+
run-devdb:
5+
$(MAKE) -j2 run-api-devdb run-frontend
6+
47
run-api:
58
dotnet run --project api
69

10+
run-api-devdb:
11+
ASPNETCORE_ENVIRONMENT=Local \
12+
Database__UseInMemoryDatabase=false \
13+
Database__AllowedAuthMethods__0=AppRegIdentity \
14+
Database__AllowedAuthMethods__1="" \
15+
dotnet run --project api
16+
717
run-frontend:
818
@echo "Waiting for backend on port 8100..."
919
@while ! curl -sf http://localhost:8100/api/health > /dev/null 2>&1; do sleep 0.5; done

api/.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Storage
2+
Storage__ThermalReferenceStorageAccount=saradevthermalref
3+
4+
# PostgreSQL server name (without .postgres.database.azure.com suffix)
5+
Database__Server=robotics-dev-psql-server
6+
7+
# Database name on the server
8+
Database__PostgresDatabase=sara
9+
10+
# AAD user name configured on the PostgreSQL server for this app
11+
Database__User=sara-dev
12+
13+
# Thermal reference storage account
14+
Storage__ThermalReferenceStorageAccount=saradevthermalref
15+

api/Configurations/CustomServiceConfigurations.cs

Lines changed: 258 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@
77
using Microsoft.EntityFrameworkCore;
88
using Microsoft.OpenApi;
99
using MQTTnet.Extensions.ManagedClient;
10+
using Npgsql;
1011

1112
namespace api.Configurations;
1213

1314
public static class CustomServiceConfigurations
1415
{
16+
private const string AzurePostgresScope =
17+
"https://ossrdbms-aad.database.windows.net/.default";
18+
1519
/// <summary>
1620
/// Build a <see cref="TokenCredential"/> for authenticating against Azure resources.
1721
///
@@ -253,17 +257,51 @@ public static TokenCredential CreateRuntimeCredential(IConfiguration config)
253257
return new ChainedTokenCredential([.. credentials]);
254258
}
255259

260+
/// <summary>
261+
/// Configure the database connection for the application.
262+
///
263+
/// When <c>Database:UseInMemoryDatabase</c> is <c>true</c>, an in-memory SQLite database
264+
/// is used (local development).
265+
///
266+
/// Otherwise, the method reads <c>Database:AllowedAuthMethods</c> — an ordered list whose
267+
/// entries may be <c>"AppRegIdentity"</c> and/or <c>"ConnectionString"</c> (case-insensitive).
268+
/// Methods are tried in the order specified; the first one that succeeds wins.
269+
///
270+
/// <list type="bullet">
271+
/// <item>
272+
/// <term>AppRegIdentity</term>
273+
/// <description>
274+
/// Acquires an Entra ID (Azure AD) access token via <see cref="CreateRuntimeCredential"/>
275+
/// and connects to PostgreSQL using <c>UsePeriodicPasswordProvider</c> (token refreshed
276+
/// every 55 minutes). Requires <c>Database:Server</c>, <c>Database:PostgresDatabase</c>
277+
/// and <c>Database:User</c> to be configured.
278+
/// </description>
279+
/// </item>
280+
/// <item>
281+
/// <term>ConnectionString</term>
282+
/// <description>
283+
/// Uses a traditional connection string from <c>Database:postgresConnectionString</c>
284+
/// (typically loaded from Azure Key Vault).
285+
/// </description>
286+
/// </item>
287+
/// </list>
288+
///
289+
/// If the list is empty or absent, defaults to <c>["ConnectionString"]</c> for backward
290+
/// compatibility.
291+
/// </summary>
256292
public static IServiceCollection ConfigureDatabase(
257293
this IServiceCollection services,
258294
IConfiguration configuration
259295
)
260296
{
297+
Console.WriteLine("Configuring Database...");
261298
bool useInMemoryDatabase = configuration
262299
.GetSection("Database")
263300
.GetValue<bool>("UseInMemoryDatabase");
264301

265302
if (useInMemoryDatabase)
266303
{
304+
Console.WriteLine("Using InMemory Database");
267305
DbContextOptionsBuilder dbBuilder = new DbContextOptionsBuilder<SaraDbContext>();
268306
string sqlConnectionString = new SqliteConnectionStringBuilder
269307
{
@@ -290,24 +328,231 @@ IConfiguration configuration
290328
}
291329
else
292330
{
293-
string? connection = configuration["Database:postgresConnectionString"];
294-
// Setting splitting behavior explicitly to avoid warning
295-
services.AddDbContext<SaraDbContext>(
296-
options =>
297-
options.UseNpgsql(
298-
connection,
299-
o =>
300-
{
301-
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery);
302-
o.EnableRetryOnFailure();
303-
}
304-
),
305-
ServiceLifetime.Transient
331+
string[] allowedDbAuthMethods =
332+
configuration.GetSection("Database:AllowedAuthMethods").Get<string[]>() ?? [];
333+
if (allowedDbAuthMethods.Length == 0)
334+
{
335+
allowedDbAuthMethods = ["ConnectionString"];
336+
}
337+
338+
Console.WriteLine(
339+
$"Database auth methods to try (in order): {string.Join(", ", allowedDbAuthMethods)}"
306340
);
341+
342+
var errors = new List<(string method, Exception ex)>();
343+
bool configured = false;
344+
345+
foreach (string method in allowedDbAuthMethods)
346+
{
347+
if (configured)
348+
break;
349+
350+
if (string.Equals(method, "AppRegIdentity", StringComparison.OrdinalIgnoreCase))
351+
{
352+
try
353+
{
354+
Console.WriteLine(
355+
"Trying AppRegIdentity (Entra ID token) for PostgreSQL..."
356+
);
357+
ConfigureDatabaseWithAppRegIdentity(services, configuration);
358+
Console.WriteLine("AppRegIdentity configured successfully.");
359+
configured = true;
360+
}
361+
catch (Exception ex)
362+
{
363+
Console.WriteLine(
364+
$"AppRegIdentity failed: {ex.GetType().Name}: {ex.Message}"
365+
);
366+
errors.Add(("AppRegIdentity", ex));
367+
}
368+
}
369+
else if (
370+
string.Equals(method, "ConnectionString", StringComparison.OrdinalIgnoreCase)
371+
)
372+
{
373+
try
374+
{
375+
Console.WriteLine(
376+
"Trying ConnectionString (Key Vault) for PostgreSQL..."
377+
);
378+
ConfigureDatabaseWithConnectionString(services, configuration);
379+
Console.WriteLine("ConnectionString configured successfully.");
380+
configured = true;
381+
}
382+
catch (Exception ex)
383+
{
384+
Console.WriteLine(
385+
$"ConnectionString failed: {ex.GetType().Name}: {ex.Message}"
386+
);
387+
errors.Add(("ConnectionString", ex));
388+
}
389+
}
390+
else
391+
{
392+
Console.WriteLine(
393+
$"Unknown database auth method '{method}' in Database:AllowedAuthMethods; "
394+
+ "expected 'AppRegIdentity' or 'ConnectionString'."
395+
);
396+
}
397+
}
398+
399+
if (!configured)
400+
{
401+
var summary = string.Join(
402+
"; ",
403+
errors.Select(e => $"{e.method}: {e.ex.GetType().Name}: {e.ex.Message}")
404+
);
405+
throw new InvalidOperationException(
406+
"All database authentication methods failed. "
407+
+ $"Tried: {string.Join(", ", allowedDbAuthMethods)}. Details: {summary}"
408+
);
409+
}
307410
}
411+
308412
return services;
309413
}
310414

415+
/// <summary>
416+
/// Configure PostgreSQL using Entra ID (Azure AD) token-based authentication via the
417+
/// app registration identity. The token is used as the PostgreSQL password and refreshed
418+
/// periodically via <c>UsePeriodicPasswordProvider</c>.
419+
/// </summary>
420+
private static void ConfigureDatabaseWithAppRegIdentity(
421+
IServiceCollection services,
422+
IConfiguration configuration
423+
)
424+
{
425+
var server =
426+
configuration["Database:Server"]
427+
?? throw new InvalidOperationException(
428+
"Database:Server is required for AppRegIdentity auth."
429+
);
430+
var postgresDb =
431+
configuration["Database:PostgresDatabase"]
432+
?? throw new InvalidOperationException(
433+
"Database:PostgresDatabase is required for AppRegIdentity auth."
434+
);
435+
var dbUser =
436+
configuration["Database:User"]
437+
?? throw new InvalidOperationException(
438+
"Database:User is required for AppRegIdentity auth."
439+
);
440+
441+
var credential = CreateRuntimeCredential(configuration);
442+
443+
// Probe: acquire an initial token to verify connectivity before registering the DbContext.
444+
Console.WriteLine("Requesting Entra ID token via credential...");
445+
var tokenRequestContext = new TokenRequestContext([AzurePostgresScope]);
446+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
447+
AccessToken token;
448+
try
449+
{
450+
token = credential.GetToken(tokenRequestContext, cts.Token);
451+
}
452+
catch (OperationCanceledException oce)
453+
{
454+
throw new TimeoutException(
455+
"Timed out acquiring Entra ID token for PostgreSQL.",
456+
oce
457+
);
458+
}
459+
Console.WriteLine("Entra ID token acquired successfully.");
460+
461+
var baseConnString = new NpgsqlConnectionStringBuilder
462+
{
463+
Host = $"{server}.postgres.database.azure.com",
464+
Database = postgresDb,
465+
Username = dbUser,
466+
SslMode = SslMode.VerifyFull,
467+
}.ToString();
468+
469+
int databaseTimeout = GetDatabaseTimeout(configuration);
470+
471+
services.AddDbContext<SaraDbContext>(
472+
options =>
473+
options.UseNpgsql(
474+
baseConnString,
475+
o =>
476+
{
477+
o.ConfigureDataSource(ds =>
478+
{
479+
var dsCredential = CreateRuntimeCredential(configuration);
480+
ds.UsePeriodicPasswordProvider(
481+
async (_, ct) =>
482+
{
483+
using var tokenCts = new CancellationTokenSource(
484+
TimeSpan.FromSeconds(5)
485+
);
486+
var accessToken = await dsCredential.GetTokenAsync(
487+
new TokenRequestContext([AzurePostgresScope]),
488+
CancellationTokenSource
489+
.CreateLinkedTokenSource(ct, tokenCts.Token)
490+
.Token
491+
);
492+
return accessToken.Token;
493+
},
494+
successRefreshInterval: TimeSpan.FromMinutes(55),
495+
failureRefreshInterval: TimeSpan.FromSeconds(5)
496+
);
497+
});
498+
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery);
499+
o.EnableRetryOnFailure();
500+
o.CommandTimeout(databaseTimeout);
501+
}
502+
),
503+
ServiceLifetime.Transient
504+
);
505+
}
506+
507+
/// <summary>
508+
/// Configure PostgreSQL using a traditional connection string (typically loaded from
509+
/// Azure Key Vault via the <c>Database:postgresConnectionString</c> configuration key).
510+
/// </summary>
511+
private static void ConfigureDatabaseWithConnectionString(
512+
IServiceCollection services,
513+
IConfiguration configuration
514+
)
515+
{
516+
string? connection = configuration["Database:postgresConnectionString"];
517+
if (string.IsNullOrEmpty(connection))
518+
{
519+
throw new InvalidOperationException(
520+
"Database:postgresConnectionString is empty or missing. "
521+
+ "Ensure the connection string is loaded (e.g. from Azure Key Vault)."
522+
);
523+
}
524+
525+
int databaseTimeout = GetDatabaseTimeout(configuration);
526+
527+
// Setting splitting behavior explicitly to avoid warning
528+
services.AddDbContext<SaraDbContext>(
529+
options =>
530+
options.UseNpgsql(
531+
connection,
532+
o =>
533+
{
534+
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery);
535+
o.EnableRetryOnFailure();
536+
o.CommandTimeout(databaseTimeout);
537+
}
538+
),
539+
ServiceLifetime.Transient
540+
);
541+
}
542+
543+
private static int GetDatabaseTimeout(IConfiguration configuration)
544+
{
545+
var timeoutValue = configuration["Database:Timeout"];
546+
if (
547+
!string.IsNullOrEmpty(timeoutValue)
548+
&& int.TryParse(timeoutValue, out var parsedTimeout)
549+
)
550+
{
551+
return parsedTimeout;
552+
}
553+
return 30;
554+
}
555+
311556
public static IServiceCollection ConfigureSwagger(
312557
this IServiceCollection services,
313558
IConfiguration configuration

0 commit comments

Comments
 (0)