pinot icon indicating copy to clipboard operation
pinot copied to clipboard

Add Row-Level Security (RLS) Support for ZooKeeper-Based Authentication (#17220)

Open NihalJain opened this issue 3 months ago • 6 comments

This commit extends Row-Level Security (RLS) functionality to ZooKeeper-based authentication (ZkBasicAuthAccessControlFactory), enabling dynamic user management with table-level filter controls via REST API.

Previously, RLS filters were only supported with file-based BasicAuth configuration. ZK-based authentication passed empty RLS filter maps, preventing users from applying row-level restrictions when using ZooKeeper for user management.

Changes:

  • Extended UserConfig to store RLS filters per table (Map<String, List<String>>)
  • Updated AccessControlUserConfigUtils to serialize/deserialize RLS filters to/from ZNRecord
  • Modified BasicAuthUtils to extract RLS filters from UserConfig and pass to ZkBasicAuthPrincipal
  • Implemented getRowColFilters() in ZkBasicAuthAccessControlFactory to return RLS filters
  • Updated UserConfigBuilder to support RLS filter configuration
  • Added comprehensive unit tests for UserConfig, UserConfigBuilder, BasicAuthTests, PinotAccessControlUserRestletResourceTest, AccessControlUserConfigUtils etc.
  • Added integration tests for ZkAuth RLS scenarios by extracting base class from existing BasicAuth tests

API Example:

POST /users
{
  "username": "user",
  "password": "secret",
  "component": "BROKER",
  "role": "USER",
  "tables": ["table1", "table2"],
  "permissions": ["READ"],
  "rlsFilters": {
    "table1": ["country='US'"],
    "table2": ["department='Engineering'", "level='Senior'"]
  }
}

NihalJain avatar Dec 01 '25 17:12 NihalJain

Manual testing:

  1. Tested REST API create/get/delete operations with RLS filters
  • POST

    curl -X 'POST' \
      'http://localhost:9001/users' \
      -H 'accept: application/json' \
      -H 'Authorization: Basic YWRtaW46dmVyeXNlY3JldA==' \
      -H 'Content-Type: application/json' \
      -d '{
      "username": "userRLS_Simple",
      "password": "secretSimple",
      "component": "BROKER",
      "role": "USER",
      "tables": ["airlineStats"],
      "permissions": ["READ"],
      "rlsFilters": {"airlineStats": ["AirlineID='\''19805'\''"]}
    }'
    
    {
      "status": "User userRLS_Simple_BROKER has been successfully added!"
    }
    
  • GET

    curl -X 'GET' \
      'http://localhost:9001/users/userRLS_Simple?component=BROKER' \
      -H 'accept: application/json' \
      -H 'Authorization: Basic YWRtaW46dmVyeXNlY3JldA=='
    
    {
      "userRLS_Simple_BROKER": {
        "username": "userRLS_Simple",
        "password": "$2a$10$lA8P0gGKpG66Ype3wTmrp.2Bel8jHbQNglf8g/Cai7DHUbY/LZnzS",
        "component": "BROKER",
        "role": "USER",
        "tables": [
          "airlineStats"
        ],
        "permissions": [
          "READ"
        ],
        "rlsFilters": {
          "airlineStats": [
            "AirlineID='19805'"
          ]
        },
        "usernameWithComponent": "userRLS_Simple_BROKER"
      }
    }
    
  • DELETE

    curl -X 'DELETE' \
      'http://localhost:9001/users/userRLS_Simple?component=BROKER' \
      -H 'accept: application/json' \
      -H 'Authorization: Basic YWRtaW46dmVyeXNlY3JldA=='
    
    {
      "status": "User: userRLS_Simple_BROKER has been successfully deleted"
    }
    
  1. Tested queries work as expected with rls for the ZK-based authentication
  ▸ Row-Level Security (RLS) Testing

  Testing: RLS filter application on airlineStats:
