Skip to content

Commit eaaae18

Browse files
Merge branch 'main' into mcp-allowed-hosts-validation
2 parents 0fd59c2 + 2210289 commit eaaae18

11 files changed

Lines changed: 271 additions & 110 deletions

Dockerfile

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,78 @@ WORKDIR /src
77
COPY [".", "./"]
88
RUN dotnet build "./src/Service/Azure.DataApiBuilder.Service.csproj" -c Docker -o /out -r linux-x64
99

10-
FROM mcr.microsoft.com/dotnet/aspnet:10.0-azurelinux3.0 AS runtime
10+
# ---------------------------------------------------------------------------
11+
# Common runtime base.
12+
#
13+
# Not intended as a final build target. Both the `runtime` (root, default)
14+
# and `runtime-nonroot` (non-root, scanner-friendly) variants derive from
15+
# this stage so the shared setup stays in one place.
16+
# ---------------------------------------------------------------------------
17+
FROM mcr.microsoft.com/dotnet/aspnet:10.0-azurelinux3.0 AS runtime-base
1118

1219
COPY --from=build /out /App
1320
# Add default dab-config.json to /App in the image
1421
COPY --from=build /out/dab-config.json /App/dab-config.json
1522
WORKDIR /App
1623
ENV ASPNETCORE_URLS=http://+:5000
24+
EXPOSE 5000
1725
ENTRYPOINT ["dotnet", "Azure.DataApiBuilder.Service.dll"]
26+
27+
# ---------------------------------------------------------------------------
28+
# Non-root variant. Build explicitly with:
29+
# docker build --target runtime-nonroot -t <repo>:<version>-nonroot .
30+
#
31+
# Runs as the non-root user that ships with the
32+
# mcr.microsoft.com/dotnet/aspnet base image (UID/GID 1654, exposed via the
33+
# APP_UID env var, on the azurelinux3.0 variant). DAB does not require root,
34+
# and declaring USER explicitly sets the image's Config.User field so image
35+
# scanners (e.g. Checkmarx One) that require a non-root user in the final
36+
# stage are satisfied. We use the numeric `USER $APP_UID` form (rather than
37+
# `USER app`) per .NET container guidance: a numeric UID is friendlier to
38+
# image scanners and to Kubernetes `runAsNonRoot`/`runAsUser` checks, which
39+
# cannot resolve a username to a UID at admission time.
40+
#
41+
# Safeguards applied to minimize the chance of runtime breakage:
42+
# * Pre-create /App/logs and `chown app:app /App/logs` (non-recursive) so
43+
# the documented default file-sink path ("logs/dab-log.txt", relative to
44+
# WORKDIR /App) is writable. Ownership of the published assemblies under
45+
# /App is intentionally left unchanged - a recursive chown would
46+
# duplicate every assembly layer and roughly double the image size for
47+
# no runtime benefit, since DAB only needs write access to /App/logs.
48+
# * Default port stays at 5000, which is above 1024, so binding works
49+
# without CAP_NET_BIND_SERVICE. Users overriding ASPNETCORE_URLS to a
50+
# privileged port (<1024) must add `--cap-add=NET_BIND_SERVICE` to
51+
# `docker run` or front DAB with a reverse proxy.
52+
#
53+
# Notes for consumers of this image:
54+
# * Host bind-mounts (config, logs, certs, etc.) must be readable - and
55+
# writable, if DAB needs to write them - by UID 1654 on the host.
56+
# Either `chown -R 1654:1654 /host/path` or, in Kubernetes, set
57+
# `securityContext.fsGroup: 1654`.
58+
# * `docker exec` defaults to UID 1654. Use `docker exec --user 0`
59+
# for administrative actions inside a running container.
60+
# * Downstream Dockerfiles (FROM <this image>) that need to install
61+
# packages or write outside /App should add `USER 0` before those
62+
# instructions, then restore `USER $APP_UID` at the end.
63+
# ---------------------------------------------------------------------------
64+
FROM runtime-base AS runtime-nonroot
65+
66+
RUN mkdir -p /App/logs && chown $APP_UID:$APP_UID /App/logs
67+
USER $APP_UID
68+
69+
LABEL org.opencontainers.image.title="Data API builder (non-root)" \
70+
org.opencontainers.image.description="Data API builder running as the non-root 'app' user (UID 1654 on azurelinux3.0)." \
71+
org.opencontainers.image.source="https://github.com/Azure/data-api-builder"
72+
73+
# ---------------------------------------------------------------------------
74+
# Default (root-running) variant. Build with either:
75+
# docker build -t <repo>:<version> . # no --target needed
76+
# docker build --target runtime -t <repo>:<version> .
77+
#
78+
# This is the LAST stage in the file, so a plain `docker build` with no
79+
# --target argument produces this image. Keeping the root-running variant
80+
# as the default preserves backwards compatibility with the previously
81+
# published image - existing users see no behavior change. The non-root
82+
# variant is opt-in via `--target runtime-nonroot`.
83+
# ---------------------------------------------------------------------------
84+
FROM runtime-base AS runtime

