Skip to content

Commit c8315db

Browse files
authored
Support PostgreSQL role assignments on app specific managed identity (#8209)
* Support PostgreSQL role assignments on app specific managed identity Adding role assignment support for PostgreSQL following the pattern set in #8140 Contributes to #6161 * Fix publisher tests * Add ACA RunMode test
1 parent f2d4896 commit c8315db

File tree

18 files changed

+415
-511
lines changed

18 files changed

+415
-511
lines changed

playground/AzureContainerApps/AzureContainerApps.AppHost/infra.module.bicep

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ resource cae_Contributor 'Microsoft.Authorization/roleAssignments@2022-04-01' =
7575
properties: {
7676
principalId: userPrincipalId
7777
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')
78-
principalType: 'ServicePrincipal'
7978
}
8079
scope: cae
8180
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using System.Text;
2+
using System.Text.Json;
3+
using Azure.Core;
4+
using Azure.Identity;
5+
using Npgsql;
6+
7+
/// <summary>
8+
/// Extension methods for NpgsqlDataSourceBuilder to enable Entra authentication with Azure DB for PostgreSQL.
9+
/// This class provides methods to configure NpgsqlDataSourceBuilder to use Entra authentication, handling token
10+
/// acquisition and connection setup. It is not specific to this repository and can be used in any project that
11+
/// requires Entra authentication with Azure DB for PostgreSQL.
12+
/// </summary>
13+
/// <example>
14+
/// Example usage:
15+
/// <code>
16+
/// var dataSourceBuilder = new NpgsqlDataSourceBuilder("{connection string}");
17+
/// dataSourceBuilder.UseEntraAuthentication();
18+
/// var dataSource = dataSourceBuilder.Build();
19+
/// </code>
20+
/// </example>
21+
public static class NpgsqlDataSourceBuilderExtensions
22+
{
23+
private static readonly TokenRequestContext s_azureDBForPostgresTokenRequestContext = new(["https://ossrdbms-aad.database.windows.net/.default"]);
24+
25+
/// <summary>
26+
/// Configures the NpgsqlDataSourceBuilder to use Entra authentication.
27+
/// </summary>
28+
/// <param name="dataSourceBuilder">The NpgsqlDataSourceBuilder instance.</param>
29+
/// <param name="credential">The TokenCredential to use for authentication. If not provided, DefaultAzureCredential will be used.</param>
30+
/// <returns>The configured NpgsqlDataSourceBuilder instance.</returns>
31+
public static NpgsqlDataSourceBuilder UseEntraAuthentication(this NpgsqlDataSourceBuilder dataSourceBuilder, TokenCredential? credential = default)
32+
{
33+
credential ??= new DefaultAzureCredential();
34+
35+
if (dataSourceBuilder.ConnectionStringBuilder.Username == null)
36+
{
37+
var token = credential.GetToken(s_azureDBForPostgresTokenRequestContext, default);
38+
SetUsernameFromToken(dataSourceBuilder, token.Token);
39+
}
40+
41+
if (dataSourceBuilder.ConnectionStringBuilder.Password == null)
42+
{
43+
SetPasswordProvider(dataSourceBuilder, credential, s_azureDBForPostgresTokenRequestContext);
44+
}
45+
46+
return dataSourceBuilder;
47+
}
48+
49+
private static void SetPasswordProvider(NpgsqlDataSourceBuilder dataSourceBuilder, TokenCredential credential, TokenRequestContext tokenRequestContext)
50+
{
51+
dataSourceBuilder.UsePasswordProvider(_ =>
52+
{
53+
var token = credential.GetToken(tokenRequestContext, default);
54+
return token.Token;
55+
}, async (_, ct) =>
56+
{
57+
var token = await credential.GetTokenAsync(tokenRequestContext, ct).ConfigureAwait(false);
58+
return token.Token;
59+
});
60+
}
61+
62+
private static void SetUsernameFromToken(NpgsqlDataSourceBuilder dataSourceBuilder, string token)
63+
{
64+
var username = TryGetUsernameFromToken(token);
65+
66+
if (username != null)
67+
{
68+
dataSourceBuilder.ConnectionStringBuilder.Username = username;
69+
}
70+
else
71+
{
72+
throw new InvalidOperationException("Could not determine username from token claims");
73+
}
74+
}
75+
76+
private static string? TryGetUsernameFromToken(string jwtToken)
77+
{
78+
// Split the token into its parts (Header, Payload, Signature)
79+
var tokenParts = jwtToken.Split('.');
80+
if (tokenParts.Length != 3)
81+
{
82+
return null;
83+
}
84+
85+
// The payload is the second part, Base64Url encoded
86+
var payload = tokenParts[1];
87+
88+
// Add padding if necessary
89+
payload = AddBase64Padding(payload);
90+
91+
// Decode the payload from Base64Url
92+
var decodedBytes = Convert.FromBase64String(payload);
93+
var decodedPayload = Encoding.UTF8.GetString(decodedBytes);
94+
95+
// Parse the decoded payload as JSON
96+
var payloadJson = JsonSerializer.Deserialize<JsonElement>(decodedPayload);
97+
98+
// Try to get the username from 'upn', 'preferred_username', or 'unique_name' claims
99+
if (payloadJson.TryGetProperty("upn", out var upn))
100+
{
101+
return upn.GetString();
102+
}
103+
else if (payloadJson.TryGetProperty("preferred_username", out var preferredUsername))
104+
{
105+
return preferredUsername.GetString();
106+
}
107+
else if (payloadJson.TryGetProperty("unique_name", out var uniqueName))
108+
{
109+
return uniqueName.GetString();
110+
}
111+
112+
return null;
113+
}
114+
115+
private static string AddBase64Padding(string base64) => (base64.Length % 4) switch
116+
{
117+
2 => base64 + "==",
118+
3 => base64 + "=",
119+
_ => base64,
120+
};
121+
}

playground/PostgresEndToEnd/PostgresEndToEnd.ApiService/PostgresEndToEnd.ApiService.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<PackageReference Include="Npgsql.DependencyInjection" VersionOverride="$(Npgsql8Version)" />
1313
<PackageReference Include="Npgsql.OpenTelemetry" VersionOverride="$(Npgsql8Version)" />
1414
<ProjectReference Include="..\..\Playground.ServiceDefaults\Playground.ServiceDefaults.csproj" />
15+
<PackageReference Include="Azure.Identity" />
1516
</ItemGroup>
1617

1718
</Project>

playground/PostgresEndToEnd/PostgresEndToEnd.ApiService/Program.cs

Lines changed: 12 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,29 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using Microsoft.EntityFrameworkCore;
5+
using Npgsql;
56

67
var builder = WebApplication.CreateBuilder(args);
78

89
builder.AddServiceDefaults();
910

10-
builder.AddNpgsqlDbContext<MyDb1Context>("db1");
11-
builder.AddNpgsqlDbContext<MyDb2Context>("db2");
12-
builder.AddNpgsqlDbContext<MyDb3Context>("db3");
13-
builder.AddNpgsqlDbContext<MyDb4Context>("db4");
14-
builder.AddNpgsqlDbContext<MyDb5Context>("db5");
15-
builder.AddNpgsqlDbContext<MyDb6Context>("db6");
16-
builder.AddNpgsqlDbContext<MyDb7Context>("db7");
11+
var dsBuilder = new NpgsqlDataSourceBuilder(builder.Configuration.GetConnectionString("db1"));
12+
dsBuilder.UseEntraAuthentication();
1713

18-
var connectionString = builder.Configuration.GetConnectionString("db8");
19-
builder.Services.AddDbContextPool<MyDb8Context>(dbContextOptionsBuilder => dbContextOptionsBuilder.UseNpgsql(connectionString));
20-
builder.EnrichNpgsqlDbContext<MyDb8Context>();
21-
22-
builder.AddNpgsqlDbContext<MyDb9Context>("db9");
14+
builder.AddNpgsqlDbContext<MyDb1Context>("db1",
15+
configureDbContextOptions: (options) => options.UseNpgsql(dsBuilder.Build()));
2316

2417
var app = builder.Build();
2518

2619
app.MapDefaultEndpoints();
27-
app.MapGet("/", async (MyDb1Context db1Context, MyDb2Context db2Context, MyDb3Context db3Context, MyDb4Context db4Context, MyDb5Context db5Context, MyDb6Context db6Context, MyDb7Context db7Context, MyDb8Context db8Context, MyDb9Context db9Context) =>
20+
var firstTime = true;
21+
app.MapGet("/", async (MyDb1Context db1Context) =>
2822
{
29-
// You wouldn't normally do this on every call,
30-
// but doing it here just to make this simple.
31-
db1Context.Database.EnsureCreated();
32-
db2Context.Database.EnsureCreated();
33-
db3Context.Database.EnsureCreated();
34-
db4Context.Database.EnsureCreated();
35-
db5Context.Database.EnsureCreated();
36-
db6Context.Database.EnsureCreated();
37-
db7Context.Database.EnsureCreated();
38-
db8Context.Database.EnsureCreated();
39-
db9Context.Database.EnsureCreated();
23+
if (firstTime)
24+
{
25+
firstTime = false;
26+
db1Context.Database.EnsureCreated();
27+
}
4028

4129
// We only work with db1Context for the rest of this
4230
// since we've proven connectivity to the others for now.
@@ -67,102 +55,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
6755
public DbSet<Entry> Entries { get; set; }
6856
}
6957

70-
public class MyDb2Context(DbContextOptions<MyDb2Context> options) : DbContext(options)
71-
{
72-
protected override void OnModelCreating(ModelBuilder modelBuilder)
73-
{
74-
base.OnModelCreating(modelBuilder);
75-
76-
modelBuilder.Entity<Entry>().HasKey(e => e.Id);
77-
}
78-
79-
public DbSet<Entry> Entries { get; set; }
80-
}
81-
82-
public class MyDb3Context(DbContextOptions<MyDb3Context> options) : DbContext(options)
83-
{
84-
protected override void OnModelCreating(ModelBuilder modelBuilder)
85-
{
86-
base.OnModelCreating(modelBuilder);
87-
88-
modelBuilder.Entity<Entry>().HasKey(e => e.Id);
89-
}
90-
91-
public DbSet<Entry> Entries { get; set; }
92-
}
93-
94-
public class MyDb4Context(DbContextOptions<MyDb4Context> options) : DbContext(options)
95-
{
96-
protected override void OnModelCreating(ModelBuilder modelBuilder)
97-
{
98-
base.OnModelCreating(modelBuilder);
99-
100-
modelBuilder.Entity<Entry>().HasKey(e => e.Id);
101-
}
102-
103-
public DbSet<Entry> Entries { get; set; }
104-
}
105-
106-
public class MyDb5Context(DbContextOptions<MyDb5Context> options) : DbContext(options)
107-
{
108-
protected override void OnModelCreating(ModelBuilder modelBuilder)
109-
{
110-
base.OnModelCreating(modelBuilder);
111-
112-
modelBuilder.Entity<Entry>().HasKey(e => e.Id);
113-
}
114-
115-
public DbSet<Entry> Entries { get; set; }
116-
}
117-
118-
public class MyDb6Context(DbContextOptions<MyDb6Context> options) : DbContext(options)
119-
{
120-
protected override void OnModelCreating(ModelBuilder modelBuilder)
121-
{
122-
base.OnModelCreating(modelBuilder);
123-
124-
modelBuilder.Entity<Entry>().HasKey(e => e.Id);
125-
}
126-
127-
public DbSet<Entry> Entries { get; set; }
128-
}
129-
130-
public class MyDb7Context(DbContextOptions<MyDb7Context> options) : DbContext(options)
131-
{
132-
protected override void OnModelCreating(ModelBuilder modelBuilder)
133-
{
134-
base.OnModelCreating(modelBuilder);
135-
136-
modelBuilder.Entity<Entry>().HasKey(e => e.Id);
137-
}
138-
139-
public DbSet<Entry> Entries { get; set; }
140-
}
141-
142-
public class MyDb8Context(DbContextOptions<MyDb8Context> options) : DbContext(options)
143-
{
144-
protected override void OnModelCreating(ModelBuilder modelBuilder)
145-
{
146-
base.OnModelCreating(modelBuilder);
147-
148-
modelBuilder.Entity<Entry>().HasKey(e => e.Id);
149-
}
150-
151-
public DbSet<Entry> Entries { get; set; }
152-
}
153-
154-
public class MyDb9Context(DbContextOptions<MyDb9Context> options) : DbContext(options)
155-
{
156-
protected override void OnModelCreating(ModelBuilder modelBuilder)
157-
{
158-
base.OnModelCreating(modelBuilder);
159-
160-
modelBuilder.Entity<Entry>().HasKey(e => e.Id);
161-
}
162-
163-
public DbSet<Entry> Entries { get; set; }
164-
}
165-
16658
public class Entry
16759
{
16860
public Guid Id { get; set; } = Guid.NewGuid();

playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/PostgresEndToEnd.AppHost.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
@@ -15,6 +15,7 @@
1515

1616
<ItemGroup>
1717
<AspireProjectOrPackageReference Include="Aspire.Hosting.AppHost" />
18+
<AspireProjectOrPackageReference Include="Aspire.Hosting.Azure.PostgreSQL" />
1819
<AspireProjectOrPackageReference Include="Aspire.Hosting.PostgreSQL" />
1920

2021
<ProjectReference Include="..\PostgresEndToEnd.ApiService\PostgresEndToEnd.ApiService.csproj" />

playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/Program.cs

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,14 @@
33

44
var builder = DistributedApplication.CreateBuilder(args);
55

6-
// Abstract resources.
7-
var db1 = builder.AddPostgres("pg1").WithPgAdmin().AddDatabase("db1");
8-
var db2 = builder.AddPostgres("pg2").WithPgAdmin().AddDatabase("db2");
9-
var pg3 = builder.AddPostgres("pg3").WithPgAdmin();
10-
var db3 = pg3.AddDatabase("db3");
11-
var db4 = pg3.AddDatabase("db4");
12-
13-
// Containerized resources.
14-
var db5 = builder.AddPostgres("pg4").WithPgAdmin().PublishAsContainer().AddDatabase("db5");
15-
var db6 = builder.AddPostgres("pg5").WithPgAdmin().PublishAsContainer().AddDatabase("db6");
16-
var pg6 = builder.AddPostgres("pg6").WithPgAdmin(c => c.WithHostPort(8999)).PublishAsContainer();
17-
var db7 = pg6.AddDatabase("db7");
18-
var db8 = pg6.AddDatabase("db8");
19-
var db9 = pg6.AddDatabase("db9", "db8"); // different connection string (db9) on same database as db8
20-
21-
// External resources.
22-
var db10 = builder.AddPostgres("pg10").WithPgAdmin().PublishAsConnectionString().AddDatabase("db10");
23-
24-
var db11 = builder.AddPostgres("pg11").WithPgWeb().AddDatabase("postgres");
6+
var db1 = builder.AddAzurePostgresFlexibleServer("pg")
7+
.RunAsContainer()
8+
.AddDatabase("db1");
259

2610
builder.AddProject<Projects.PostgresEndToEnd_ApiService>("api")
2711
.WithExternalHttpEndpoints()
28-
.WithReference(db1)
29-
.WithReference(db2)
30-
.WithReference(db3)
31-
.WithReference(db4)
32-
.WithReference(db5)
33-
.WithReference(db6)
34-
.WithReference(db7)
35-
.WithReference(db8)
36-
.WithReference(db9)
37-
.WithReference(db10)
38-
.WithReference(db11);
12+
.WithReference(db1).WaitFor(db1);
13+
3914
#if !SKIP_DASHBOARD_REFERENCE
4015
// This project is only added in playground projects to support development/debugging
4116
// of the dashboard. It is not required in end developer code. Comment out this code

0 commit comments

Comments
 (0)