DEBUG: curl -s -w \n%{http_code} -u admin:verysecret -X POST -H Content-Type: application/json -d {"sql":"SELECT COUNT(*) FROM airlineStats LIMIT 1"} http://localhost:8099/query/sql
DEBUG: Response body: {"resultTable":{"dataSchema":{"columnNames":["count(*)"],"columnDataTypes":["LONG"]},"rows":[[289]]},"numRowsResultSet":1,"partialResult":false,"exceptions":[],"numGroupsLimitReached":false,"numGroupsWarningLimitReached":false,"timeUsedMs":2,"requestId":"97156851000000116","clientRequestId":null,"brokerId":"Broker_localhost_8099","numDocsScanned":1,"totalDocs":289,"numEntriesScannedInFilter":0,"numEntriesScannedPostFilter":1,"numServersQueried":1,"numServersResponded":1,"numSegmentsQueried":1,"numSegmentsProcessed":1,"numSegmentsMatched":1,"numConsumingSegmentsQueried":0,"numConsumingSegmentsProcessed":0,"numConsumingSegmentsMatched":0,"minConsumingFreshnessTimeMs":0,"numSegmentsPrunedByBroker":0,"numSegmentsPrunedByServer":0,"numSegmentsPrunedInvalid":0,"numSegmentsPrunedByLimit":0,"numSegmentsPrunedByValue":0,"brokerReduceTimeMs":0,"offlineThreadCpuTimeNs":0,"realtimeThreadCpuTimeNs":0,"offlineSystemActivitiesCpuTimeNs":0,"realtimeSystemActivitiesCpuTimeNs":0,"offlineResponseSerializationCpuTimeNs":0,"realtimeResponseSerializationCpuTimeNs":0,"offlineTotalCpuTimeNs":0,"realtimeTotalCpuTimeNs":0,"explainPlanNumEmptyFilterSegments":0,"explainPlanNumMatchAllFilterSegments":0,"traceInfo":{},"tablesQueried":["airlineStats"],"offlineThreadMemAllocatedBytes":0,"realtimeThreadMemAllocatedBytes":0,"offlineResponseSerMemAllocatedBytes":0,"realtimeResponseSerMemAllocatedBytes":0,"offlineTotalMemAllocatedBytes":0,"realtimeTotalMemAllocatedBytes":0,"pools":[-1],"rlsFiltersApplied":false,"groupsTrimmed":false}
DEBUG: Response status: 200
    ✓ Admin sees all rows (no RLS, count=289) → 200 OK, count=289
DEBUG: curl -s -w \n%{http_code} -u userRLS:secretRLS -X POST -H Content-Type: application/json -d {"sql":"SELECT COUNT(*) FROM airlineStats LIMIT 1"} http://localhost:8099/query/sql
DEBUG: Response body: {"resultTable":{"dataSchema":{"columnNames":["count(*)"],"columnDataTypes":["LONG"]},"rows":[[45]]},"numRowsResultSet":1,"partialResult":false,"exceptions":[],"numGroupsLimitReached":false,"numGroupsWarningLimitReached":false,"timeUsedMs":3,"requestId":"97156851000000117","clientRequestId":null,"brokerId":"Broker_localhost_8099","numDocsScanned":45,"totalDocs":289,"numEntriesScannedInFilter":289,"numEntriesScannedPostFilter":0,"numServersQueried":1,"numServersResponded":1,"numSegmentsQueried":1,"numSegmentsProcessed":1,"numSegmentsMatched":1,"numConsumingSegmentsQueried":0,"numConsumingSegmentsProcessed":0,"numConsumingSegmentsMatched":0,"minConsumingFreshnessTimeMs":0,"numSegmentsPrunedByBroker":0,"numSegmentsPrunedByServer":0,"numSegmentsPrunedInvalid":0,"numSegmentsPrunedByLimit":0,"numSegmentsPrunedByValue":0,"brokerReduceTimeMs":0,"offlineThreadCpuTimeNs":0,"realtimeThreadCpuTimeNs":0,"offlineSystemActivitiesCpuTimeNs":0,"realtimeSystemActivitiesCpuTimeNs":0,"offlineResponseSerializationCpuTimeNs":0,"realtimeResponseSerializationCpuTimeNs":0,"offlineTotalCpuTimeNs":0,"realtimeTotalCpuTimeNs":0,"explainPlanNumEmptyFilterSegments":0,"explainPlanNumMatchAllFilterSegments":0,"traceInfo":{},"tablesQueried":["airlineStats"],"offlineThreadMemAllocatedBytes":0,"realtimeThreadMemAllocatedBytes":0,"offlineResponseSerMemAllocatedBytes":0,"realtimeResponseSerMemAllocatedBytes":0,"offlineTotalMemAllocatedBytes":0,"realtimeTotalMemAllocatedBytes":0,"pools":[-1],"rlsFiltersApplied":true,"groupsTrimmed":false}
DEBUG: Response status: 200
    ✓ UserRLS sees only TX rows (RLS applied, count=45) → 200 OK, count=45
