Repository navigation
feat: clear errors for evaluator permission denials and add scope changes - #275
quinn-galileo wants to merge 7 commits into
Conversation
…nges
Add EvaluatorPermissionError (a ForbiddenError subclass) and translate 403s
from evaluator create, update, delete and scope changes into it, with a hint
that names the role needed. Reword client-side not-found errors to cover
evaluators that are not shared with the caller's projects, handle a 422 from
the scorer post on the LLM and code create paths, and add Evaluator.set_scope
over PUT /scorers/{scorer_id}/scope.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
I have read the CLA Document and I hereby sign the CLA You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot. |
quinn-galileo
left a comment
There was a problem hiding this comment.
🤖 This review was generated by the Astra agent (claude-opus-5-5). It may contain mistakes.
Verdict: request_changes — A 403 on the decision version post now gets an RBAC create hint. The scorer post has already succeeded at that point, so the hint misdiagnoses the documented feature-disabled 403.
Follow-ups
Suggested follow-up work that could be tracked as Jira tickets:
src/splunk_ao/evaluator.py:685-688:Evaluator.delete()deletes by name throughEvaluators.delete_evaluator(name=self.name), not byself.id. Under metric RBAC, several visible scorers can share a name. The loop can delete some of them and then raise a 403 on a sibling the caller cannot edit. Deleteself.iddirectly throughdelete_scorer_scorers_scorer_id_deleteinstead.src/splunk_ao/evaluators.py:403-406:create_custom_llm_evaluatordoes not check the version-post response or clean up when it fails. A 422 or an exception there leaves an orphan scorer with no version. The decision path already handles this with_was_rejectedand_delete_scorer_quietly. Reuse that pattern for the LLM and code create paths.
| except Exception as error: | ||
| if self._was_rejected(error): | ||
| self._delete_scorer_quietly(scorer.id) | ||
| raise | ||
| with _translate_forbidden(create_hint): | ||
| raise |
There was a problem hiding this comment.
🟠 major (bug): This translates a 403 from the decision version post into EvaluatorPermissionError with the create-scope hint. That hint is wrong on this path:
- The scorer post at line 510 already succeeded with the same
is_global/project_ids. So the caller already has the create permission for that scope. - The docstring at line 482 says a 403 here means the decision feature is not enabled.
- The user sees "Only org admins can create global evaluators. Pass project_ids=[...]" or "needs the owner or editor role on every project". If they follow that advice, they get the same 403 again.
The LLM path (line 404) and the code path (evaluator.py:1540) do not translate version-post 403s. That makes this the inconsistent branch.
Options:
- Re-raise the raw error, as before. This is the simplest fix and keeps the docstring true.
- If a version-post 403 must be translated, use its own hint. For example: "The evaluator was created but its decision version was refused. Check that decision evaluators are enabled for your organization."
Update test_version_post_403_is_translated_and_the_scorer_is_still_deleted (tests/test_evaluator_permissions.py:205) to match the option you choose.
| except Exception as error: | |
| if self._was_rejected(error): | |
| self._delete_scorer_quietly(scorer.id) | |
| raise | |
| with _translate_forbidden(create_hint): | |
| raise | |
| if self._was_rejected(error): | |
| self._delete_scorer_quietly(scorer.id) | |
| raise |
🤖 Generated by the Astra agent
There was a problem hiding this comment.
Fixed in f310b37: the version-post 403 is still translated (it must be a clear SDK error) but now uses its own hint about the refused decision version, and the scorer is still deleted. Docstrings updated.
| def _create_denied_hint(scope_is_global: bool | Unset) -> str: | ||
| """Hint for a 403 on create, chosen from the scope that was actually sent.""" | ||
| return _SCOPED_CREATE_DENIED_HINT if scope_is_global is False else _GLOBAL_CREATE_DENIED_HINT |
There was a problem hiding this comment.
🟡 minor (bug): An explicit is_global=True gets the same hint as an unset scope. Part of that hint says "or set SPLUNK_AO_PROJECT / SPLUNK_AO_PROJECT_ID so it defaults to one." That advice cannot work here. _resolve_create_scope returns True, UNSET for is_global=True before it reads the environment (line 235). The env var is ignored, and the user hits the same 403. The test at tests/test_evaluator_permissions.py:99-100 asserts this wrong advice for the explicit_global case. Use a separate hint for True that says only "drop is_global=True and pass project_ids=[...]". Keep the env var advice for UNSET.
| def _create_denied_hint(scope_is_global: bool | Unset) -> str: | |
| """Hint for a 403 on create, chosen from the scope that was actually sent.""" | |
| return _SCOPED_CREATE_DENIED_HINT if scope_is_global is False else _GLOBAL_CREATE_DENIED_HINT | |
| def _create_denied_hint(scope_is_global: bool | Unset) -> str: | |
| """Hint for a 403 on create, chosen from the scope that was actually sent.""" | |
| if scope_is_global is False: | |
| return _SCOPED_CREATE_DENIED_HINT | |
| if scope_is_global is True: | |
| return _EXPLICIT_GLOBAL_CREATE_DENIED_HINT | |
| return _GLOBAL_CREATE_DENIED_HINT |
🤖 Generated by the Astra agent
There was a problem hiding this comment.
Declined: the task's acceptance criteria require the same hint for unset and explicit-global scope, naming both project_ids and the SPLUNK_AO_PROJECT variables. Flagged to the tech lead as a possible follow-up.
|
|
||
| New evaluators are scoped to the current project (`SPLUNK_AO_PROJECT_ID` or `SPLUNK_AO_PROJECT`) by default. Pass | ||
| `project_ids` to choose other projects, or `is_global=True` for a global evaluator. Only org admins can create global | ||
| evaluators, and changing or deleting an evaluator needs the owner or editor role on one of its projects. |
There was a problem hiding this comment.
🔵 nit (documentation): The example right below calls set_scope. But this sentence says "changing" needs the role on one of the evaluator's projects. The PR's own hint says a scope change needs the role on each project added or removed, and org admin to make it global (evaluators.py:106-109). Say "updating or deleting" here, and state the scope-change rule separately.
🤖 Generated by the Astra agent
There was a problem hiding this comment.
Fixed in f310b37: README now separates update/delete from scope changes.
There was a problem hiding this comment.
src/splunk_ao/evaluators.py:482-482 (line not in diff)
🟡 minor (documentation): This docstring now contradicts the code. A 403 from the version post no longer surfaces as a plain SplunkAOAPIError. It surfaces as EvaluatorPermissionError with an RBAC hint (lines 527-528). The same text is in the module-level create_decision_evaluator at line 754. If you keep the translation, reword both docstrings. If you drop it, as the comment on line 528 suggests, no change is needed.
🤖 Generated by the Astra agent
There was a problem hiding this comment.
Fixed in f310b37: both docstrings reworded.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| _DECISION_VERSION_DENIED_HINT = ( | ||
| "The server refused the decision version request, so the new evaluator was deleted. " | ||
| "Check that decision evaluators are enabled for your organization." | ||
| ) |
There was a problem hiding this comment.
🟡 minor (bug): The hint says "so the new evaluator was deleted" without condition. But _delete_scorer_quietly is best-effort (evaluators.py:555-569). If the cleanup delete raises, or returns HTTPValidationError or None, it only logs a warning and returns. In that case the scorer still exists, and the message tells the user it is gone. The user then sees a leftover evaluator with the same name and no version.
Options:
- Reword the hint so it does not assert the outcome. For example: "The server refused the decision version request. The SDK tried to delete the new evaluator."
- Make
_delete_scorer_quietlyreturn abool. Then pick the hint text from that result, and name the scorer id when the cleanup failed.
| _DECISION_VERSION_DENIED_HINT = ( | |
| "The server refused the decision version request, so the new evaluator was deleted. " | |
| "Check that decision evaluators are enabled for your organization." | |
| ) | |
| _DECISION_VERSION_DENIED_HINT = ( | |
| "The server refused the decision version request, and the SDK tried to delete the new evaluator. " | |
| "Check that decision evaluators are enabled for your organization." | |
| ) |
🤖 Generated by the Astra agent
There was a problem hiding this comment.
Fixed: reworded so it does not assert the cleanup outcome.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
quinn-galileo
left a comment
There was a problem hiding this comment.
🤖 This review was generated by the Astra agent (claude-opus-5-5). It may contain mistakes.
Verdict: approve — b1940be and e233c2b fix every earlier finding in code. What remains is minor: one stale docstring and a PR description that no longer matches the messages.
General Comments
-
🟡 minor (documentation): The PR description quotes messages that the code no longer produces after e233c2b:
-
It gives one hint for "Create, unset or global scope". Explicit
is_global=Truenow gets_EXPLICIT_GLOBAL_CREATE_DENIED_HINT(evaluators.py:106-109), which does not mentionSPLUNK_AO_PROJECT. -
It quotes "You can't update this evaluator...".
_edit_denied_hintnow returns "Updating an evaluator needs the owner or editor role on one of its projects..." (evaluators.py:126-130). -
The quoted
set_scopehint leaves out the new "one of its current projects" condition (evaluators.py:117-121). -
The description does not mention
_DECISION_ENABLED_HINT, which is appended to decision scorer-post 403s (evaluators.py:523).
Update the description so reviewers check the real messages.
| EvaluatorPermissionError: If the API answers 403: org admins only for a global evaluator, owner or | ||
| editor role on the projects for a scoped one. |
There was a problem hiding this comment.
🟡 minor (documentation): This docstring for DecisionEvaluator.create describes a 403 only as a create-scope problem. The code can also raise EvaluatorPermissionError in two other ways:
- After the version post is refused. That error uses
_DECISION_VERSION_DENIED_HINT, and the SDK tries to delete the scorer (evaluators.py:538-543). - From the scorer post with the feature-gate note appended (
evaluators.py:523).
The Evaluators.create_decision_evaluator docstrings already state the 403 path, after f310b37. Align this one with them.
| EvaluatorPermissionError: If the API answers 403: org admins only for a global evaluator, owner or | |
| editor role on the projects for a scoped one. | |
| EvaluatorPermissionError: If the API answers 403: org admins only for a global evaluator, owner or | |
| editor role on the projects for a scoped one. Decision evaluators may also not be enabled for the | |
| organization. A 403 on the version request deletes the new scorer before the error is raised. |
🤖 Generated by the Astra agent
There was a problem hiding this comment.
Fixed: docstring now covers the feature-gate note and the version-request 403.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Why
With metric RBAC on, the server returns 403 when a non-admin creates a global evaluator, or edits, deletes or re-scopes an evaluator without the owner or editor role on one of its projects. The SDK showed the generic "Your API key doesn't have access" message. This PR says what is needed and how to fix it, rewords client-side "not found" errors, and adds
Evaluator.set_scope(). Ticket: https://splunk.atlassian.net/browse/SAO-9342What changed
EvaluatorPermissionError(subclass ofForbiddenError, so existing handlers still work, exported fromsplunk_ao) replaces the generic 403 on evaluator operations. The server response body stays in the message. Example messages:is_global=True: "Only org admins can create global evaluators. Drop is_global=True and pass project_ids=[...] to scope the evaluator to your projects." (The env vars would not help here, so they are not mentioned.)set_scope: "Changing an evaluator's scope needs the owner or editor role on one of its current projects and on each project added or removed. Making it global, or changing the scope of a global evaluator, needs the org admin role."Other changes:
_resolve_create_scope, never from instance attributes. On the decision path, a 403 from the scorer post or the version post is translated, and a rejected version still deletes the new scorer.Evaluators.delete_evaluatorandEvaluator.refreshsay "not found, or not shared with your projects".Evaluator.getstill returnsNone; its docstring says so.Evaluator.set_scope(project_ids=[...] | is_global=True)callsPUT /scorers/{scorer_id}/scope(full replace), validates before any request, and does not touch the create-time scope.ValueError(detail)on a 422 from the scorer post, as the decision path already did.A 404 from update, delete or
set_scopestaysNotFoundError. The server returns 404 (not 403) for evaluators outside the caller's projects on some paths, so no message promises a 403.Where to start reading
src/splunk_ao/evaluators.py(hint helpers and_translate_forbidden), thenEvaluator.set_scopeandEvaluator.updateinsrc/splunk_ao/evaluator.py, thentests/test_evaluator_permissions.py.Testing
poetry run pytest tests/test_evaluator_permissions.py tests/test_evaluators.py tests/test_evaluator.py tests/test_decision_scorers.py -n 0(274 passed) andpoetry run invoke type-check(clean). All network is mocked.Related PRs
set_scope🤖 Generated with Claude Code