xiangfu0 commented on code in PR #19664:
URL: https://github.com/apache/pinot/pull/19664#discussion_r4244659757
##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java:
##########
@@ -424,6 +445,116 @@ private StreamingOutput executeSqlQuery(@Context
HttpHeaders httpHeaders, String
}
}
+ /// Executes a `DELETE` once the caller is authorized to delete rows from
its table, see [#authorizeDelete].
+ ///
+ /// The caller is first checked without a table (the `READ` access type with
the `/sql` endpoint, as for a
+ /// multi-stage query), before the statement is parsed and its table looked
up: with basic auth, an unauthenticated
+ /// caller gets HTTP 401 here, before the table cache is consulted. The
table is then resolved, with the database of
+ /// the request and in the case it is defined with, and the caller
authorized to delete rows from the resolved
+ /// table. Only then is the table checked not to be a logical table and to
exist, and every error message names the
+ /// table as the caller wrote it, so that a caller who is not authorized for
a table learns neither whether it exists
+ /// nor the case it is defined with, nor whether the name is a logical
table. The executor deletes rows from the
+ /// exact resolved table.
+ ///
+ /// The executor runs before the response streams, so that an exception it
lets escape is mapped to an error
+ /// response by [#executeSqlQueryCatching] rather than failing the request
with HTTP 500.
+ ///
+ /// @throws QueryException with [QueryErrorCode#ACCESS_DENIED] if the caller
is not authorized, with
+ /// [QueryErrorCode#QUERY_VALIDATION] if the table is
a logical table, with
+ /// [QueryErrorCode#TABLE_DOES_NOT_EXIST] if the
table does not exist, and with the errors of
+ /// [DataManipulationStatementParser#parse] and
[DeleteStatement#resolveTableName]
+ private StreamingOutput executeDelete(SqlNodeAndOptions sqlNodeAndOptions,
HttpHeaders httpHeaders) {
+ AccessControl accessControl = _accessControlFactory.create();
+ if (!accessControl.hasAccess(AccessType.READ, httpHeaders, SQL_ENDPOINT)) {
+ throw QueryErrorCode.ACCESS_DENIED.asException("Permission denied to
delete rows");
+ }
+ DeleteStatement parsed = (DeleteStatement)
DataManipulationStatementParser.parse(sqlNodeAndOptions);
+ DeleteStatement statement =
parsed.resolveTableName(httpHeaders.getHeaderString(CommonConstants.DATABASE),
+ _pinotHelixResourceManager.getTableCache());
+ // Authorized on the resolved table, reported with the name as written
+ String writtenTableName = parsed.getTableName();
+ authorizeDelete(accessControl, statement.getTableName(), writtenTableName,
httpHeaders);
+ if (statement.isLogicalTable()) {
+ throw QueryErrorCode.QUERY_VALIDATION.asException("DELETE does not
support logical tables: " + writtenTableName);
+ }
+ if (!statement.tableExists()) {
+ throw QueryErrorCode.TABLE_DOES_NOT_EXIST.asException("Table does not
exist: " + writtenTableName);
+ }
+ LOGGER.info(deleteLogMessage(statement, httpHeaders));
+ BrokerResponse response = _sqlQueryExecutor.executeStatement(statement,
extractHeaders(httpHeaders));
+ return output -> {
+ try (OutputStream os = output) {
+ response.toOutputStream(os);
+ }
+ };
+ }
+
+ /// Formats the log line of a `DELETE` the executor is about to run: the
resolved table, the predicate, the option
+ /// keys and the client of the request, in the format of the broker's line
so that it parses the same way in the
+ /// logs of both roles. The predicate, the option keys and the client are
kept on a single line (see
+ /// [#toSingleLine]), so that a line ending in the statement, in a request
option or in a proxy header cannot forge
+ /// a log record.
+ @VisibleForTesting
+ static String deleteLogMessage(DeleteStatement statement, HttpHeaders
httpHeaders) {
+ return "Executing DELETE on table: " + statement.getTableName() + ",
predicate: "
+ + toSingleLine(statement.getPredicate()) + ", options: "
+ + toSingleLine(statement.getOptions().keySet().toString()) + ",
client: "
+ + toSingleLine(getClientIp(httpHeaders));
+ }
+
+ /// Client of the request for the log: the `X-Forwarded-For` header, else
the `X-Real-IP` header, else
+ /// [CommonConstants#UNKNOWN]. The addresses of a comma-separated
`X-Forwarded-For` value are joined with `;`, the
+ /// format the broker's `HttpRequesterIdentity#getClientIp` logs, so that
the field parses the same way in the logs
+ /// of both roles. Both headers are set by the caller unless a proxy
overwrites them.
+ private static String getClientIp(HttpHeaders httpHeaders) {
+ String forwardedFor = httpHeaders.getHeaderString("X-Forwarded-For");
+ if (forwardedFor != null) {
+ return forwardedFor.replace(',', ';');
+ }
+ String realIp = httpHeaders.getHeaderString("X-Real-IP");
+ return realIp != null ? realIp : CommonConstants.UNKNOWN;
+ }
+
+ /// Escapes backslashes, CR and LF as `\\`, `\r` and `\n`, as the broker's
query log does, so that a value the
+ /// caller controls (a string literal or a quoted identifier of the
predicate, an option key from `SET`, the legacy
+ /// `OPTION(...)` suffix or the request `queryOptions`, a proxy header)
cannot split a log record or append a line
+ /// that looks like a log entry.
+ private static String toSingleLine(String value) {
+ if (value.indexOf('\\') < 0 && value.indexOf('\n') < 0 &&
value.indexOf('\r') < 0) {
+ return value;
+ }
+ return value.replace("\\", "\\\\").replace("\r", "\\r").replace("\n",
"\\n");
+ }
+
+ /// Authorizes the caller, who passed the caller-level check of
[#executeDelete], to delete rows from the table.
+ ///
+ /// It checks, on the raw table name and in this order, the `READ` access
type with the `/sql` endpoint (the request
+ /// path, as the authentication filter passes for the other endpoints), the
fine-grained [Actions.Table#QUERY]
+ /// action, the `DELETE` access type with the `/sql` endpoint and the
fine-grained [Actions.Table#DELETE_ROWS]
+ /// action. The `READ` and `QUERY` checks are there because the WHERE clause
reads the table, the `DELETE` and
+ /// `DELETE_ROWS` ones because rows are deleted, as the other deletions of
the controller (e.g. of segments) check
+ /// `DELETE` on the raw table name. This is stricter than the controller's
query path, which only checks the `READ`
+ /// access type with `"Query"` as the endpoint and no fine-grained action,
and it does not apply the row-level
+ /// security of the brokers. Unlike the broker, which has no access type to
tell a deletion from a query and denies
+ /// it by default, an access control that does not tell access types apart
and allows every fine-grained action
+ /// lets every reader of the table delete rows, as it lets them delete
segments.
+ ///
+ /// @param tableName resolved table name the caller is authorized on
+ /// @param writtenTableName table name as the caller wrote it, which the
denial names so that the denial does not
+ /// reveal the case the table is defined with (i.e.
that it exists)
+ /// @throws QueryException with [QueryErrorCode#ACCESS_DENIED] if the caller
is not authorized, as for queries
+ private void authorizeDelete(AccessControl accessControl, String tableName,
String writtenTableName,
+ HttpHeaders httpHeaders) {
+ String rawTableName = TableNameBuilder.extractRawTableName(tableName);
+ if (!accessControl.hasAccess(rawTableName, AccessType.READ, httpHeaders,
SQL_ENDPOINT)
+ || !accessControl.hasAccess(httpHeaders, TargetType.TABLE,
rawTableName, Actions.Table.QUERY)
+ || !accessControl.hasAccess(rawTableName, AccessType.DELETE,
httpHeaders, SQL_ENDPOINT)
+ || !accessControl.hasAccess(httpHeaders, TargetType.TABLE,
rawTableName, Actions.Table.DELETE_ROWS)) {
Review Comment:
**[CONFIRMED — authorization asymmetry] The controller `/sql` DELETE path
applies no row-level security, so a broker RLS refusal is bypassable here.**
The broker denies a DELETE when RLS filters apply to the table, but this
path checks only `READ`/`QUERY`/`DELETE`/`DELETE_ROWS` (the controller
`AccessControl` has no RLS surface). A principal whose DELETE the broker
refuses with 403 "Row-level security applies to the table" can POST the
identical statement to the controller `/sql` and have it executed — deleting
rows outside their row scope. Note `BasicAuthPrincipal.hasPermission` grants
all permissions to a principal with no permission list, so the same credentials
plausibly pass here.
The javadoc acknowledges the gap ("does not apply the row-level security of
the brokers"), but since both endpoints reach the same executor, consider
denying DELETE on the controller when the deployment uses broker RLS (e.g. a
controller-side opt-out config), or documenting loudly that RLS-protected
clusters must not install a deleting executor on the controller.
##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java:
##########
@@ -424,6 +445,116 @@ private StreamingOutput executeSqlQuery(@Context
HttpHeaders httpHeaders, String
}
}
+ /// Executes a `DELETE` once the caller is authorized to delete rows from
its table, see [#authorizeDelete].
+ ///
+ /// The caller is first checked without a table (the `READ` access type with
the `/sql` endpoint, as for a
+ /// multi-stage query), before the statement is parsed and its table looked
up: with basic auth, an unauthenticated
+ /// caller gets HTTP 401 here, before the table cache is consulted. The
table is then resolved, with the database of
+ /// the request and in the case it is defined with, and the caller
authorized to delete rows from the resolved
+ /// table. Only then is the table checked not to be a logical table and to
exist, and every error message names the
+ /// table as the caller wrote it, so that a caller who is not authorized for
a table learns neither whether it exists
+ /// nor the case it is defined with, nor whether the name is a logical
table. The executor deletes rows from the
+ /// exact resolved table.
+ ///
+ /// The executor runs before the response streams, so that an exception it
lets escape is mapped to an error
+ /// response by [#executeSqlQueryCatching] rather than failing the request
with HTTP 500.
+ ///
+ /// @throws QueryException with [QueryErrorCode#ACCESS_DENIED] if the caller
is not authorized, with
+ /// [QueryErrorCode#QUERY_VALIDATION] if the table is
a logical table, with
+ /// [QueryErrorCode#TABLE_DOES_NOT_EXIST] if the
table does not exist, and with the errors of
+ /// [DataManipulationStatementParser#parse] and
[DeleteStatement#resolveTableName]
+ private StreamingOutput executeDelete(SqlNodeAndOptions sqlNodeAndOptions,
HttpHeaders httpHeaders) {
+ AccessControl accessControl = _accessControlFactory.create();
+ if (!accessControl.hasAccess(AccessType.READ, httpHeaders, SQL_ENDPOINT)) {
+ throw QueryErrorCode.ACCESS_DENIED.asException("Permission denied to
delete rows");
+ }
+ DeleteStatement parsed = (DeleteStatement)
DataManipulationStatementParser.parse(sqlNodeAndOptions);
+ DeleteStatement statement =
parsed.resolveTableName(httpHeaders.getHeaderString(CommonConstants.DATABASE),
+ _pinotHelixResourceManager.getTableCache());
+ // Authorized on the resolved table, reported with the name as written
+ String writtenTableName = parsed.getTableName();
+ authorizeDelete(accessControl, statement.getTableName(), writtenTableName,
httpHeaders);
+ if (statement.isLogicalTable()) {
+ throw QueryErrorCode.QUERY_VALIDATION.asException("DELETE does not
support logical tables: " + writtenTableName);
+ }
+ if (!statement.tableExists()) {
+ throw QueryErrorCode.TABLE_DOES_NOT_EXIST.asException("Table does not
exist: " + writtenTableName);
+ }
+ LOGGER.info(deleteLogMessage(statement, httpHeaders));
+ BrokerResponse response = _sqlQueryExecutor.executeStatement(statement,
extractHeaders(httpHeaders));
+ return output -> {
+ try (OutputStream os = output) {
+ response.toOutputStream(os);
+ }
+ };
+ }
+
+ /// Formats the log line of a `DELETE` the executor is about to run: the
resolved table, the predicate, the option
+ /// keys and the client of the request, in the format of the broker's line
so that it parses the same way in the
+ /// logs of both roles. The predicate, the option keys and the client are
kept on a single line (see
+ /// [#toSingleLine]), so that a line ending in the statement, in a request
option or in a proxy header cannot forge
+ /// a log record.
+ @VisibleForTesting
+ static String deleteLogMessage(DeleteStatement statement, HttpHeaders
httpHeaders) {
+ return "Executing DELETE on table: " + statement.getTableName() + ",
predicate: "
+ + toSingleLine(statement.getPredicate()) + ", options: "
+ + toSingleLine(statement.getOptions().keySet().toString()) + ",
client: "
+ + toSingleLine(getClientIp(httpHeaders));
+ }
+
+ /// Client of the request for the log: the `X-Forwarded-For` header, else
the `X-Real-IP` header, else
+ /// [CommonConstants#UNKNOWN]. The addresses of a comma-separated
`X-Forwarded-For` value are joined with `;`, the
+ /// format the broker's `HttpRequesterIdentity#getClientIp` logs, so that
the field parses the same way in the logs
+ /// of both roles. Both headers are set by the caller unless a proxy
overwrites them.
+ private static String getClientIp(HttpHeaders httpHeaders) {
+ String forwardedFor = httpHeaders.getHeaderString("X-Forwarded-For");
+ if (forwardedFor != null) {
+ return forwardedFor.replace(',', ';');
+ }
+ String realIp = httpHeaders.getHeaderString("X-Real-IP");
+ return realIp != null ? realIp : CommonConstants.UNKNOWN;
+ }
+
+ /// Escapes backslashes, CR and LF as `\\`, `\r` and `\n`, as the broker's
query log does, so that a value the
+ /// caller controls (a string literal or a quoted identifier of the
predicate, an option key from `SET`, the legacy
+ /// `OPTION(...)` suffix or the request `queryOptions`, a proxy header)
cannot split a log record or append a line
+ /// that looks like a log entry.
+ private static String toSingleLine(String value) {
+ if (value.indexOf('\\') < 0 && value.indexOf('\n') < 0 &&
value.indexOf('\r') < 0) {
+ return value;
+ }
+ return value.replace("\\", "\\\\").replace("\r", "\\r").replace("\n",
"\\n");
+ }
+
+ /// Authorizes the caller, who passed the caller-level check of
[#executeDelete], to delete rows from the table.
+ ///
+ /// It checks, on the raw table name and in this order, the `READ` access
type with the `/sql` endpoint (the request
+ /// path, as the authentication filter passes for the other endpoints), the
fine-grained [Actions.Table#QUERY]
+ /// action, the `DELETE` access type with the `/sql` endpoint and the
fine-grained [Actions.Table#DELETE_ROWS]
+ /// action. The `READ` and `QUERY` checks are there because the WHERE clause
reads the table, the `DELETE` and
+ /// `DELETE_ROWS` ones because rows are deleted, as the other deletions of
the controller (e.g. of segments) check
+ /// `DELETE` on the raw table name. This is stricter than the controller's
query path, which only checks the `READ`
+ /// access type with `"Query"` as the endpoint and no fine-grained action,
and it does not apply the row-level
+ /// security of the brokers. Unlike the broker, which has no access type to
tell a deletion from a query and denies
+ /// it by default, an access control that does not tell access types apart
and allows every fine-grained action
+ /// lets every reader of the table delete rows, as it lets them delete
segments.
+ ///
+ /// @param tableName resolved table name the caller is authorized on
+ /// @param writtenTableName table name as the caller wrote it, which the
denial names so that the denial does not
+ /// reveal the case the table is defined with (i.e.
that it exists)
+ /// @throws QueryException with [QueryErrorCode#ACCESS_DENIED] if the caller
is not authorized, as for queries
+ private void authorizeDelete(AccessControl accessControl, String tableName,
String writtenTableName,
+ HttpHeaders httpHeaders) {
+ String rawTableName = TableNameBuilder.extractRawTableName(tableName);
Review Comment:
**[CONFIRMED — authorization asymmetry] Fine-grained rules keyed on typed
table names are never consulted on the controller DELETE path.**
All four checks here run on `extractRawTableName(tableName)` only, while the
broker checks QUERY/DELETE_ROWS on the resolved (possibly typed) name *and*
DELETE_ROWS on the raw name "so that a type suffix cannot bypass a rule on the
raw name". The inverse bypass remains here: a `FineGrainedAccessControl`
denying `DeleteRows` on `mytable_OFFLINE` blocks the DELETE on the broker but
not on the controller. Consider checking the resolved name as well when it
differs from the raw name, matching the broker.
##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java:
##########
@@ -424,6 +445,116 @@ private StreamingOutput executeSqlQuery(@Context
HttpHeaders httpHeaders, String
}
}
+ /// Executes a `DELETE` once the caller is authorized to delete rows from
its table, see [#authorizeDelete].
+ ///
+ /// The caller is first checked without a table (the `READ` access type with
the `/sql` endpoint, as for a
+ /// multi-stage query), before the statement is parsed and its table looked
up: with basic auth, an unauthenticated
+ /// caller gets HTTP 401 here, before the table cache is consulted. The
table is then resolved, with the database of
+ /// the request and in the case it is defined with, and the caller
authorized to delete rows from the resolved
+ /// table. Only then is the table checked not to be a logical table and to
exist, and every error message names the
+ /// table as the caller wrote it, so that a caller who is not authorized for
a table learns neither whether it exists
+ /// nor the case it is defined with, nor whether the name is a logical
table. The executor deletes rows from the
+ /// exact resolved table.
+ ///
+ /// The executor runs before the response streams, so that an exception it
lets escape is mapped to an error
+ /// response by [#executeSqlQueryCatching] rather than failing the request
with HTTP 500.
+ ///
+ /// @throws QueryException with [QueryErrorCode#ACCESS_DENIED] if the caller
is not authorized, with
+ /// [QueryErrorCode#QUERY_VALIDATION] if the table is
a logical table, with
+ /// [QueryErrorCode#TABLE_DOES_NOT_EXIST] if the
table does not exist, and with the errors of
+ /// [DataManipulationStatementParser#parse] and
[DeleteStatement#resolveTableName]
+ private StreamingOutput executeDelete(SqlNodeAndOptions sqlNodeAndOptions,
HttpHeaders httpHeaders) {
+ AccessControl accessControl = _accessControlFactory.create();
+ if (!accessControl.hasAccess(AccessType.READ, httpHeaders, SQL_ENDPOINT)) {
+ throw QueryErrorCode.ACCESS_DENIED.asException("Permission denied to
delete rows");
+ }
+ DeleteStatement parsed = (DeleteStatement)
DataManipulationStatementParser.parse(sqlNodeAndOptions);
+ DeleteStatement statement =
parsed.resolveTableName(httpHeaders.getHeaderString(CommonConstants.DATABASE),
+ _pinotHelixResourceManager.getTableCache());
+ // Authorized on the resolved table, reported with the name as written
+ String writtenTableName = parsed.getTableName();
+ authorizeDelete(accessControl, statement.getTableName(), writtenTableName,
httpHeaders);
+ if (statement.isLogicalTable()) {
+ throw QueryErrorCode.QUERY_VALIDATION.asException("DELETE does not
support logical tables: " + writtenTableName);
+ }
+ if (!statement.tableExists()) {
+ throw QueryErrorCode.TABLE_DOES_NOT_EXIST.asException("Table does not
exist: " + writtenTableName);
+ }
+ LOGGER.info(deleteLogMessage(statement, httpHeaders));
+ BrokerResponse response = _sqlQueryExecutor.executeStatement(statement,
extractHeaders(httpHeaders));
+ return output -> {
+ try (OutputStream os = output) {
+ response.toOutputStream(os);
+ }
+ };
+ }
+
+ /// Formats the log line of a `DELETE` the executor is about to run: the
resolved table, the predicate, the option
+ /// keys and the client of the request, in the format of the broker's line
so that it parses the same way in the
+ /// logs of both roles. The predicate, the option keys and the client are
kept on a single line (see
+ /// [#toSingleLine]), so that a line ending in the statement, in a request
option or in a proxy header cannot forge
+ /// a log record.
+ @VisibleForTesting
+ static String deleteLogMessage(DeleteStatement statement, HttpHeaders
httpHeaders) {
+ return "Executing DELETE on table: " + statement.getTableName() + ",
predicate: "
Review Comment:
**[CONFIRMED — privacy] The controller logs the DELETE predicate verbatim,
with no redaction.**
The broker's `deleteLogMessage` applies
`pinot.broker.query.log.sqlRedaction` precisely because "its literals are the
very values a privacy-driven delete removes" — that rationale applies
identically here. On a redaction-configured cluster, `DELETE FROM users WHERE
email = '[email protected]'` POSTed to controller `/sql` writes the PII literal
into the controller log while the broker logs `REDACTED`. There is currently no
controller-side redaction config at all, so consider reading the same
broker-namespaced key (as the legacy-syntax mode listener already does) or at
minimum documenting the gap.
##########
pinot-broker/src/main/java/org/apache/pinot/broker/api/resources/PinotClientRequest.java:
##########
@@ -703,7 +726,13 @@ private BrokerResponse executeSqlQuery(ObjectNode
sqlRequestJson, HttpRequesterI
Map<String, String> headers = new HashMap<>();
httpRequesterIdentity.getHttpHeaders().entries()
.forEach(entry -> headers.put(entry.getKey(), entry.getValue()));
+ if (sqlNodeAndOptions.getSqlNode() instanceof SqlDelete) {
+ return executeDelete(sqlNodeAndOptions, headers,
httpRequesterIdentity, httpHeaders);
Review Comment:
**[CONFIRMED] A DELETE sent through the cursor endpoint (`getCursor=true`)
is silently accepted with bogus cursor options.**
The `getCursor` block earlier in `executeSqlQuery` runs before the
statement-type switch: it calls `setExtraOptions({getCursor:true,
cursorNumRows:N})` and increments `CURSOR_QUERIES_GLOBAL`, then the DML branch
executes the DELETE anyway. The executor receives `getCursor`/`cursorNumRows`
in `DeleteStatement.getOptions()`, the client gets a plain non-cursor response
(its follow-up cursor fetch fails), and cursor metrics are skewed —
contradicting this PR's own principle that DML options must fail loudly rather
than be silently misapplied. Suggested fix: reject non-DQL statements when
`getCursor` is true (mirroring the `onlyDql` guard).
##########
pinot-broker/src/main/java/org/apache/pinot/broker/broker/BasicAuthAccessControlFactory.java:
##########
@@ -133,6 +144,28 @@ public TableAuthorizationResult
authorize(RequesterIdentity requesterIdentity, S
return new TableAuthorizationResult(failedTables);
}
+ @Override
+ public AuthorizationResult authorizeDeleteRows(RequesterIdentity
requesterIdentity,
+ @Nullable HttpHeaders httpHeaders, String tableName) {
+ Optional<BasicAuthPrincipal> principalOpt =
getPrincipalOpt(requesterIdentity);
+ if (principalOpt.isEmpty()) {
+ return new BasicAuthorizationResultImpl(false, "Missing or invalid
credentials");
+ }
+ BasicAuthPrincipal principal = principalOpt.get();
+ // `tables` matches the name as given, as for queries, while
`excludeTables` lists raw names: also check the
+ // exclusion list on the raw name, so that a name with a type suffix
cannot bypass an excluded table
+ if (!principal.hasTable(tableName)
+ ||
principal.isTableExcluded(TableNameBuilder.extractRawTableName(tableName))) {
Review Comment:
**[CONFIRMED — pre-existing, fix is DELETE-only] The excludeTables
type-suffix bypass remains on the query path.**
This line correctly checks `isTableExcluded(extractRawTableName(tableName))`
for DELETE, but `hasTable`/`authorize(Set)` on the query path still match
exclusions verbatim: with `excludeTables=[t]`, a principal can still *query*
`t_OFFLINE`. The underlying issue is that
`BasicAuthPrincipal.isTableNotExcluded` compares the name as given. Consider
normalizing to the raw name inside `BasicAuthPrincipal` so every caller (query
and DELETE) gets consistent suffix handling, rather than patching only this
path. (Pre-existing bug; this PR just makes the inconsistency visible.)
##########
pinot-broker/src/main/java/org/apache/pinot/broker/api/resources/PinotClientRequest.java:
##########
@@ -713,6 +742,231 @@ private BrokerResponse executeSqlQuery(ObjectNode
sqlRequestJson, HttpRequesterI
}
}
+ /// Executes a `DELETE` once the caller is authorized to delete rows from
its table.
+ ///
+ /// The first-step access control runs first, as for queries, followed by
the table check without a table, so that
+ /// an access control that only authenticates the caller in that check
rejects an unauthenticated caller before the
+ /// table is looked up. The table is then resolved with the database of the
request and in the case it is defined
+ /// with, the caller is authorized to delete rows from it (see
[#authorizeDelete]), and the executor deletes rows
+ /// from that exact table. A logical table fails with
[QueryErrorCode#QUERY_VALIDATION], and a table the table cache
+ /// does not know with [QueryErrorCode#TABLE_DOES_NOT_EXIST], as a query
does, but only once the caller is
+ /// authorized for it, and every message names the table as the caller wrote
it (see [#deleteAccessDenied] for the
+ /// reason of a denial), so that neither the existence of a table nor its
name as defined is leaked to a caller who
+ /// is not authorized for it.
+ private BrokerResponse executeDelete(SqlNodeAndOptions sqlNodeAndOptions,
Map<String, String> headers,
+ HttpRequesterIdentity requesterIdentity, @Nullable HttpHeaders
httpHeaders) {
+ AccessControl accessControl = _accessControlFactory.create();
+ // The first-step access control runs before the table is looked up, as
for queries
+ AuthorizationResult authorizationResult =
accessControl.authorize(requesterIdentity);
+ if (!authorizationResult.hasAccess()) {
+ throw deleteAccessDenied(null, null, authorizationResult);
+ }
+ // Access controls that authenticate the caller in their table check
rather than in the first step (e.g. the
+ // ZooKeeper basic auth) reject an unauthenticated caller here, before the
table is looked up
+ authorizationResult = authorizeTables(accessControl, requesterIdentity,
Set.of());
+ if (!authorizationResult.hasAccess()) {
+ throw deleteAccessDenied(null, null, authorizationResult);
+ }
+ if (_tableCache == null) {
+ return new BrokerResponseNative(QueryErrorCode.QUERY_VALIDATION,
+ "DELETE is not supported by this broker: no table cache was
configured");
+ }
+ DeleteStatement parsed;
+ DeleteStatement statement;
+ try {
+ String databaseHeader = httpHeaders != null ?
httpHeaders.getHeaderString(CommonConstants.DATABASE) : null;
+ parsed = (DeleteStatement)
DataManipulationStatementParser.parse(sqlNodeAndOptions);
+ statement = parsed.resolveTableName(databaseHeader, _tableCache);
+ } catch (QueryException e) {
+ // e.g. an invalid statement, or a database header that does not match
the statement
+ return new BrokerResponseNative(e.getErrorCode(), e.getMessage());
+ }
+ // The caller is authorized for the table as resolved, but the messages
name it as written
+ String writtenTableName = parsed.getTableName();
+ authorizeDelete(accessControl, statement.getTableName(), writtenTableName,
requesterIdentity, httpHeaders);
+ if (statement.isLogicalTable()) {
+ return new BrokerResponseNative(QueryErrorCode.QUERY_VALIDATION,
+ "DELETE does not support logical tables: " + writtenTableName);
+ }
+ if (!statement.tableExists()) {
+ return new BrokerResponseNative(QueryErrorCode.TABLE_DOES_NOT_EXIST,
+ "Table does not exist: " + writtenTableName);
+ }
+ LOGGER.info(deleteLogMessage(statement, sqlNodeAndOptions,
requesterIdentity));
+ return _sqlQueryExecutor.executeStatement(statement, headers);
+ }
+
+ /// Formats the log line of a `DELETE` the executor is about to run. The
predicate is redacted with the SQL
+ /// redaction mode of the query log (`pinot.broker.query.log.sqlRedaction`),
as the queries are, since its literals
+ /// are the very values a privacy-driven delete removes: the
`literal_values` mode logs the fingerprint of the
+ /// statement (`DELETE FROM t WHERE c = ?`), which keeps its shape as the
fingerprint of a query does, in place of
+ /// the predicate. The client is only logged when the query log logs client
IPs
+ /// (`pinot.broker.request.client.ip.logging`), since it comes from request
headers the caller controls. The option
+ /// keys and the client are kept on a single line, as the query log keeps
the predicate, so that a line ending in
+ /// the statement or in a header cannot forge a log record.
+ ///
+ /// @param sqlNodeAndOptions parsed statement, which the `literal_values`
mode fingerprints
+ @VisibleForTesting
+ String deleteLogMessage(DeleteStatement statement, SqlNodeAndOptions
sqlNodeAndOptions,
+ HttpRequesterIdentity requesterIdentity) {
+ SqlRedactionMode sqlRedactionMode = SqlRedactionMode.fromString(
+
_brokerConf.getProperty(CommonConstants.Broker.CONFIG_OF_BROKER_QUERY_LOG_SQL_REDACTION,
+ CommonConstants.Broker.DEFAULT_BROKER_QUERY_LOG_SQL_REDACTION));
+ QueryFingerprint fingerprint = null;
+ if (sqlRedactionMode == SqlRedactionMode.LITERAL_VALUES) {
+ try {
+ fingerprint =
QueryFingerprintUtils.generateFingerprint(sqlNodeAndOptions);
+ } catch (Exception e) {
+ // Logged as the query log logs a query it could not fingerprint
+ LOGGER.warn("Failed to generate the fingerprint of a DELETE on table:
{}", statement.getTableName(), e);
+ }
+ }
+ String predicate = QueryLogger.redactQuery(statement.getPredicate(),
sqlRedactionMode, fingerprint);
+ String options = toSingleLine(statement.getOptions().keySet().toString());
+ String client =
_brokerConf.getProperty(CommonConstants.Broker.CONFIG_OF_BROKER_REQUEST_CLIENT_IP_LOGGING,
+ CommonConstants.Broker.DEFAULT_BROKER_REQUEST_CLIENT_IP_LOGGING)
+ ? toSingleLine(requesterIdentity.getClientIp()) :
CommonConstants.UNKNOWN;
+ return "Executing DELETE on table: " + statement.getTableName() + ",
predicate: " + predicate + ", options: "
+ + options + ", client: " + client;
+ }
+
+ /// Escapes backslashes, CR and LF as `\\`, `\r` and `\n`, as `QueryLogger`
does to log a query on a single line.
+ private static String toSingleLine(String text) {
+ return text.replace("\\", "\\\\").replace("\r", "\\r").replace("\n",
"\\n");
+ }
+
+ /// Authorizes the caller, who passed the first-step access control, to
delete rows from the table.
+ ///
+ /// The checks run in this order, and the first one that denies answers HTTP
403:
+ /// 1. [AccessControl#authorize(RequesterIdentity, Set)] with the table as
its only element, since the WHERE clause
+ /// reads it. [AccessControl#authorize(RequesterIdentity, BrokerRequest)]
is not called, since a `DELETE` is not a
+ /// broker request: table restrictions that an access control only
applies in that overload are not consulted,
+ /// and must be applied again in [AccessControl#authorizeDeleteRows].
+ /// 2. The fine-grained [Actions.Table#QUERY] action on the table name, as
for a query.
+ /// 3. The fine-grained [Actions.Table#DELETE_ROWS] action on the table
name, then on its raw name when the name has
+ /// a type suffix, which the controller checks, so that a type suffix
cannot bypass a rule on the raw name.
+ /// 4. [AccessControl#authorizeDeleteRows], which denies by default: the
fine-grained checks allow every action by
+ /// default, so they cannot tell an access control written before
`DELETE` existed from one that allows it.
+ /// 5. No row-level security filter applies to the table (see
[AccessControl#getRowColFilters], when the broker
+ /// enables row-level security), since it would not restrict the rows the
statement deletes.
+ ///
+ /// @param tableName table as resolved, which the checks authorize
+ /// @param writtenTableName table as the caller wrote it, which a denial
names
+ /// @throws WebApplicationException with status 403 if the caller is not
authorized
+ private void authorizeDelete(AccessControl accessControl, String tableName,
String writtenTableName,
+ HttpRequesterIdentity requesterIdentity, @Nullable HttpHeaders
httpHeaders) {
+ checkDeleteAccess(authorizeTables(accessControl, requesterIdentity,
Set.of(tableName)), tableName,
+ writtenTableName);
+ checkDeleteAccess(accessControl.authorize(httpHeaders, TargetType.TABLE,
tableName, Actions.Table.QUERY),
+ tableName, writtenTableName);
+ checkDeleteAccess(accessControl.authorize(httpHeaders, TargetType.TABLE,
tableName, Actions.Table.DELETE_ROWS),
+ tableName, writtenTableName);
+ String rawTableName = TableNameBuilder.extractRawTableName(tableName);
+ if (!rawTableName.equals(tableName)) {
+ checkDeleteAccess(
+ accessControl.authorize(httpHeaders, TargetType.TABLE, rawTableName,
Actions.Table.DELETE_ROWS), tableName,
+ writtenTableName);
+ }
+ checkDeleteAccess(accessControl.authorizeDeleteRows(requesterIdentity,
httpHeaders, tableName), tableName,
+ writtenTableName);
+ if (hasRowFilters(accessControl, requesterIdentity, tableName)) {
+ throw deleteAccessDenied(tableName, writtenTableName, new
BasicAuthorizationResultImpl(false,
+ "Row-level security applies to the table, and would not restrict the
rows the statement deletes"));
+ }
+ }
+
+ /// Throws the HTTP 403 error of a `DELETE` if the check denied it.
+ private void checkDeleteAccess(AuthorizationResult authorizationResult,
String tableName, String writtenTableName) {
+ if (!authorizationResult.hasAccess()) {
+ throw deleteAccessDenied(tableName, writtenTableName,
authorizationResult);
+ }
+ }
+
+ /// Runs the table check of the access control, denying the `DELETE` when
the access control cannot run it: an
+ /// access control written for single-stage queries only, which overrides
+ /// [AccessControl#hasAccess(RequesterIdentity, BrokerRequest)] but not the
table check, throws
+ /// [UnsupportedOperationException] from the default table check (see
+ /// [AccessControl#hasAccess(RequesterIdentity, Set)]). A `DELETE` is not a
broker request, so it cannot be
+ /// authorized by such an access control, and is denied rather than failed
as an uncaught error.
+ private static AuthorizationResult authorizeTables(AccessControl
accessControl, RequesterIdentity requesterIdentity,
+ Set<String> tables) {
+ try {
+ return accessControl.authorize(requesterIdentity, tables);
+ } catch (UnsupportedOperationException e) {
+ LOGGER.warn("The access control {} cannot authorize a DELETE: it
implements the broker-request check only",
+ accessControl.getClass().getName(), e);
+ return new BasicAuthorizationResultImpl(false,
+ "The access control of the broker cannot authorize a DELETE: it
implements the broker-request check only");
+ }
+ }
+
+ /// Records a `DELETE` denied by the access control (metric, and a log line
once the table is known) and returns its
+ /// HTTP 403 error to throw. The error only names the table once the caller
passed the first-step access control,
+ /// and names it as the caller wrote it rather than as resolved, so that a
caller who is not authorized for the
+ /// table does not learn its name as defined (e.g. its case, which
`enable.case.insensitive` resolves).
+ ///
+ /// The reason of the access control is only sent to the caller when it does
not mention the table, since the
+ /// access control echoes the name it authorized, which is the resolved one
(e.g. the basic auth: "Authorization
+ /// Failed for tables: [MyTable]"). Rewriting the resolved name in the
reason would not do: it would only apply
+ /// when the table cache resolved the name, i.e. when the table exists, so
the reason would read differently for a
+ /// table that does not exist. The rule applies whether or not the resolved
name differs from the written one, so
+ /// that the message is the same for a table that exists and for one that
does not. The log line keeps the full
+ /// reason and the resolved name for the operator.
+ private WebApplicationException deleteAccessDenied(@Nullable String
tableName, @Nullable String writtenTableName,
+ AuthorizationResult authorizationResult) {
+
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.REQUEST_DROPPED_DUE_TO_ACCESS_ERROR,
1);
+ String reason = authorizationResult.getFailureMessage();
+ String message;
+ if (tableName != null) {
+ // The name, which is the one written when the table cache does not know
it, and the reason come from the
+ // request and from the access control: escaped as the predicate of the
log line of a DELETE is, so that they
+ // cannot forge a log record
+ LOGGER.info("Access denied to delete rows from table: {}, reason: {}",
toSingleLine(tableName),
+ reason != null ? toSingleLine(reason) : null);
+ if (reason != null && (mentionsTable(reason, tableName) ||
mentionsTable(reason, writtenTableName))) {
+ reason = null;
+ }
+ message = "Permission denied to delete rows from table: " +
writtenTableName;
+ } else {
+ message = "Permission denied to delete rows";
+ }
+ if (reason != null && !reason.isBlank()) {
+ message += ". Reason: " + reason;
+ }
+ return new WebApplicationException(message, Response.Status.FORBIDDEN);
+ }
+
+ /// Whether the reason of a denial mentions the table name, or its raw name
(the fine-grained check of the right to
+ /// delete rows also runs on the raw name), ignoring case, since
`enable.case.insensitive` resolves it.
+ private static boolean mentionsTable(String reason, @Nullable String
tableName) {
Review Comment:
**[CONFIRMED — minor] `mentionsTable` substring matching over-suppresses
denial reasons.**
`containsIgnoreCase(reason, tableName)` false-positives on incidental
substrings: for a table named `rows`, the default reason "The access control of
the broker does not allow deleting rows" is suppressed; for short names like
`a`, virtually every reason is. Impact is cosmetic (the operator log keeps the
full reason; no leak), but callers lose the explanation exactly when they need
it. Word-boundary matching, or the simpler rule of never echoing the
access-control reason once a table is involved, would avoid the heuristic.
##########
pinot-controller/src/main/java/org/apache/pinot/controller/BaseControllerStarter.java:
##########
@@ -715,6 +714,12 @@ private void setUpPinotController() {
// max.segment.completion.time.millis is seeded from cluster config before
serving requests.
_clusterConfigChangeHandler.registerClusterConfigChangeListener(_pinotLLCRealtimeSegmentManager);
LOGGER.info("Registered PinotLLCRealtimeSegmentManager as cluster config
change listener");
+ // Keeps the legacy OPTION(...) syntax mode cluster config in effect for
the statements the controller parses on
+ // /sql, including the DML it executes itself (a DELETE carrying legacy
options fails in IGNORE and REJECT mode).
+ // The query option validation mode is not applied here: the broker
validates every query the controller forwards,
+ // and the REJECT allowlist of registered SET keys is per JVM (broker
plugins register their keys in the broker
+ // only), so applying it here would reject options the broker accepts
+
_clusterConfigChangeHandler.registerClusterConfigChangeListener(QueryOptionConfigListener.legacySyntaxModeOnly());
Review Comment:
**[PLAUSIBLE — unacknowledged blast radius] The legacy-syntax mode now
applies to every controller-side parse, not just `/sql` DML.**
`QueryOptionsUtils._legacyOptionSyntaxMode` is a JVM-wide static read by
`compileToSqlNodeAndOptions`, so this registration also changes the recommender
(`InputManager.compileToPinotQuery`) and `/query/tableNames`
(`TableNameExtractor`) on clusters with `legacySyntaxMode=REJECT/IGNORE`:
queries with `OPTION(...)` those endpoints accepted pre-PR now fail. Arguably
the intended cluster-policy consistency (the broker would reject the same query
at runtime), but worth an explicit note in the comment/release notes since
those endpoints aren't mentioned.
##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java:
##########
@@ -424,6 +445,116 @@ private StreamingOutput executeSqlQuery(@Context
HttpHeaders httpHeaders, String
}
}
+ /// Executes a `DELETE` once the caller is authorized to delete rows from
its table, see [#authorizeDelete].
+ ///
+ /// The caller is first checked without a table (the `READ` access type with
the `/sql` endpoint, as for a
+ /// multi-stage query), before the statement is parsed and its table looked
up: with basic auth, an unauthenticated
+ /// caller gets HTTP 401 here, before the table cache is consulted. The
table is then resolved, with the database of
+ /// the request and in the case it is defined with, and the caller
authorized to delete rows from the resolved
+ /// table. Only then is the table checked not to be a logical table and to
exist, and every error message names the
+ /// table as the caller wrote it, so that a caller who is not authorized for
a table learns neither whether it exists
+ /// nor the case it is defined with, nor whether the name is a logical
table. The executor deletes rows from the
+ /// exact resolved table.
+ ///
+ /// The executor runs before the response streams, so that an exception it
lets escape is mapped to an error
+ /// response by [#executeSqlQueryCatching] rather than failing the request
with HTTP 500.
+ ///
+ /// @throws QueryException with [QueryErrorCode#ACCESS_DENIED] if the caller
is not authorized, with
+ /// [QueryErrorCode#QUERY_VALIDATION] if the table is
a logical table, with
+ /// [QueryErrorCode#TABLE_DOES_NOT_EXIST] if the
table does not exist, and with the errors of
+ /// [DataManipulationStatementParser#parse] and
[DeleteStatement#resolveTableName]
+ private StreamingOutput executeDelete(SqlNodeAndOptions sqlNodeAndOptions,
HttpHeaders httpHeaders) {
+ AccessControl accessControl = _accessControlFactory.create();
+ if (!accessControl.hasAccess(AccessType.READ, httpHeaders, SQL_ENDPOINT)) {
+ throw QueryErrorCode.ACCESS_DENIED.asException("Permission denied to
delete rows");
+ }
+ DeleteStatement parsed = (DeleteStatement)
DataManipulationStatementParser.parse(sqlNodeAndOptions);
+ DeleteStatement statement =
parsed.resolveTableName(httpHeaders.getHeaderString(CommonConstants.DATABASE),
+ _pinotHelixResourceManager.getTableCache());
+ // Authorized on the resolved table, reported with the name as written
+ String writtenTableName = parsed.getTableName();
+ authorizeDelete(accessControl, statement.getTableName(), writtenTableName,
httpHeaders);
+ if (statement.isLogicalTable()) {
+ throw QueryErrorCode.QUERY_VALIDATION.asException("DELETE does not
support logical tables: " + writtenTableName);
+ }
+ if (!statement.tableExists()) {
+ throw QueryErrorCode.TABLE_DOES_NOT_EXIST.asException("Table does not
exist: " + writtenTableName);
+ }
+ LOGGER.info(deleteLogMessage(statement, httpHeaders));
+ BrokerResponse response = _sqlQueryExecutor.executeStatement(statement,
extractHeaders(httpHeaders));
+ return output -> {
+ try (OutputStream os = output) {
+ response.toOutputStream(os);
+ }
+ };
+ }
+
+ /// Formats the log line of a `DELETE` the executor is about to run: the
resolved table, the predicate, the option
+ /// keys and the client of the request, in the format of the broker's line
so that it parses the same way in the
+ /// logs of both roles. The predicate, the option keys and the client are
kept on a single line (see
+ /// [#toSingleLine]), so that a line ending in the statement, in a request
option or in a proxy header cannot forge
+ /// a log record.
+ @VisibleForTesting
+ static String deleteLogMessage(DeleteStatement statement, HttpHeaders
httpHeaders) {
+ return "Executing DELETE on table: " + statement.getTableName() + ",
predicate: "
+ + toSingleLine(statement.getPredicate()) + ", options: "
+ + toSingleLine(statement.getOptions().keySet().toString()) + ",
client: "
+ + toSingleLine(getClientIp(httpHeaders));
+ }
+
+ /// Client of the request for the log: the `X-Forwarded-For` header, else
the `X-Real-IP` header, else
+ /// [CommonConstants#UNKNOWN]. The addresses of a comma-separated
`X-Forwarded-For` value are joined with `;`, the
+ /// format the broker's `HttpRequesterIdentity#getClientIp` logs, so that
the field parses the same way in the logs
+ /// of both roles. Both headers are set by the caller unless a proxy
overwrites them.
+ private static String getClientIp(HttpHeaders httpHeaders) {
+ String forwardedFor = httpHeaders.getHeaderString("X-Forwarded-For");
Review Comment:
**[PLAUSIBLE — minor hardening] The DELETE audit log's `client` field is
header-only, ungated, with no remote-address fallback.**
It is derived solely from caller-controlled `X-Forwarded-For`/`X-Real-IP`
(spoofable, as the javadoc notes), logged unconditionally (the broker gates on
`pinot.broker.request.client.ip.logging`), and falls back to `unknown` even
though the real peer address is available via `@Context
Request#getRemoteAddr()` (used elsewhere in the controller, e.g.
`PinotSegmentUploadDownloadRestletResource`). Appending the socket address
would make the audit line trustworthy for forensics at negligible cost.
##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java:
##########
@@ -424,6 +445,116 @@ private StreamingOutput executeSqlQuery(@Context
HttpHeaders httpHeaders, String
}
}
+ /// Executes a `DELETE` once the caller is authorized to delete rows from
its table, see [#authorizeDelete].
+ ///
+ /// The caller is first checked without a table (the `READ` access type with
the `/sql` endpoint, as for a
+ /// multi-stage query), before the statement is parsed and its table looked
up: with basic auth, an unauthenticated
+ /// caller gets HTTP 401 here, before the table cache is consulted. The
table is then resolved, with the database of
+ /// the request and in the case it is defined with, and the caller
authorized to delete rows from the resolved
+ /// table. Only then is the table checked not to be a logical table and to
exist, and every error message names the
+ /// table as the caller wrote it, so that a caller who is not authorized for
a table learns neither whether it exists
+ /// nor the case it is defined with, nor whether the name is a logical
table. The executor deletes rows from the
+ /// exact resolved table.
+ ///
+ /// The executor runs before the response streams, so that an exception it
lets escape is mapped to an error
+ /// response by [#executeSqlQueryCatching] rather than failing the request
with HTTP 500.
+ ///
+ /// @throws QueryException with [QueryErrorCode#ACCESS_DENIED] if the caller
is not authorized, with
+ /// [QueryErrorCode#QUERY_VALIDATION] if the table is
a logical table, with
+ /// [QueryErrorCode#TABLE_DOES_NOT_EXIST] if the
table does not exist, and with the errors of
+ /// [DataManipulationStatementParser#parse] and
[DeleteStatement#resolveTableName]
+ private StreamingOutput executeDelete(SqlNodeAndOptions sqlNodeAndOptions,
HttpHeaders httpHeaders) {
+ AccessControl accessControl = _accessControlFactory.create();
+ if (!accessControl.hasAccess(AccessType.READ, httpHeaders, SQL_ENDPOINT)) {
+ throw QueryErrorCode.ACCESS_DENIED.asException("Permission denied to
delete rows");
+ }
+ DeleteStatement parsed = (DeleteStatement)
DataManipulationStatementParser.parse(sqlNodeAndOptions);
+ DeleteStatement statement =
parsed.resolveTableName(httpHeaders.getHeaderString(CommonConstants.DATABASE),
+ _pinotHelixResourceManager.getTableCache());
+ // Authorized on the resolved table, reported with the name as written
+ String writtenTableName = parsed.getTableName();
+ authorizeDelete(accessControl, statement.getTableName(), writtenTableName,
httpHeaders);
+ if (statement.isLogicalTable()) {
+ throw QueryErrorCode.QUERY_VALIDATION.asException("DELETE does not
support logical tables: " + writtenTableName);
+ }
+ if (!statement.tableExists()) {
+ throw QueryErrorCode.TABLE_DOES_NOT_EXIST.asException("Table does not
exist: " + writtenTableName);
+ }
+ LOGGER.info(deleteLogMessage(statement, httpHeaders));
+ BrokerResponse response = _sqlQueryExecutor.executeStatement(statement,
extractHeaders(httpHeaders));
+ return output -> {
+ try (OutputStream os = output) {
+ response.toOutputStream(os);
+ }
+ };
+ }
+
+ /// Formats the log line of a `DELETE` the executor is about to run: the
resolved table, the predicate, the option
+ /// keys and the client of the request, in the format of the broker's line
so that it parses the same way in the
+ /// logs of both roles. The predicate, the option keys and the client are
kept on a single line (see
+ /// [#toSingleLine]), so that a line ending in the statement, in a request
option or in a proxy header cannot forge
+ /// a log record.
+ @VisibleForTesting
+ static String deleteLogMessage(DeleteStatement statement, HttpHeaders
httpHeaders) {
+ return "Executing DELETE on table: " + statement.getTableName() + ",
predicate: "
+ + toSingleLine(statement.getPredicate()) + ", options: "
+ + toSingleLine(statement.getOptions().keySet().toString()) + ",
client: "
+ + toSingleLine(getClientIp(httpHeaders));
+ }
+
+ /// Client of the request for the log: the `X-Forwarded-For` header, else
the `X-Real-IP` header, else
+ /// [CommonConstants#UNKNOWN]. The addresses of a comma-separated
`X-Forwarded-For` value are joined with `;`, the
+ /// format the broker's `HttpRequesterIdentity#getClientIp` logs, so that
the field parses the same way in the logs
+ /// of both roles. Both headers are set by the caller unless a proxy
overwrites them.
+ private static String getClientIp(HttpHeaders httpHeaders) {
+ String forwardedFor = httpHeaders.getHeaderString("X-Forwarded-For");
+ if (forwardedFor != null) {
+ return forwardedFor.replace(',', ';');
+ }
+ String realIp = httpHeaders.getHeaderString("X-Real-IP");
+ return realIp != null ? realIp : CommonConstants.UNKNOWN;
+ }
+
+ /// Escapes backslashes, CR and LF as `\\`, `\r` and `\n`, as the broker's
query log does, so that a value the
+ /// caller controls (a string literal or a quoted identifier of the
predicate, an option key from `SET`, the legacy
+ /// `OPTION(...)` suffix or the request `queryOptions`, a proxy header)
cannot split a log record or append a line
+ /// that looks like a log entry.
+ private static String toSingleLine(String value) {
Review Comment:
**[Cleanup — reuse] Third copy of the `toSingleLine` escaping helper.**
This is byte-identical to `QueryLogger.toSingleLine` (private) and
`PinotClientRequest.toSingleLine`. The javadocs promise the broker and
controller lines "parse the same way" — a promise three hand-synced copies will
eventually break. Since the PR already made `QueryLogger.redactQuery` public
static for cross-class use, hoisting one `toSingleLine` into pinot-common (or
making QueryLogger's public) and calling it from all three places would pin the
contract.
##########
pinot-broker/src/main/java/org/apache/pinot/broker/broker/ZkBasicAuthAccessControlFactory.java:
##########
@@ -115,6 +123,26 @@ public TableAuthorizationResult
authorize(RequesterIdentity requesterIdentity, S
return new TableAuthorizationResult(failedTables);
}
+ @Override
+ public AuthorizationResult authorizeDeleteRows(RequesterIdentity
requesterIdentity,
Review Comment:
**[Cleanup — duplication] `authorizeDeleteRows` is copy-pasted between the
file-based and ZK basic-auth factories.**
Same principal lookup, same three failure messages, differing only in
table-name matching (raw here, as-given + raw exclusion in the file-based
variant). A fix to one (message wording, name-matching subtleties) won't reach
the other. A shared helper next to `BasicAuthPrincipal` taking the principal
lookup and a name-matching predicate would keep the two implementations in
lockstep.
##########
pinot-common/src/main/java/org/apache/pinot/sql/parsers/dml/DeleteStatement.java:
##########
@@ -0,0 +1,480 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.sql.parsers.dml;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.calcite.avatica.util.Casing;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlDelete;
+import org.apache.calcite.sql.SqlDialect;
+import org.apache.calcite.sql.SqlIdentifier;
+import org.apache.calcite.sql.SqlIntervalLiteral;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlLiteral;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlNumericLiteral;
+import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.SqlSyntax;
+import org.apache.calcite.sql.SqlWindow;
+import org.apache.calcite.sql.fun.SqlBetweenOperator;
+import org.apache.calcite.sql.fun.SqlLikeOperator;
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
+import org.apache.calcite.sql.parser.SqlParserPos;
+import org.apache.calcite.sql.util.SqlBasicVisitor;
+import org.apache.calcite.sql.util.SqlShuttle;
+import org.apache.calcite.sql.validate.SqlValidatorUtil;
+import org.apache.pinot.common.config.provider.TableCache;
+import org.apache.pinot.common.request.Expression;
+import org.apache.pinot.common.request.ExpressionType;
+import org.apache.pinot.common.request.Function;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DatabaseUtils;
+import org.apache.pinot.spi.config.task.AdhocTaskConfig;
+import org.apache.pinot.spi.exception.QueryErrorCode;
+import org.apache.pinot.spi.exception.QueryException;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.apache.pinot.sql.parsers.CalciteSqlParser;
+import org.apache.pinot.sql.parsers.SqlNodeAndOptions;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+
+/// A SQL `DELETE FROM <table> WHERE <predicate>` statement.
+///
+/// Pinot parses `DELETE` but does not delete rows itself: the default SQL
executor answers that it is not supported,
+/// and a deployment that can delete rows, e.g. by purging the matching rows
from the segments with a minion task,
+/// executes the parsed statement by overriding
`SqlQueryExecutor#executeDelete`. Its execution type is
+/// [ExecutionType#EXECUTOR]: the generic [#execute()], [#getResultSchema()]
and [#generateAdhocTaskConfig()] do not
+/// apply to it.
+///
+/// Before handing the statement to the executor, the broker and the
controller resolve its table with
+/// [#resolveTableName], authorize the caller to delete rows from that table,
and check that the table is not a
+/// logical table with [#isLogicalTable()] and that it exists with
[#tableExists()].
+///
+/// Its options are the `SET` statements, the legacy `OPTION(...)` suffix and
the request `queryOptions` of the
+/// statement (`SET` takes precedence). The `database` option only qualifies
the table name, see [#resolveTableName].
+/// Every other option, including query options such as `timeoutMs`, is passed
to the executor through
+/// [#getOptions()], so that an option the executor relies on (e.g. a dry run)
cannot be reclassified as a query option
+/// and silently dropped.
+///
+/// Instances are immutable and thread-safe.
+public class DeleteStatement implements DataManipulationStatement {
+ public static final String NOT_SUPPORTED_MESSAGE =
+ "DELETE is not supported by this endpoint, it requires a SQL executor
that implements row deletion";
+
+ /// Serializes the WHERE clause into SQL that the Pinot parser reads back
into the same expression. Combined with
+ /// `quoteAllIdentifiers = false`, identifiers are only quoted (with double
quotes) when they were quoted.
+ private static final SqlDialect PINOT_SQL_DIALECT = new
SqlDialect(SqlDialect.EMPTY_CONTEXT
+ .withIdentifierQuoteString("\"")
+ .withLiteralQuoteString("'")
+ .withLiteralEscapedQuoteString("''")
+ .withUnquotedCasing(Casing.UNCHANGED)
+ .withQuotedCasing(Casing.UNCHANGED)
+ .withCaseSensitive(true));
+
+ /// Quotes the unquoted identifiers named like a SQL function without
arguments, e.g. `user`, `pi` or `current_date`:
+ /// Calcite unparses them as upper-cased keywords, while Pinot reads them as
columns, so they would name another
+ /// column. Mirrors the check of `SqlUtil.unparseSqlIdentifierSyntax`.
+ private static final SqlShuttle KEYWORD_IDENTIFIER_QUOTER = new SqlShuttle()
{
+ @Override
+ public SqlNode visit(SqlIdentifier identifier) {
+ if (identifier.isSimple() && !identifier.getParserPosition().isQuoted())
{
+ SqlOperator operator =
+
SqlValidatorUtil.lookupSqlFunctionByID(SqlStdOperatorTable.instance(),
identifier, null);
+ if (operator != null && (operator.getSyntax() == SqlSyntax.FUNCTION_ID
+ || operator.getSyntax() == SqlSyntax.FUNCTION_ID_CONSTANT)) {
+ return new SqlIdentifier(identifier.names, null,
SqlParserPos.QUOTED_ZERO,
+ List.of(SqlParserPos.QUOTED_ZERO));
+ }
+ }
+ return identifier;
+ }
+ };
+
+ /// Canonical names (upper case, without underscores) of the functions that
read another table than the one the
+ /// statement deletes from, which the caller is not authorized to read.
Upper case because the compiled function
+ /// names are lower-cased with the default locale, and lower-casing with a
Turkish locale turns `I` into the dotless
+ /// `ı` (U+0131), which only an upper-case fold with [Locale#ROOT] maps back
to `I`.
+ private static final Set<String> CROSS_TABLE_FUNCTIONS = Set.of("LOOKUP",
"INSUBQUERY", "INPARTITIONEDSUBQUERY");
Review Comment:
**[Cleanup — altitude] The cross-table/script function blocklist will
silently go stale.**
Nothing ties `CROSS_TABLE_FUNCTIONS`/`SCRIPT_FUNCTIONS` to where such
functions are registered: a future cross-table or script function added in
another module passes into DELETE predicates by default, re-opening the
authorization/groovy-policy hole this set closes. Consider a central
classification (a flag on the function registry) checked here, or at minimum a
pointer comment at the registration site(s) so new functions update this set.
The `like()` 3-operand gap found in this review is an instance of the same
fail-open-by-default shape.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]