DEBUG: curl -s -w \n%{http_code} -u userRLS:secretRLS -X POST -H Content-Type: application/json -d {"sql":"SELECT COUNT(*) FROM airlineStats WHERE OriginState='TX' LIMIT 1"} http://localhost:8099/query/sql
DEBUG: Response body: {"resultTable":{"dataSchema":{"columnNames":["count(*)"],"columnDataTypes":["LONG"]},"rows":[[45]]},"numRowsResultSet":1,"partialResult":false,"exceptions":[],"numGroupsLimitReached":false,"numGroupsWarningLimitReached":false,"timeUsedMs":3,"requestId":"97156851000000118","clientRequestId":null,"brokerId":"Broker_localhost_8099","numDocsScanned":45,"totalDocs":289,"numEntriesScannedInFilter":334,"numEntriesScannedPostFilter":0,"numServersQueried":1,"numServersResponded":1,"numSegmentsQueried":1,"numSegmentsProcessed":1,"numSegmentsMatched":1,"numConsumingSegmentsQueried":0,"numConsumingSegmentsProcessed":0,"numConsumingSegmentsMatched":0,"minConsumingFreshnessTimeMs":0,"numSegmentsPrunedByBroker":0,"numSegmentsPrunedByServer":0,"numSegmentsPrunedInvalid":0,"numSegmentsPrunedByLimit":0,"numSegmentsPrunedByValue":0,"brokerReduceTimeMs":0,"offlineThreadCpuTimeNs":0,"realtimeThreadCpuTimeNs":0,"offlineSystemActivitiesCpuTimeNs":0,"realtimeSystemActivitiesCpuTimeNs":0,"offlineResponseSerializationCpuTimeNs":0,"realtimeResponseSerializationCpuTimeNs":0,"offlineTotalCpuTimeNs":0,"realtimeTotalCpuTimeNs":0,"explainPlanNumEmptyFilterSegments":0,"explainPlanNumMatchAllFilterSegments":0,"traceInfo":{},"tablesQueried":["airlineStats"],"offlineThreadMemAllocatedBytes":0,"realtimeThreadMemAllocatedBytes":0,"offlineResponseSerMemAllocatedBytes":0,"realtimeResponseSerMemAllocatedBytes":0,"offlineTotalMemAllocatedBytes":0,"realtimeTotalMemAllocatedBytes":0,"pools":[-1],"rlsFiltersApplied":true,"groupsTrimmed":false}
DEBUG: Response status: 200
    ✓ UserRLS explicit TX query (count=45) → 200 OK, count=45
DEBUG: curl -s -w \n%{http_code} -u userRLS:secretRLS -X POST -H Content-Type: application/json -d {"sql":"SELECT COUNT(*) FROM airlineStats WHERE OriginState='CA' LIMIT 1"} http://localhost:8099/query/sql
DEBUG: Response body: {"resultTable":{"dataSchema":{"columnNames":["count(*)"],"columnDataTypes":["LONG"]},"rows":[[0]]},"numRowsResultSet":1,"partialResult":false,"exceptions":[],"numGroupsLimitReached":false,"numGroupsWarningLimitReached":false,"timeUsedMs":3,"requestId":"97156851000000119","clientRequestId":null,"brokerId":"Broker_localhost_8099","numDocsScanned":0,"totalDocs":289,"numEntriesScannedInFilter":313,"numEntriesScannedPostFilter":0,"numServersQueried":1,"numServersResponded":1,"numSegmentsQueried":1,"numSegmentsProcessed":1,"numSegmentsMatched":0,"numConsumingSegmentsQueried":0,"numConsumingSegmentsProcessed":0,"numConsumingSegmentsMatched":0,"minConsumingFreshnessTimeMs":0,"numSegmentsPrunedByBroker":0,"numSegmentsPrunedByServer":0,"numSegmentsPrunedInvalid":0,"numSegmentsPrunedByLimit":0,"numSegmentsPrunedByValue":0,"brokerReduceTimeMs":0,"offlineThreadCpuTimeNs":0,"realtimeThreadCpuTimeNs":0,"offlineSystemActivitiesCpuTimeNs":0,"realtimeSystemActivitiesCpuTimeNs":0,"offlineResponseSerializationCpuTimeNs":0,"realtimeResponseSerializationCpuTimeNs":0,"offlineTotalCpuTimeNs":0,"realtimeTotalCpuTimeNs":0,"explainPlanNumEmptyFilterSegments":0,"explainPlanNumMatchAllFilterSegments":0,"traceInfo":{},"tablesQueried":["airlineStats"],"offlineThreadMemAllocatedBytes":0,"realtimeThreadMemAllocatedBytes":0,"offlineResponseSerMemAllocatedBytes":0,"realtimeResponseSerMemAllocatedBytes":0,"offlineTotalMemAllocatedBytes":0,"realtimeTotalMemAllocatedBytes":0,"pools":[-1],"rlsFiltersApplied":true,"groupsTrimmed":false}
DEBUG: Response status: 200
    ✓ UserRLS CA query returns 0 (RLS blocks CA, count=0) → 200 OK, count=0

  Testing: RLS Simple filter (AirlineID='19805'):
DEBUG: curl -s -w \n%{http_code} -u userRLS_Simple:secretSimple -X POST -H Content-Type: application/json -d {"sql":"SELECT COUNT(*) FROM airlineStats LIMIT 1"} http://localhost:8099/query/sql
DEBUG: Response body: {"resultTable":{"dataSchema":{"columnNames":["count(*)"],"columnDataTypes":["LONG"]},"rows":[[40]]},"numRowsResultSet":1,"partialResult":false,"exceptions":[],"numGroupsLimitReached":false,"numGroupsWarningLimitReached":false,"timeUsedMs":3,"requestId":"97156851000000120","clientRequestId":null,"brokerId":"Broker_localhost_8099","numDocsScanned":1,"totalDocs":289,"numEntriesScannedInFilter":0,"numEntriesScannedPostFilter":1,"numServersQueried":1,"numServersResponded":1,"numSegmentsQueried":1,"numSegmentsProcessed":1,"numSegmentsMatched":1,"numConsumingSegmentsQueried":0,"numConsumingSegmentsProcessed":0,"numConsumingSegmentsMatched":0,"minConsumingFreshnessTimeMs":0,"numSegmentsPrunedByBroker":0,"numSegmentsPrunedByServer":0,"numSegmentsPrunedInvalid":0,"numSegmentsPrunedByLimit":0,"numSegmentsPrunedByValue":0,"brokerReduceTimeMs":0,"offlineThreadCpuTimeNs":0,"realtimeThreadCpuTimeNs":0,"offlineSystemActivitiesCpuTimeNs":0,"realtimeSystemActivitiesCpuTimeNs":0,"offlineResponseSerializationCpuTimeNs":0,"realtimeResponseSerializationCpuTimeNs":0,"offlineTotalCpuTimeNs":0,"realtimeTotalCpuTimeNs":0,"explainPlanNumEmptyFilterSegments":0,"explainPlanNumMatchAllFilterSegments":0,"traceInfo":{},"tablesQueried":["airlineStats"],"offlineThreadMemAllocatedBytes":0,"realtimeThreadMemAllocatedBytes":0,"offlineResponseSerMemAllocatedBytes":0,"realtimeResponseSerMemAllocatedBytes":0,"offlineTotalMemAllocatedBytes":0,"realtimeTotalMemAllocatedBytes":0,"pools":[-1],"rlsFiltersApplied":true,"groupsTrimmed":false}
DEBUG: Response status: 200
    ✓ UserRLS_Simple sees filtered rows (RLS applied, count=40) → 200 OK, count=40

  Testing: RLS AND filter (OriginState='TX' AND AirlineID='19805'):
DEBUG: curl -s -w \n%{http_code} -u userRLS_And:secretAnd -X POST -H Content-Type: application/json -d {"sql":"SELECT COUNT(*) FROM airlineStats LIMIT 1"} http://localhost:8099/query/sql
DEBUG: Response body: {"resultTable":{"dataSchema":{"columnNames":["count(*)"],"columnDataTypes":["LONG"]},"rows":[[14]]},"numRowsResultSet":1,"partialResult":false,"exceptions":[],"numGroupsLimitReached":false,"numGroupsWarningLimitReached":false,"timeUsedMs":3,"requestId":"97156851000000121","clientRequestId":null,"brokerId":"Broker_localhost_8099","numDocsScanned":14,"totalDocs":289,"numEntriesScannedInFilter":304,"numEntriesScannedPostFilter":0,"numServersQueried":1,"numServersResponded":1,"numSegmentsQueried":1,"numSegmentsProcessed":1,"numSegmentsMatched":1,"numConsumingSegmentsQueried":0,"numConsumingSegmentsProcessed":0,"numConsumingSegmentsMatched":0,"minConsumingFreshnessTimeMs":0,"numSegmentsPrunedByBroker":0,"numSegmentsPrunedByServer":0,"numSegmentsPrunedInvalid":0,"numSegmentsPrunedByLimit":0,"numSegmentsPrunedByValue":0,"brokerReduceTimeMs":0,"offlineThreadCpuTimeNs":0,"realtimeThreadCpuTimeNs":0,"offlineSystemActivitiesCpuTimeNs":0,"realtimeSystemActivitiesCpuTimeNs":0,"offlineResponseSerializationCpuTimeNs":0,"realtimeResponseSerializationCpuTimeNs":0,"offlineTotalCpuTimeNs":0,"realtimeTotalCpuTimeNs":0,"explainPlanNumEmptyFilterSegments":0,"explainPlanNumMatchAllFilterSegments":0,"traceInfo":{},"tablesQueried":["airlineStats"],"offlineThreadMemAllocatedBytes":0,"realtimeThreadMemAllocatedBytes":0,"offlineResponseSerMemAllocatedBytes":0,"realtimeResponseSerMemAllocatedBytes":0,"offlineTotalMemAllocatedBytes":0,"realtimeTotalMemAllocatedBytes":0,"pools":[-1],"rlsFiltersApplied":true,"groupsTrimmed":false}
DEBUG: Response status: 200
    ✓ UserRLS_And sees filtered rows (RLS applied, count=14) → 200 OK, count=14

  Testing: RLS OR filter (OriginState='TX' OR OriginState='CA'):
