Conversation
The registry server's REST and gRPC paths bind the SecurityManager to the project each request names, so its permission list is loaded for that project. The Arrow Flight offline server never did: both of its dispatchers ran the handlers, and therefore `assert_permissions`, with whatever project the SecurityManager was constructed from — the project in the server's own `feature_store.yaml`. An offline server that serves more than one project consequently enforced its home project's `Permission` list against every request, so a role granted only in that project reached the other projects' data sources and feature views, while the policies those projects defined for themselves were never consulted. `get_historical_features` already requires a `project` in its command, so the per-request project was available all along. Bind the SecurityManager to `command["project"]` around both dispatchers and reset it afterwards, following the interceptor in permissions/server/grpc.py. A command that carries no project passes `None`, which the SecurityManager falls back from to the server's own project, leaving those paths unchanged. Fixes feast-dev#6784 Signed-off-by: Chen Yufan <[email protected]>
292fa1c to
bb18433
Compare
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #6803 +/- ##
=======================================
Coverage 47.08% 47.08%
=======================================
Files 419 419
Lines 51877 51877
Branches 7525 7525
=======================================
Hits 24428 24428
Misses 25700 25700
Partials 1749 1749
*This pull request uses carry forward flags. Click here to find out more.
Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
|
@hiyufan The Arrow Flight offline server never scopes However, the current implementation has a bug that needs to be fixed before merging:
Something like this in both from feast.permissions.security_manager import get_security_manager
sm = get_security_manager()
sm_token = None
if sm is not None:
sm_token = sm.set_current_project(command.get("project"))
try:
# ... existing handler logic ...
finally:
if sm is not None and sm_token is not None:
sm.reset_current_project(sm_token)You can keep The tests should also be updated to assert that the SecurityManager's project was set, not just the FeatureStore's — currently they only verify |
…roject
Review caught that the previous commit did not do what it claimed. `FeatureStore`
and `SecurityManager` each own a separate `ContextVar`, and `SecurityManager.
permissions` reads its own:
project = self._current_project.get() or self._project
return self._registry.list_permissions(project=project)
so `self.store.set_current_project(...)` never reached the permission lookup, and
every check still ran against the project the server was started from. The store
binding is kept -- it is what `FeatureStore.project` resolves objects through, which
`get_data_source` and friends need -- but the security manager has to be bound too,
the way `permissions/server/grpc.py`, `permissions/server/rest.py` and
`api/registry/rest/rest_utils.py` already do it.
The tests were the reason this got through: they asserted that
`store.set_current_project` had been called on a `MagicMock`, which is true either
way and proves nothing about the check. They now install a real `SecurityManager`
whose registry records the project each permission lookup resolves to, and assert on
that -- inside the handler it must be the requested project, and after the dispatcher
returns it must be the server's own again, so the reset is covered by the same
assertion. Both fail against the previous commit:
assert ['the_project_the_server_was_started_from'] == ['project_b', ...]
Also covers a deployment with no SecurityManager at all, which the dispatchers have
to keep serving.
Fixes feast-dev#6784
Signed-off-by: Chen Yufan <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
|
You are right, and thank you for reading the code rather than the description — the description said the right thing and the code did not do it. Fixed in I confirmed it before changing anything: Two independent Both dispatchers now bind the security manager, following the three paths you pointed at, and keep the store binding for the reason you gave: project = command.get("project")
sm = get_security_manager()
sm_token = sm.set_current_project(project) if sm is not None else None
project_token = self.store.set_current_project(project)
try:
...
finally:
self.store.reset_current_project(project_token)
if sm is not None and sm_token is not None:
sm.reset_current_project(sm_token)The tests were the real problemYou are right that asserting They now install a real registry.list_permissions.side_effect = lambda project=None, **kw: (seen.append(project) or [])
sm = SecurityManager(project=_SERVER_HOME_PROJECT, registry=registry)
...
OfflineServer._call_api(server, command["api"], command, key) # handler reads sm.permissions
sm.permissions # after the dispatcher returns
assert seen == ["project_b", _SERVER_HOME_PROJECT]One assertion covers both halves: inside the handler the check has to resolve to the requested project, and afterwards it has to be back to the server's own, so the reset is proved rather than assumed. Against the previous commit: Added
Unrelated: the red CIAll five failing jobs on the previous head died in dependency installation, before running anything: lint-python, the three unit-test jobs and smoke-test-python all have that same line. Nothing to do with this branch, but worth knowing before you read the red. And on your framing — agreed that this is defense in depth rather than critical, given one-server-per-project deployments. I would rather it be described that way in the changelog than oversold. |
What this PR does / why we need it:
SecurityManager.permissionsis loaded per project, and every request-facing path issupposed to bind it to the project the request names before any permission check runs.
The registry server does this —
api/registry/rest/rest_utils.py,permissions/server/rest.py,permissions/server/grpc.pyandregistry_server.pyallcall
set_current_project(...)and reset it afterwards.The Arrow Flight offline server does not. Neither of its two dispatchers —
OfflineServer.do_getandOfflineServer._call_api— touches the current project, soevery
assert_permissionscall in the handlers below them is evaluated against thePermissionlist of whatever project theSecurityManagerwas constructed from, whichis the project in the server's own
feature_store.yaml.An offline server that serves more than one project therefore enforces its home
project's policy on every request. A role granted only in that project reaches the other
projects' data sources, feature views and saved datasets, and the policies those projects
defined for themselves are never loaded.
get_historical_featuresalready asserts thatprojectis mandatory in its command and uses it to resolve feature views, so theper-request project was available at the dispatcher the whole time — it just never
reached the permission layer.
The fix
Bind the
SecurityManagertocommand["project"]around both dispatchers and reset itin a
finally, following the interceptor inpermissions/server/grpc.py:A command that carries no project passes
None.SecurityManager.permissionsalreadyfalls back from
Noneto the server's own project, so those paths —offline_write_batchand
write_logged_features, which readself.store.config.project— behave exactly asbefore.
Which issue(s) this PR fixes:
Relates to #6784 (advisory
GHSA-5px7-7gwg-6g93).That issue is written against the registry server, and the registry paths already scope
the lookup on
master. This is the same defect on the Arrow Flight offline server, whichwas not covered. I have left it as "relates to" rather than "fixes" so you can decide
whether it closes the issue or belongs as a follow-up.
Checks
git commit -s)Testing Strategy
Four tests in
sdk/python/tests/unit/test_offline_server.py:test_do_get_scopes_permissions_to_the_requested_projecttest_call_api_scopes_permissions_to_the_requested_projecttest_call_api_resets_the_project_when_the_handler_raises— a failed request must notleave its project bound for the next one
test_call_api_without_a_project_leaves_the_lookup_unchanged— pins theNonefallback, so the paths that do not send a project keep their current behaviour
All four fail on the base revision and pass with the change:
pre-commitpasses on both changed files.Misc
The scope here is deliberately narrow: it moves the offline server onto the same
per-request project binding the registry server already uses, and changes nothing about
how permissions themselves are defined or evaluated. If you would rather see the binding
lifted into a shared Flight middleware — the way
permissions/server/grpc.pydoes it forgRPC, instead of at each dispatcher — I am happy to rework it that way.
This change was developed with AI assistance. The test runs and the base-revision
comparison reported above were executed locally against this branch as submitted.