feat: add authz schema discovery - #474
Conversation
|
Thanks for the pull request, @rodmgwgu! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
| return [] | ||
| discovered: list[DiscoveredResource] = [] | ||
| for directory in directories: | ||
| discovered.extend(self._iter_directory(directory, origin="settings")) |
There was a problem hiding this comment.
It’s not clear from the setting name whether the directory should be relative or absolute. For example, Mako templates use absolute directories for their configuration. Based on that, I couldn’t tell at first glance whether this referred to a directory of an installed module or to an absolute path.
There was a problem hiding this comment.
Also, can we use an origin class for the "settings" string?
There was a problem hiding this comment.
Good point, about the directory, it is actually a importlib.resources anchor + path.
I'll add documentation to clarify this.
mariajgrimaldi
left a comment
There was a problem hiding this comment.
Few comments about testing :)
Also, do you think some of the decisions made here should be documented in an ADR or the general ADR in #422 is enough?
| discovered: list[DiscoveredResource] = [] | ||
| for entry in entries: | ||
| if not entry.name.endswith(SCHEMA_FILE_SUFFIXES): | ||
| continue | ||
| if not entry.is_file(): | ||
| continue | ||
| resource_path = f"{subpath}/{entry.name}" if subpath else entry.name | ||
| discovered.append( | ||
| DiscoveredResource(package=anchor, resource_path=resource_path, module=module, origin=origin) | ||
| ) | ||
| return discovered |
There was a problem hiding this comment.
Wouldn't this work as an iter?
| discovered: list[DiscoveredResource] = [] | |
| for entry in entries: | |
| if not entry.name.endswith(SCHEMA_FILE_SUFFIXES): | |
| continue | |
| if not entry.is_file(): | |
| continue | |
| resource_path = f"{subpath}/{entry.name}" if subpath else entry.name | |
| discovered.append( | |
| DiscoveredResource(package=anchor, resource_path=resource_path, module=module, origin=origin) | |
| ) | |
| return discovered | |
| for entry in entries: | |
| if not entry.name.endswith(SCHEMA_FILE_SUFFIXES): | |
| continue | |
| if not entry.is_file(): | |
| continue | |
| resource_path = f"{subpath}/{entry.name}" if subpath else entry.name | |
| yield DiscoveredResource(package=anchor, resource_path=resource_path, module=module, origin=origin) |
| return [] | ||
| discovered: list[DiscoveredResource] = [] | ||
| for directory in directories: | ||
| discovered.extend(self._iter_directory(directory, origin="settings")) |
There was a problem hiding this comment.
Also, can we use an origin class for the "settings" string?
| assert {r.origin for r in resources} == {"settings"} | ||
|
|
||
| def test_missing_django_contributes_nothing(self): | ||
| """The Casbin-free steps must stay importable and runnable without Django.""" |
There was a problem hiding this comment.
In which cases we won't have django available?
There was a problem hiding this comment.
None, in reality what we wanted to test here was for when the settings module is not available, changed to reflect that.
| @@ -0,0 +1,276 @@ | |||
| """Tests for directory-based schema discovery (ADR 0019).""" | |||
There was a problem hiding this comment.
I think I'm missing consistent docstrings from this module so it's easier to understand what's expected from each test.
There was a problem hiding this comment.
Refactored to classes and added docstrings.
| def test_entry_point_origin_wins_over_explicit_duplicate(self): | ||
| """The same file from two routes is kept once, tagged with the first route.""" | ||
| resources = SchemaDiscovery(explicit_directories=[SCHEMA_DIR]).discover() | ||
|
|
||
| assert {r.origin for r in resources} == {"entry_point"} | ||
| assert len(resources) == len(EXPECTED_FILES) |
There was a problem hiding this comment.
Is this precedence documented somewhere? Not sure if it's already in an ADR.
There was a problem hiding this comment.
I think this is more of an implementation detail, as it doesn't affect functionality, it only changes how the origin is marked, but the origin is only for diagnostics.
b2ed726 to
3b467d7
Compare
3b467d7 to
c5853aa
Compare
| """ | ||
| try: | ||
| return resources.files(self.package).joinpath(self.resource_path).read_bytes() | ||
| except (FileNotFoundError, ModuleNotFoundError, OSError) as exc: |
There was a problem hiding this comment.
FileNotFoundError is a subclass of OSError
| except (FileNotFoundError, ModuleNotFoundError, OSError) as exc: | |
| except (ModuleNotFoundError, OSError) as exc: |
| base = resources.files(anchor) | ||
| target = base.joinpath(subpath) if subpath else base | ||
| entries = sorted(target.iterdir(), key=lambda entry: entry.name) | ||
| except (FileNotFoundError, ModuleNotFoundError, NotADirectoryError, OSError) as exc: |
There was a problem hiding this comment.
| except (FileNotFoundError, ModuleNotFoundError, NotADirectoryError, OSError) as exc: | |
| except (ModuleNotFoundError, OSError) as exc: |
| try: | ||
| base = resources.files(anchor) | ||
| target = base.joinpath(subpath) if subpath else base | ||
| entries = sorted(target.iterdir(), key=lambda entry: entry.name) |
There was a problem hiding this comment.
Is it necessary to sort here, considering it will be sorted later in .discover()?
There was a problem hiding this comment.
not necessary, removed, thanks!
| ) from exc | ||
|
|
||
|
|
||
| class SchemaDiscoveryError(Exception): |
There was a problem hiding this comment.
Should we move this error class to the exceptions module?
There was a problem hiding this comment.
We don't currently have an exceptions module, perhaps we can consider this as an improvement later when we have more exception classes.
mariajgrimaldi
left a comment
There was a problem hiding this comment.
LGTM. I don't have any other comments left :), thanks a lot!
We can merge this once we address Bryann's questions!
| return discovered | ||
|
|
||
| def _iter_directory(self, directory: str, *, origin: Origin) -> list[DiscoveredResource]: | ||
| """Resolve a directory path and yield a resource per ``.yaml`` file. |
There was a problem hiding this comment.
Nit: I got a bit confused because of this docstring. It's not yielding a resource but all resources in the dir instead, can we update the yielding part or update the iter to return a resource (yield resource)?
21f461a to
5c642be
Compare
c9c6afc to
498dd8e
Compare
Problem
Authorization schema resources need to be located before anything can be loaded: from the
authz.schemaentry-point group so applications ship definitions with their code, and from explicit directories so operators can contribute them through deployment configuration (ADR 0019).Approach
The discovery phase only — no parsing, no models, no database.
openedx_authz/engine/schema/discovery.py—SchemaDiscovery,DiscoveredResource,SchemaDiscoveryErrorsetup.py— registers this package's own YAML files under theauthz.schemagroupopenedx_authz/tests/schema/test_discovery.pyResources are located and identified (package anchor, resource path, module, origin); reading and parsing them is the next PR in the stack.
Manual testing instructions
Entry-point discovery is exercised against this package's real installed metadata, so it covers the
setup.pychange.Rollback plan
Revert this PR. Nothing consumes discovery yet, so the revert is inert.
Retro compatibility
No authorization behavior changes. No models, no migration, and no code path runs unless
SchemaDiscoveryis called explicitly.AI Usage
Kiro was used to assist on feature planning and implementation. Implementation was done step by step with human guidance and validation, based on the ADRs.
Stack (1/8) — #446 split into reviewable pieces. Bases chain bottom-up; merge in order.
main)rod/authz-schema-loader-only— schema loadingrod/authz-schema-compiler— schema compilationrod/authz-schema-validator— schema validationrod/authz-schema-models— definition models + migrationrod/authz-schema-renderer— policy rendererrod/authz-schema-applier— schema applierload_authz_schemacommand, version bump and changelogMerge checklist: