Repository navigation
feat: add authz schema policy renderer #479
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| """Render compiled definitions to Casbin rows (ADR 0018 §1, §5). | ||
|
|
||
| This is the Casbin-aware edge of the schema pipeline. It implements the | ||
| ``render`` lifecycle step: | ||
|
|
||
| * ``render`` builds the Casbin ``p`` rows for a :class:`CompiledSchema` in | ||
| memory, without touching the database. | ||
|
|
||
| Key semantics: | ||
| * Definition rows only: ``render`` emits ``p`` rows and never ``g`` | ||
| (assignments) or ``g2`` (legacy action inheritance), so data owned by | ||
| other services is out of its reach by construction (ADR 0018 §3). | ||
| * Namespacing happens here, at the boundary: schema objects carry bare | ||
| identifiers, and the internal Casbin form (``role^``, ``act^``, | ||
| ``<scope>^*``) is applied on the way out. | ||
| * Deterministic output: rows are emitted in sorted order, so the rendered | ||
| set can be compared against a stored policy without spurious diffs. | ||
|
|
||
| ``render`` is pure — it imports nothing from Casbin or Django and performs no | ||
| database access. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field | ||
|
|
||
| from openedx_authz.data import ( | ||
| ACTION_NAMESPACE, | ||
| EFFECT_ALLOW, | ||
| POLICY_PTYPE, | ||
| ROLE_NAMESPACE, | ||
| SCOPE_WILDCARD, | ||
| PolicyIndex, | ||
| ) | ||
| from openedx_authz.data import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR as SEP | ||
| from openedx_authz.engine.schema.types import CompiledSchema, RoleDefinition | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class PolicyRow: | ||
| """A single Casbin ``p`` row rendered from a role-permission pair. | ||
|
|
||
| Fields follow the ``p`` shape: subject (role), action (permission), scope | ||
| pattern, effect. Namespacing to the internal Casbin form (``role^``, | ||
| ``act^``, ``<scope>^*``) happens here, at the boundary — schema objects | ||
| never carry those prefixes. | ||
| """ | ||
|
|
||
| ptype: str # always "p" for rendered definition rows | ||
| subject: str | ||
| action: str | ||
| scope: str | ||
| effect: str | ||
|
|
||
| def as_policy(self) -> list[str]: | ||
| """Return the enforcer arg form: ``[subject, action, scope, effect]``.""" | ||
| return [self.subject, self.action, self.scope, self.effect] | ||
|
|
||
| @classmethod | ||
| def from_policy(cls, values: list[str]) -> "PolicyRow": | ||
| """Build from a stored ``p`` row (``[subject, action, scope, effect]``). | ||
|
|
||
| Parsing is delegated to the shared, strict | ||
| :meth:`~openedx_authz.data.PolicyIndex.parse`, so this stays in step | ||
| with the ``api`` layer. A partially populated row is padded first, so an | ||
| in-memory row round-trips instead of being rejected. | ||
| """ | ||
| subject, action, scope, effect = PolicyIndex.parse(PolicyIndex.pad(values)) | ||
| return cls(POLICY_PTYPE, subject, action, scope, effect) | ||
|
|
||
| @classmethod | ||
| def from_grant(cls, role_id: str, permission_id: str, scope: str) -> "PolicyRow": | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we've been calling it assignment
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assignment would be when we assign a role to a user over a scope. Here we are assigning a permission to a role, I have been using grant to design this since ADR 0025 I think. These terms are confusing sometimes... What do you think about this idea of using "assignment" for user-role, and "grant" for role-permission? |
||
| """Build the Casbin ``p`` row for one ``(role, permission, scope)`` grant. | ||
|
|
||
| The single place the internal namespacing is applied, so every producer | ||
| and consumer of a rendered row agrees on its exact shape. A drift | ||
| between two such places would silently stop a later comparison against | ||
| the stored policy from matching anything. | ||
| """ | ||
| return cls( | ||
| ptype=POLICY_PTYPE, | ||
| subject=f"{ROLE_NAMESPACE}{SEP}{role_id}", | ||
| action=f"{ACTION_NAMESPACE}{SEP}{permission_id}", | ||
| scope=f"{scope}{SEP}{SCOPE_WILDCARD}", | ||
| effect=EFFECT_ALLOW, | ||
| ) | ||
|
|
||
|
|
||
| @dataclass | ||
| class RenderedPolicy: | ||
| """The full set of ``p`` rows for a compiled schema (no DB access).""" | ||
|
|
||
| rows: list[PolicyRow] = field(default_factory=list) | ||
|
|
||
|
|
||
| class PolicyRenderer: | ||
| """Turns a :class:`CompiledSchema` into Casbin ``p`` rows in memory.""" | ||
|
|
||
| def render(self, schema: CompiledSchema) -> RenderedPolicy: | ||
| """Produce one ``p`` row per (role, permission, supported scope). | ||
|
|
||
| Emits definition (``p``) rows only — never ``g`` (assignments) or ``g2`` | ||
| (action inheritance). Applies the internal Casbin namespacing here. | ||
| Performs no database access. Output order is deterministic. | ||
| """ | ||
| rows: list[PolicyRow] = [] | ||
| for role_id in sorted(schema.roles): | ||
| role: RoleDefinition = schema.roles[role_id].definition | ||
| for scope in sorted(role.scopes): | ||
| for permission in sorted(role.permissions): | ||
| rows.append(PolicyRow.from_grant(role.id, permission, scope)) | ||
| return RenderedPolicy(rows=rows) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """Unit tests for the (pure) render step. | ||
|
|
||
| Covers turning a compiled schema into Casbin ``p`` rows: one row per | ||
| role-permission-scope, the ``role^``/``act^``/``^*`` namespacing convention, | ||
| deterministic output, and scope fan-out. | ||
| """ | ||
|
|
||
| from openedx_authz.engine.renderer import PolicyRenderer, PolicyRow | ||
| from openedx_authz.engine.schema.compilation import SchemaCompiler | ||
|
|
||
| from .factories import category, make_document, permission, role | ||
|
|
||
|
|
||
| class TestPolicyRow: | ||
| """Constructing and round-tripping a single Casbin ``p`` row.""" | ||
|
|
||
| def test_from_policy_round_trips_as_policy(self): | ||
| """A row rebuilt from ``as_policy`` output equals the original.""" | ||
| row = PolicyRow("p", "role^course_editor", "act^courses.view_course", "course-v1^*", "allow") | ||
| assert PolicyRow.from_policy(row.as_policy()) == row | ||
|
|
||
| def test_from_policy_reads_a_stored_row(self): | ||
| """A stored ``[subject, action, scope, effect]`` row maps to its fields.""" | ||
| row = PolicyRow.from_policy(["role^r", "act^p", "course-v1^*", "allow"]) | ||
| assert row.ptype == "p" | ||
| assert (row.subject, row.action, row.scope, row.effect) == ("role^r", "act^p", "course-v1^*", "allow") | ||
|
|
||
| def test_from_policy_pads_short_rows_with_empty_strings(self): | ||
| """A row with fewer than four values is padded rather than raising.""" | ||
| row = PolicyRow.from_policy(["role^r", "act^p"]) | ||
| assert (row.subject, row.action, row.scope, row.effect) == ("role^r", "act^p", "", "") | ||
|
|
||
|
|
||
| class TestPolicyRendering: | ||
| """Rendering a compiled schema into Casbin ``p`` policy rows.""" | ||
|
|
||
| @staticmethod | ||
| def _schema(): | ||
| """Build a compiled schema fixture for renderer tests.""" | ||
| doc = make_document( | ||
| categories=[category("cat")], | ||
| permissions=[ | ||
| permission(name="view_course", cat="cat", scopes=("course-v1",)), | ||
| permission(name="edit_course_content", cat="cat", scopes=("course-v1",)), | ||
| ], | ||
| roles=[ | ||
| role( | ||
| rid="course_editor", | ||
| scopes=("course-v1",), | ||
| permissions=("courses.view_course", "courses.edit_course_content"), | ||
| ) | ||
| ], | ||
| ) | ||
| return SchemaCompiler().compile([doc]) | ||
|
|
||
| def test_render_emits_one_p_row_per_role_permission_scope(self): | ||
| """Each role-permission-scope combination becomes one allow ``p`` row.""" | ||
| rendered = PolicyRenderer().render(self._schema()) | ||
| assert len(rendered.rows) == 2 | ||
| assert all(row.ptype == "p" and row.effect == "allow" for row in rendered.rows) | ||
|
|
||
| def test_render_applies_casbin_namespacing(self): | ||
| """Subjects, actions, and scopes carry their Casbin namespace prefixes.""" | ||
| rendered = PolicyRenderer().render(self._schema()) | ||
| row = next(r for r in rendered.rows if r.action == "act^courses.view_course") | ||
| assert row.subject == "role^course_editor" | ||
| assert row.scope == "course-v1^*" | ||
| assert row.as_policy() == ["role^course_editor", "act^courses.view_course", "course-v1^*", "allow"] | ||
|
|
||
| def test_render_is_deterministic(self): | ||
| """Rendering the same schema twice yields identical rows.""" | ||
| schema = self._schema() | ||
| assert PolicyRenderer().render(schema).rows == PolicyRenderer().render(schema).rows | ||
|
|
||
| def test_multiple_scopes_multiply_rows(self): | ||
| """A permission spanning multiple scopes fans out into one row per scope.""" | ||
| doc = make_document( | ||
| categories=[category("cat")], | ||
| permissions=[permission(name="view_course", cat="cat", scopes=("course-v1", "ccx-v1"))], | ||
| roles=[role(rid="r", scopes=("course-v1", "ccx-v1"), permissions=("courses.view_course",))], | ||
| ) | ||
| rendered = PolicyRenderer().render(SchemaCompiler().compile([doc])) | ||
| scopes = {row.scope for row in rendered.rows} | ||
| assert scopes == {"course-v1^*", "ccx-v1^*"} |
Uh oh!
There was an error while loading. Please reload this page.