config-generators/postgresql-commands.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ update Publisher --config "dab-config.PostgreSql.json" --permissions "database_p
5858
update Publisher --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:create"
5959
update Publisher --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:update" --policy-database "@item.id ne 1234"
6060
update Stock --config "dab-config.PostgreSql.json" --permissions "authenticated:create,read,update,delete" --rest commodities --graphql true --relationship stocks_price --target.entity stocks_price --cardinality one
61-
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:create,read"
6261
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:update" --policy-database "@item.pieceid ne 1"
62+
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:create" --policy-database "@item.pieceid ne 6 and @item.piecesAvailable gt 0"
63+
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:read"
6364
update Stock --config "dab-config.PostgreSql.json" --permissions "test_role_with_noread:create,update,delete"
6465
update Stock --config "dab-config.PostgreSql.json" --permissions "test_role_with_excluded_fields:create,update,delete"
6566
update Stock --config "dab-config.PostgreSql.json" --permissions "test_role_with_excluded_fields:read" --fields.exclude "categoryName"

src/Core/Configurations/RuntimeConfigValidator.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ public class RuntimeConfigValidator : IConfigValidator
4747
private static readonly HashSet<DatabaseType> _databaseTypesSupportingCreatePolicy =
4848
[
4949
DatabaseType.MSSQL,
50-
DatabaseType.DWSQL
50+
DatabaseType.DWSQL,
51+
DatabaseType.PostgreSQL
5152
];
5253

5354
// Error messages for user-delegated authentication configuration.

src/Core/Resolvers/PostgreSqlExecutor.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
// Licensed under the MIT License.
33

44
using System.Data.Common;
5+
using System.Net;
56
using Azure.Core;
67
using Azure.DataApiBuilder.Config;
78
using Azure.DataApiBuilder.Config.ObjectModel;
89
using Azure.DataApiBuilder.Core.Configurations;
910
using Azure.DataApiBuilder.Core.Models;
11+
using Azure.DataApiBuilder.Service.Exceptions;
1012
using Azure.Identity;
1113
using Microsoft.AspNetCore.Http;
1214
using Microsoft.Extensions.Logging;
@@ -146,6 +148,87 @@ private static bool ShouldManagedIdentityAccessBeAttempted(NpgsqlConnectionStrin
146148
return string.IsNullOrEmpty(builder.Password);
147149
}
148150