DEBUG: curl -s -w \n%{http_code} -u userRLS_Or:secretOr -X POST -H Content-Type: application/json -d {"sql":"SELECT COUNT(*) FROM airlineStats LIMIT 1"} http://localhost:8099/query/sql
DEBUG: Response body: {"resultTable":{"dataSchema":{"columnNames":["count(*)"],"columnDataTypes":["LONG"]},"rows":[[84]]},"numRowsResultSet":1,"partialResult":false,"exceptions":[],"numGroupsLimitReached":false,"numGroupsWarningLimitReached":false,"timeUsedMs":6,"requestId":"97156851000000122","clientRequestId":null,"brokerId":"Broker_localhost_8099","numDocsScanned":84,"totalDocs":289,"numEntriesScannedInFilter":578,"numEntriesScannedPostFilter":0,"numServersQueried":1,"numServersResponded":1,"numSegmentsQueried":1,"numSegmentsProcessed":1,"numSegmentsMatched":1,"numConsumingSegmentsQueried":0,"numConsumingSegmentsProcessed":0,"numConsumingSegmentsMatched":0,"minConsumingFreshnessTimeMs":0,"numSegmentsPrunedByBroker":0,"numSegmentsPrunedByServer":0,"numSegmentsPrunedInvalid":0,"numSegmentsPrunedByLimit":0,"numSegmentsPrunedByValue":0,"brokerReduceTimeMs":0,"offlineThreadCpuTimeNs":0,"realtimeThreadCpuTimeNs":0,"offlineSystemActivitiesCpuTimeNs":0,"realtimeSystemActivitiesCpuTimeNs":0,"offlineResponseSerializationCpuTimeNs":0,"realtimeResponseSerializationCpuTimeNs":0,"offlineTotalCpuTimeNs":0,"realtimeTotalCpuTimeNs":0,"explainPlanNumEmptyFilterSegments":0,"explainPlanNumMatchAllFilterSegments":0,"traceInfo":{},"tablesQueried":["airlineStats"],"offlineThreadMemAllocatedBytes":0,"realtimeThreadMemAllocatedBytes":0,"offlineResponseSerMemAllocatedBytes":0,"realtimeResponseSerMemAllocatedBytes":0,"offlineTotalMemAllocatedBytes":0,"realtimeTotalMemAllocatedBytes":0,"pools":[-1],"rlsFiltersApplied":true,"groupsTrimmed":false}
DEBUG: Response status: 200
    ✓ UserRLS_Or sees filtered rows (RLS applied, count=84) → 200 OK, count=84

PS: This was done with a bash script which I plan to contribute later with another PR

NihalJain avatar Dec 01 '25 17:12 NihalJain

Codecov Report

:x: Patch coverage is 64.70588% with 12 lines in your changes missing coverage. Please review. :white_check_mark: Project coverage is 63.30%. Comparing base (4e3599c) to head (6d96961). :warning: Report is 37 commits behind head on master.

Files with missing lines Patch % Lines
...broker/broker/ZkBasicAuthAccessControlFactory.java 0.00% 10 Missing :warning:
...mon/utils/config/AccessControlUserConfigUtils.java 93.75% 0 Missing and 1 partial :warning:
...a/org/apache/pinot/spi/config/user/UserConfig.java 75.00% 1 Missing :warning:
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #17295      +/-   ##
============================================
+ Coverage     63.23%   63.30%   +0.07%     
- Complexity     1433     1434       +1     
============================================
  Files          3133     3133              
  Lines        186131   186180      +49     
  Branches      28408    28415       +7     
============================================
+ Hits         117704   117866     +162     
+ Misses        59363    59262     -101     
+ Partials       9064     9052      -12     
Flag Coverage Δ
custom-integration1 100.00% <ø> (ø)
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-11 63.25% <64.70%> (+0.03%) :arrow_up:
java-21 63.26% <64.70%> (+7.65%) :arrow_up:
temurin 63.30% <64.70%> (+0.07%) :arrow_up:
unittests 63.30% <64.70%> (+0.07%) :arrow_up:
unittests1 55.73% <91.66%> (+0.07%) :arrow_up:
unittests2 33.92% <50.00%> (+0.05%) :arrow_up:

Flags with carried forward coverage won't be shown. Click here to find out more.

:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.

:rocket: New features to boost your workflow:
  • :package: JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

codecov-commenter avatar Dec 01 '25 18:12 codecov-commenter

Please review @Jackie-Jiang @vrajat @9aman @yashmayya

NihalJain avatar Dec 01 '25 18:12 NihalJain

Ok so the test failure is due to a test added in this pr. i reverted the unrelated user config change in 2nd commit, but forgot to remove the relevant newly added test and hence the failure. will remove this test in next commit and add as part of user config validation enhancement pr later.

NihalJain avatar Dec 01 '25 20:12 NihalJain

Failure is not related.

NihalJain avatar Dec 03 '25 05:12 NihalJain

Hi team could you please provide some feedback, I need to start re-work on https://github.com/apache/pinot/pull/15861 after this PR.

NihalJain avatar Dec 12 '25 07:12 NihalJain

@9aman Can you help review this?

Jackie-Jiang avatar Jan 07 '26 23:01 Jackie-Jiang