-
Notifications
You must be signed in to change notification settings - Fork 349
Expand file tree
/
Copy pathPostgreSqlExecutor.cs
More file actions
295 lines (261 loc) · 14.3 KB
/
Copy pathPostgreSqlExecutor.cs
File metadata and controls
295 lines (261 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
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
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Data.Common;
using System.Net;
using Azure.Core;
using Azure.DataApiBuilder.Config;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Core.Models;
using Azure.DataApiBuilder.Service.Exceptions;
using Azure.Identity;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace Azure.DataApiBuilder.Core.Resolvers
{
/// <summary>
/// Specialized QueryExecutor for PostgreSql mainly providing methods to
/// handle connecting to the database with a managed identity.
/// for more info: https://learn.microsoft.com/EN-us/azure/postgresql/single-server/how-to-connect-with-managed-identity
/// </summary>
public class PostgreSqlQueryExecutor : QueryExecutor<NpgsqlConnection>
{
// This is the same scope for any Azure Database for PostgreSQL that is
// required to request a default azure credential access token
// for a managed identity.
public const string DATABASE_SCOPE = @"https://ossrdbms-aad.database.windows.net/.default";
/// <summary>
/// The managed identity Access Token string obtained
/// from the configuration controller.
/// Key: datasource name, Value: access token for this datasource.
/// </summary>
private Dictionary<string, string?> _accessTokensFromConfiguration;
public DefaultAzureCredential AzureCredential { get; set; } = new(); // CodeQL [SM05137]: DefaultAzureCredential will use Managed Identity if available or fallback to default.
/// <summary>
/// The PostgreSql specific connection string builders.
/// Key: datasource name, Value: connection string builder for this datasource.
/// </summary>
public override IDictionary<string, DbConnectionStringBuilder> ConnectionStringBuilders
=> base.ConnectionStringBuilders;
/// <summary>
/// The saved cached access token obtained from DefaultAzureCredentials
/// representing a managed identity.
/// </summary>
private AccessToken? _defaultAccessToken;
/// <summary>
/// DatasourceName to boolean value indicating if access token should be set for db.
/// </summary>
private Dictionary<string, bool> _dataSourceAccessTokenUsage;
private readonly RuntimeConfigProvider _runtimeConfigProvider;
public PostgreSqlQueryExecutor(
RuntimeConfigProvider runtimeConfigProvider,
DbExceptionParser dbExceptionParser,
ILogger<IQueryExecutor> logger,
IHttpContextAccessor httpContextAccessor,
HotReloadEventHandler<HotReloadEventArgs>? handler = null)
: base(dbExceptionParser,
logger,
runtimeConfigProvider,
httpContextAccessor,
handler)
{
_dataSourceAccessTokenUsage = new Dictionary<string, bool>();
_accessTokensFromConfiguration = runtimeConfigProvider.ManagedIdentityAccessToken;
_runtimeConfigProvider = runtimeConfigProvider;
ConfigurePostgreSqlQueryExecutor();
}
/// <summary>
/// Configure during construction or a hot-reload scenario.
/// </summary>
private void ConfigurePostgreSqlQueryExecutor()
{
IEnumerable<KeyValuePair<string, DataSource>> postgresqldbs = _runtimeConfigProvider.GetConfig().GetDataSourceNamesToDataSourcesIterator().Where(x => x.Value.DatabaseType == DatabaseType.PostgreSQL);
foreach ((string dataSourceName, DataSource dataSource) in postgresqldbs)
{
NpgsqlConnectionStringBuilder builder = new(dataSource.ConnectionString);
if (_runtimeConfigProvider.IsLateConfigured)
{
builder.SslMode = SslMode.VerifyFull;
}
ConnectionStringBuilders.TryAdd(dataSourceName, builder);
MsSqlOptions? msSqlOptions = dataSource.GetTypedOptions<MsSqlOptions>();
_dataSourceAccessTokenUsage[dataSourceName] = ShouldManagedIdentityAccessBeAttempted(builder);
}
}
/// <summary>
/// Modifies the properties of the supplied connection string to support managed identity access.
/// In the case of Postgres, if a default managed identity needs to be used, the password in the
/// connection needs to be replaced with the default access token.
/// </summary>
/// <param name="conn">The supplied connection to modify for managed identity access.</param>
/// <param name="dataSourceName">Name of datasource for which to set access token. Default dbName taken from config if null</param>
public override async Task SetManagedIdentityAccessTokenIfAnyAsync(DbConnection conn, string dataSourceName)
{
// using default datasource name for first db - maintaining backward compatibility for single db scenario.
if (string.IsNullOrEmpty(dataSourceName))
{
dataSourceName = ConfigProvider.GetConfig().DefaultDataSourceName;
}
_dataSourceAccessTokenUsage.TryGetValue(dataSourceName, out bool setAccessToken);
// Only attempt to get the access token if the connection string is in the appropriate format
if (setAccessToken)
{
NpgsqlConnection sqlConn = (NpgsqlConnection)conn;
// If the configuration controller provided a managed identity access token use that,
// else use the default saved access token if still valid.
// Get a new token only if the saved token is null or expired.
_accessTokensFromConfiguration.TryGetValue(dataSourceName, out string? accessTokenFromController);
string? accessToken = accessTokenFromController ??
(IsDefaultAccessTokenValid() ?
((AccessToken)_defaultAccessToken!).Token :
await GetAccessTokenAsync(dataSourceName));
if (accessToken is not null)
{
NpgsqlConnectionStringBuilder newConnectionString = new(sqlConn.ConnectionString)
{
Password = accessToken
};
sqlConn.ConnectionString = newConnectionString.ToString();
}
}
}
/// <summary>
/// Determines if managed identity access should be attempted or not.
/// It should only be attempted if the password is not provided
/// </summary>
private static bool ShouldManagedIdentityAccessBeAttempted(NpgsqlConnectionStringBuilder builder)
{
return string.IsNullOrEmpty(builder.Password);
}
/// <inheritdoc/>
public override async Task<DbResultSet> GetMultipleResultSetsIfAnyAsync(
DbDataReader dbDataReader, List<string>? args = null)
{
// RS1: COUNT of rows matching PK (no policy) — used to distinguish
// "row doesn't exist" from "row exists but policy blocked".
DbResultSet resultSetWithCountOfRowsWithGivenPk = await ExtractResultSetFromDbDataReaderAsync(dbDataReader);
DbResultSetRow? resultSetRowWithCountOfRowsWithGivenPk = resultSetWithCountOfRowsWithGivenPk.Rows.FirstOrDefault();
int numOfRecordsWithGivenPK;
if (resultSetRowWithCountOfRowsWithGivenPk is not null &&
resultSetRowWithCountOfRowsWithGivenPk.Columns.TryGetValue(PostgresQueryBuilder.COUNT_ROWS_WITH_GIVEN_PK, out object? rowsWithGivenPK))
{
// PostgreSQL COUNT(*) returns Int64; convert to int.
numOfRecordsWithGivenPK = Convert.ToInt32(rowsWithGivenPK!);
}
else
{
throw new DataApiBuilderException(
message: $"Neither insert nor update could be performed.",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
}
// RS2: UPDATE result, or UPDATE+INSERT CTE result.
DbResultSet dbResultSet = await dbDataReader.NextResultAsync()
? await ExtractResultSetFromDbDataReaderAsync(dbDataReader)
: throw new DataApiBuilderException(
message: $"Neither insert nor update could be performed.",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
if (numOfRecordsWithGivenPK == 1) // Row existed — we attempted an UPDATE.
{
if (dbResultSet.Rows.Count == 0)
{
// Row exists but UPDATE returned no rows — update policy blocked it.
throw new DataApiBuilderException(
message: DataApiBuilderException.AUTHORIZATION_FAILURE,
statusCode: HttpStatusCode.Forbidden,
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure);
}
dbResultSet.ResultProperties.Add(SqlMutationEngine.IS_UPDATE_RESULT_SET, true);
}
else if (dbResultSet.Rows.Count == 0)
{
// Check whether IsFallbackToUpdate was set (passed as args[2]).
// If true, the row simply didn't exist — return 404 (same as MsSql's null-RS2 path).
// If false (or not present), the INSERT ran but create policy blocked it — return 403.
bool isFallbackToUpdate = args is not null && args.Count > 2
&& bool.TryParse(args[2], out bool fallback) && fallback;
if (isFallbackToUpdate)
{
if (args is not null && args.Count > 1)
{
string prettyPrintPk = args[0];
string entityName = args[1];
throw new DataApiBuilderException(
message: $"Cannot perform INSERT and could not find {entityName} " +
$"with primary key {prettyPrintPk} to perform UPDATE on.",
statusCode: HttpStatusCode.NotFound,
subStatusCode: DataApiBuilderException.SubStatusCodes.ItemNotFound);
}
throw new DataApiBuilderException(
message: $"Neither insert nor update could be performed.",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
}
// Row didn't exist but INSERT returned no rows — create policy blocked it.
throw new DataApiBuilderException(
message: DataApiBuilderException.AUTHORIZATION_FAILURE,
statusCode: HttpStatusCode.Forbidden,
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure);
}
return dbResultSet;
}
/// <summary>
/// Determines if the saved default azure credential's access token is valid and not expired.
/// </summary>
/// <returns>True if valid, false otherwise.</returns>
private bool IsDefaultAccessTokenValid()
{
return _defaultAccessToken is not null &&
((AccessToken)_defaultAccessToken).ExpiresOn.CompareTo(DateTimeOffset.Now) > 0;
}
/// <summary>
/// Tries to get an access token using DefaultAzureCredentials.
/// Catches any CredentialUnavailableException and logs only a warning
/// since this is best effort.
/// </summary>
/// <returns>The string representation of the access token if found,
/// null otherwise.</returns>
private async Task<string?> GetAccessTokenAsync(string dataSourceName)
{
bool firstAttemptAtDefaultAccessToken = _defaultAccessToken is null;
try
{
_defaultAccessToken =
await AzureCredential.GetTokenAsync(
new TokenRequestContext(new[] { DATABASE_SCOPE }));
}
// because there can be scenarios where password is not specified but
// default managed identity is not the intended method of authentication
// so a bunch of different exceptions could occur in that scenario
catch (Exception ex)
{
string messagePrefix = "{correlationId} No password detected in the connection string. Attempt to retrieve a managed identity access token using DefaultAzureCredential failed due to:\n{errorMessage}";
string messageSuffix = (firstAttemptAtDefaultAccessToken ? $"If authentication with DefaultAzureCrendential is not intended, this warning can be safely ignored." : string.Empty);
string message = messagePrefix + messageSuffix;
QueryExecutorLogger.LogWarning(
exception: ex,
message: message,
HttpContextExtensions.GetLoggerCorrelationId(HttpContextAccessor.HttpContext),
ex.Message);
// the config doesn't contain an identity token
// and a default identity token cannot be obtained
// so the application should not attempt to set the token
// for future conntions
// note though that if a default access token has been previously
// obtained successfully (firstAttemptAtDefaultAccessToken == false)
// this might be a transitory failure don't disable attempts to set
// the token
//
// disabling the attempts is useful in scenarios where the user
// has a valid connection string without a password in it
if (firstAttemptAtDefaultAccessToken)
{
_dataSourceAccessTokenUsage[dataSourceName] = false;
}
}
return _defaultAccessToken?.Token;
}
}
}