151+
/// <inheritdoc/>
152+
public override async Task<DbResultSet> GetMultipleResultSetsIfAnyAsync(
153+
DbDataReader dbDataReader, List<string>? args = null)
154+
{
155+
// RS1: COUNT of rows matching PK (no policy) — used to distinguish
156+
// "row doesn't exist" from "row exists but policy blocked".
157+
DbResultSet resultSetWithCountOfRowsWithGivenPk = await ExtractResultSetFromDbDataReaderAsync(dbDataReader);
158+
DbResultSetRow? resultSetRowWithCountOfRowsWithGivenPk = resultSetWithCountOfRowsWithGivenPk.Rows.FirstOrDefault();
159+
int numOfRecordsWithGivenPK;
160+
bool isFallbackToUpdate;
161+
162+
if (resultSetRowWithCountOfRowsWithGivenPk is not null &&
163+
resultSetRowWithCountOfRowsWithGivenPk.Columns.TryGetValue(PostgresQueryBuilder.COUNT_ROWS_WITH_GIVEN_PK, out object? rowsWithGivenPK) &&
164+
resultSetRowWithCountOfRowsWithGivenPk.Columns.TryGetValue(PostgresQueryBuilder.IS_FALLBACK_TO_UPDATE, out object? fallbackToUpdate))
165+
{
166+
// PostgreSQL COUNT(*) returns Int64; convert to int.
167+
numOfRecordsWithGivenPK = Convert.ToInt32(rowsWithGivenPK!);
168+
isFallbackToUpdate = Convert.ToBoolean(fallbackToUpdate!);
169+
}
170+
else
171+
{
172+
throw new DataApiBuilderException(
173+
message: $"Neither insert nor update could be performed.",
174+
statusCode: HttpStatusCode.InternalServerError,
175+
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
176+
}
177+
178+
// RS2: UPDATE result, or UPDATE+INSERT CTE result.
179+
DbResultSet dbResultSet = await dbDataReader.NextResultAsync()
180+
? await ExtractResultSetFromDbDataReaderAsync(dbDataReader)
181+
: throw new DataApiBuilderException(
182+
message: $"Neither insert nor update could be performed.",
183+
statusCode: HttpStatusCode.InternalServerError,
184+
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
185+
186+
if (numOfRecordsWithGivenPK == 1) // Row existed — we attempted an UPDATE.
187+
{
188+
if (dbResultSet.Rows.Count == 0)
189+
{
190+
// Row exists but UPDATE returned no rows — update policy blocked it.
191+
throw new DataApiBuilderException(
192+
message: DataApiBuilderException.AUTHORIZATION_FAILURE,
193+
statusCode: HttpStatusCode.Forbidden,
194+
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure);
195+
}
196+
}
197+
else if (dbResultSet.Rows.Count == 0)
198+
{
199+
// If true, the row simply didn't exist — return 404 (same as MsSql's null-RS2 path).
200+
// If false, the INSERT ran but create policy blocked it — return 403.
201+
202+
if (isFallbackToUpdate)
203+
{
204+
if (args is not null && args.Count > 1)
205+
{
206+
string prettyPrintPk = args[0];
207+
string entityName = args[1];
208+
209+
throw new DataApiBuilderException(
210+
message: $"Cannot perform INSERT and could not find {entityName} " +
211+
$"with primary key {prettyPrintPk} to perform UPDATE on.",
212+
statusCode: HttpStatusCode.NotFound,
213+
subStatusCode: DataApiBuilderException.SubStatusCodes.ItemNotFound);
214+
}
215+
216+
throw new DataApiBuilderException(
217+
message: $"Neither insert nor update could be performed.",
218+
statusCode: HttpStatusCode.InternalServerError,
219+
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
220+
}
221+
222+
// Row didn't exist but INSERT returned no rows — create policy blocked it.
223+
throw new DataApiBuilderException(
224+
message: DataApiBuilderException.AUTHORIZATION_FAILURE,
225+
statusCode: HttpStatusCode.Forbidden,
226+
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure);
227+
}
228+
229+
return dbResultSet;
230+
}
231+
149232
/// <summary>
150233
/// Determines if the saved default azure credential's access token is valid and not expired.
151234
/// </summary>

src/Core/Resolvers/PostgresQueryBuilder.cs

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ public class PostgresQueryBuilder : BaseSqlQueryBuilder, IQueryBuilder
1717
private const string UPSERT_IDENTIFIER_COLUMN_NAME = "___upsert_op___";
1818
private const string INSERT_UPSERT = "inserted";
1919
private const string UPDATE_UPSERT = "updated";
20+
public const string COUNT_ROWS_WITH_GIVEN_PK = "cnt_rows_to_update";
21+
public const string IS_FALLBACK_TO_UPDATE = "is_fallback_to_update";
2022

2123
private static DbCommandBuilder _builder = new NpgsqlCommandBuilder();
2224

@@ -67,11 +69,17 @@ public string Build(SqlQueryStructure structure)
6769
/// <inheritdoc />
6870
public string Build(SqlInsertStructure structure)
6971
{
70-
string insertQuery = $"INSERT INTO {QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)} ";
72+
string tableName = $"{QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)}";
73+
string dbPolicyPredicates = JoinPredicateStrings(structure.GetDbPolicyForOperation(EntityActionOperation.Create));
74+
string insertQuery = $"INSERT INTO {tableName} ";
75+
7176
if (structure.InsertColumns.Any())
7277
{
73-
insertQuery += $"({Build(structure.InsertColumns)}) " +
74-
$"VALUES ({string.Join(", ", (structure.Values))}) ";
78+
string insertColumns = Build(structure.InsertColumns);
79+
string insertValues = dbPolicyPredicates.Equals(BASE_PREDICATE)
80+
? $"({insertColumns}) VALUES ({string.Join(", ", structure.Values)})"
81+
: $"({insertColumns}) SELECT {insertColumns} FROM (SELECT {string.Join(", ", structure.InsertColumns.Zip(structure.Values, (col, val) => $"{val} AS {QuoteIdentifier(col)}"))}) AS T WHERE {dbPolicyPredicates}";
82+
insertQuery += insertValues;
7583
}
7684
else
7785
{
@@ -117,25 +125,53 @@ public string Build(SqlUpsertQueryStructure structure)
117125
{
118126
// https://stackoverflow.com/questions/42668720/check-if-postgres-query-inserted-or-updated-via-upsert
119127
// relying on xmax to detect insert vs update breaks for views
120-
string updatePredicates = JoinPredicateStrings(Build(structure.Predicates), structure.GetDbPolicyForOperation(EntityActionOperation.Update));
121-
string updateQuery = $"UPDATE {QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)} " +
128+
string tableName = $"{QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)}";
129+
string pkPredicates = Build(structure.Predicates);
130+
string isFallbackToUpdateSqlLiteral = structure.IsFallbackToUpdate ? "TRUE" : "FALSE";
131+
132+
// RS1: COUNT of rows matching PK (no policy) — used to distinguish
133+
// "row doesn't exist" from "row exists but policy blocked" in the executor.
134+
string countQuery = $"SELECT COUNT(*) AS {COUNT_ROWS_WITH_GIVEN_PK}, " +
135+
$"{isFallbackToUpdateSqlLiteral} AS {IS_FALLBACK_TO_UPDATE} " +
136+
$"FROM {tableName} WHERE {pkPredicates}";
137+
138+
string updatePredicates = JoinPredicateStrings(pkPredicates, structure.GetDbPolicyForOperation(EntityActionOperation.Update));
139+
string updateQuery = $"UPDATE {tableName} " +
122140
$"SET {Build(structure.UpdateOperations, ", ")} " +
123141
$"WHERE {updatePredicates} " +
124142
$"RETURNING {Build(structure.OutputColumns)}, '{UPDATE_UPSERT}' AS {UPSERT_IDENTIFIER_COLUMN_NAME}";
125143

126144
if (structure.IsFallbackToUpdate)
127145
{
128-
return updateQuery + ";";
146+
// RS2: UPDATE only — no INSERT branch for autogen PK or missing required columns.
147+
return $"{countQuery}; {updateQuery};";
129148
}
130149
else
131150
{
132-
return $"WITH update_cte AS ( {updateQuery} ), insert_cte AS ( " +
133-
$"INSERT INTO {QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)} ({Build(structure.InsertColumns)}) " +
134-
$"SELECT {string.Join(", ", (structure.Values))} " +
135-
$"WHERE NOT EXISTS (SELECT 1 FROM update_cte) " +
151+
// INSERT only runs when row doesn't exist (pkPredicates match nothing)
152+
// AND the create policy (if any) is satisfied.
153+
string insertPredicates = JoinPredicateStrings(
154+
$"NOT EXISTS (SELECT 1 FROM {tableName} WHERE {pkPredicates})",
155+
structure.GetDbPolicyForOperation(EntityActionOperation.Create));
156+
157+
// Alias each value with its column name so that policy predicates referencing
158+
// column names (e.g. "pieceid" != @param) can be resolved in the WHERE clause.
159+
// Using SELECT ... FROM (SELECT @p1 AS col1, ...) AS T avoids both the VALUES(NULL)
160+
// type inference issue and the unnamed-column resolution issue.
161+
string namedValues = string.Join(", ",
162+
structure.InsertColumns.Zip(structure.Values,
163+
(col, val) => $"{val} AS {QuoteIdentifier(col)}"));
164+
165+
// RS2: CTE that attempts UPDATE first; falls through to INSERT only when row is absent.
166+
string cteQuery = $"WITH update_cte AS ( {updateQuery} ), insert_cte AS ( " +
167+
$"INSERT INTO {tableName} ({Build(structure.InsertColumns)}) " +
168+
$"SELECT {Build(structure.InsertColumns)} FROM (SELECT {namedValues}) AS T " +
169+
$"WHERE {insertPredicates} " +
136170
$"RETURNING {Build(structure.OutputColumns)}, '{INSERT_UPSERT}' AS {UPSERT_IDENTIFIER_COLUMN_NAME} ) " +
137-
$"SELECT {BuildListOfLabels(structure.OutputColumns)}, {UPSERT_IDENTIFIER_COLUMN_NAME} FROM update_cte UNION " +
171+
$"SELECT {BuildListOfLabels(structure.OutputColumns)}, {UPSERT_IDENTIFIER_COLUMN_NAME} FROM update_cte UNION ALL " +
138172
$"SELECT {BuildListOfLabels(structure.OutputColumns)}, {UPSERT_IDENTIFIER_COLUMN_NAME} FROM insert_cte;";
173+
174+
return $"{countQuery}; {cteQuery}";
139175
}
140176
}
141177

0 commit comments

Comments
 (0)