Skip to content

Commit 648e81c

Browse files
fix(schedules): grant legacy auth for run schedules (#344)
## Summary - Preserve Spark-style schedule resource registration while also granting the schedule directly for legacy SGP auth. - Ensure schedule create rollback and delete cleanup revoke legacy permissions as well as deregister Spark resources. - Cover create, rollback, grant-failure compensation, no-creator, and delete cleanup behavior in focused unit tests. ## Context Legacy `agentex-auth` currently runs with `AUTH_PROVIDER=sgp`, where `register_resource` is a no-op. Run schedules were only writing Spark-style registration, so schedule rows and Temporal clocks could exist while legacy `schedule.read` search/check returned no permission, causing list endpoints to return `200 []`. This bridges current legacy SGP auth while keeping `register_resource` / `deregister_resource` in place for the eventual Spark migration. ## Test plan - `uv run --group test pytest tests/unit/services/test_agent_run_schedule_service.py` - `uv run ruff check src/domain/services/agent_run_schedule_service.py tests/unit/services/test_agent_run_schedule_service.py` ## Notes Existing schedules created before this change may need a one-time legacy grant backfill or recreation to become visible through schedule list/get under legacy SGP auth. Made with [Cursor](https://cursor.com) <!-- greptile_comment --> <h3>Greptile Summary</h3> This PR bridges the gap where `register_resource` is a no-op under the legacy SGP auth provider (`AUTH_PROVIDER=sgp`), causing `schedule.read` to return no permission and list endpoints to return `200 []`. It adds a `grant` call after `register_resource` in the creation path and a `revoke` call before `deregister_resource` in the cleanup path, keeping both Spark-style and SGP-style auth in sync. - `_register_schedule_in_auth` now calls `grant` after `register_resource`, with a compensation block that calls `deregister_resource` on grant failure before re-raising; the `registered` flag in `create_schedule` remains `False` on any failure so the outer rollback doesn't double-deregister. - `_deregister_schedule_from_auth` now prepends a best-effort `revoke` call (in its own try/except) before the existing `deregister_resource`, so both auth paths are cleaned up on delete or rollback. - Tests cover the happy path, grant-failure compensation, registration-failure abort, Temporal-failure rollback, no-creator skip, and delete cleanup — with precise `assert_called_once_with` assertions on the new `grant` and `revoke` calls. <details><summary><h3>Confidence Score: 5/5</h3></summary> Safe to merge — the dual-auth bridge is correctly wired, the `registered` flag prevents double-cleanup, and the new test cases verify all compensation paths. The rollback semantics are sound: `registered` stays `False` if `_register_schedule_in_auth` raises (whether from `register_resource` or `grant`), so the outer cleanup never calls `_deregister_schedule_from_auth` when the inner compensation already handled it. The grant-failure path correctly skips `revoke` (nothing was granted). The delete path calls `revoke` then `deregister_resource` in independent try/except blocks, so a failure in one doesn't prevent the other. Tests are precise and cover the newly introduced failure modes. No files require special attention. </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | agentex/src/domain/services/agent_run_schedule_service.py | Adds legacy SGP grant/revoke calls alongside the existing Spark register/deregister calls; compensation logic and rollback flag semantics are correct — grant failure triggers deregister (not revoke), and the `registered` flag stays False so the outer rollback never double-cleans. | | agentex/tests/unit/services/test_agent_run_schedule_service.py | New tests cover grant-failure compensation, no-creator skip, and upgraded assertions on existing rollback/delete tests to use `assert_called_once_with` for both `grant` and `revoke`; all scenarios are consistent with implementation behavior. | </details> <details><summary><h3>Sequence Diagram</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% sequenceDiagram participant CS as create_schedule participant RA as _register_schedule_in_auth participant AS as authorization_service participant TA as temporal_adapter participant DR as _deregister_schedule_from_auth CS->>RA: _register_schedule_in_auth(authz_selector, agent_id) RA->>AS: "register_resource(schedule_resource, parent=agent_resource)" alt register_resource fails AS-->>RA: raises Exception RA-->>CS: "raises (registered=False)" CS->>CS: _best_effort_delete_row() else register_resource succeeds RA->>AS: grant(schedule_resource) alt grant fails AS-->>RA: raises Exception RA->>AS: deregister_resource(schedule_resource) [compensation] RA-->>CS: "raises (registered=False)" CS->>CS: _best_effort_delete_row() else grant succeeds RA-->>CS: "returns True (registered=True)" CS->>TA: create_schedule(...) alt Temporal fails TA-->>CS: raises Exception CS->>DR: _deregister_schedule_from_auth(authz_selector) DR->>AS: revoke(schedule_resource) DR->>AS: deregister_resource(schedule_resource) CS->>CS: _best_effort_delete_row() else Temporal succeeds TA-->>CS: success CS-->>CS: return response end end end ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant CS as create_schedule participant RA as _register_schedule_in_auth participant AS as authorization_service participant TA as temporal_adapter participant DR as _deregister_schedule_from_auth CS->>RA: _register_schedule_in_auth(authz_selector, agent_id) RA->>AS: "register_resource(schedule_resource, parent=agent_resource)" alt register_resource fails AS-->>RA: raises Exception RA-->>CS: "raises (registered=False)" CS->>CS: _best_effort_delete_row() else register_resource succeeds RA->>AS: grant(schedule_resource) alt grant fails AS-->>RA: raises Exception RA->>AS: deregister_resource(schedule_resource) [compensation] RA-->>CS: "raises (registered=False)" CS->>CS: _best_effort_delete_row() else grant succeeds RA-->>CS: "returns True (registered=True)" CS->>TA: create_schedule(...) alt Temporal fails TA-->>CS: raises Exception CS->>DR: _deregister_schedule_from_auth(authz_selector) DR->>AS: revoke(schedule_resource) DR->>AS: deregister_resource(schedule_resource) CS->>CS: _best_effort_delete_row() else Temporal succeeds TA-->>CS: success CS-->>CS: return response end end end ``` </a> </details> <sub>Reviews (2): Last reviewed commit: ["test(schedules): tighten legacy auth cle..."](7202557) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=41188765)</sub> <!-- /greptile_comment --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4c1740b commit 648e81c

2 files changed

Lines changed: 98 additions & 5 deletions

File tree

agentex/src/domain/services/agent_run_schedule_service.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,16 +502,57 @@ async def _register_schedule_in_auth(
502502
extra={"authz_selector": authz_selector, "agent_id": agent_id},
503503
)
504504
return False
505+
schedule_resource = AgentexResource.schedule(authz_selector)
505506
await self.authorization_service.register_resource(
506-
resource=AgentexResource.schedule(authz_selector),
507+
resource=schedule_resource,
507508
parent=AgentexResource.agent(agent_id),
508509
)
510+
try:
511+
# Legacy SGP auth treats register_resource as a no-op. Keep the
512+
# Spark registration above for the future path, and write the legacy
513+
# grant so current list/check calls can see the schedule.
514+
await self.authorization_service.grant(schedule_resource)
515+
except Exception as grant_exc:
516+
logger.warning(
517+
"Auth grant failed for run schedule; compensating with deregister",
518+
extra={
519+
"authz_selector": authz_selector,
520+
"error_type": type(grant_exc).__name__,
521+
},
522+
exc_info=True,
523+
)
524+
try:
525+
await self.authorization_service.deregister_resource(
526+
resource=schedule_resource,
527+
)
528+
except Exception as cleanup_exc:
529+
logger.warning(
530+
"Auth deregister failed after run schedule grant failure",
531+
extra={
532+
"authz_selector": authz_selector,
533+
"error_type": type(cleanup_exc).__name__,
534+
},
535+
exc_info=True,
536+
)
537+
raise
509538
return True
510539

511540
async def _deregister_schedule_from_auth(self, *, authz_selector: str) -> None:
541+
schedule_resource = AgentexResource.schedule(authz_selector)
542+
try:
543+
await self.authorization_service.revoke(resource=schedule_resource)
544+
except Exception as exc:
545+
logger.warning(
546+
"Auth revoke failed for run schedule; entry may be orphaned",
547+
extra={
548+
"authz_selector": authz_selector,
549+
"error_type": type(exc).__name__,
550+
},
551+
exc_info=True,
552+
)
512553
try:
513554
await self.authorization_service.deregister_resource(
514-
resource=AgentexResource.schedule(authz_selector),
555+
resource=schedule_resource
515556
)
516557
except Exception as exc:
517558
logger.warning(

agentex/tests/unit/services/test_agent_run_schedule_service.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ScheduleInitialInput,
1212
UpdateAgentRunScheduleRequest,
1313
)
14+
from src.api.schemas.authorization_types import AgentexResource
1415
from src.domain.entities.agent_run_schedules import AgentRunScheduleEntity
1516
from src.domain.entities.agents import ACPType, AgentEntity, AgentStatus
1617
from src.domain.exceptions import ClientError
@@ -112,8 +113,16 @@ async def test_create_persists_and_schedules(self, service, agent):
112113
)
113114
assert create_kwargs["time_zone_name"] == "America/New_York"
114115

115-
# Ownership registered before the Temporal write.
116-
service.authorization_service.register_resource.assert_called_once()
116+
# Ownership is written to both auth paths before the Temporal write:
117+
# Spark resource registration for the future path, and a legacy grant
118+
# for current SGP list/check behavior.
119+
authz_selector = build_run_schedule_authz_selector(agent.id, persisted.name)
120+
schedule_resource = AgentexResource.schedule(authz_selector)
121+
service.authorization_service.register_resource.assert_called_once_with(
122+
resource=schedule_resource,
123+
parent=AgentexResource.agent(agent.id),
124+
)
125+
service.authorization_service.grant.assert_called_once_with(schedule_resource)
117126

118127
assert response.name == "daily-summary"
119128
assert response.initial_input_method == "event/send" # async agent
@@ -141,8 +150,13 @@ async def test_create_rolls_back_row_on_temporal_failure(self, service, agent):
141150
with pytest.raises(RuntimeError):
142151
await service.create_schedule(agent, request, {"user_id": "u1"})
143152

144-
# The orphaned row and auth entry are compensated.
153+
# The orphaned row and both auth entries are compensated.
154+
authz_selector = build_run_schedule_authz_selector(agent.id, persisted.name)
155+
schedule_resource = AgentexResource.schedule(authz_selector)
145156
service.schedule_repository.delete.assert_called_once_with(id=persisted.id)
157+
service.authorization_service.revoke.assert_called_once_with(
158+
resource=schedule_resource
159+
)
146160
service.authorization_service.deregister_resource.assert_called_once()
147161

148162
async def test_create_rolls_back_row_on_auth_registration_failure(
@@ -163,6 +177,39 @@ async def test_create_rolls_back_row_on_auth_registration_failure(
163177
# must not create a Temporal schedule.
164178
service.schedule_repository.delete.assert_called_once_with(id=persisted.id)
165179
service.temporal_adapter.create_schedule.assert_not_called()
180+
service.authorization_service.grant.assert_not_called()
181+
182+
async def test_create_compensates_register_when_grant_fails(self, service, agent):
183+
request = _request()
184+
persisted = _persisted(agent.id, request)
185+
service.schedule_repository.get_by_agent_id_and_name.return_value = None
186+
service.schedule_repository.create.return_value = persisted
187+
service.authorization_service.grant.side_effect = RuntimeError("authz down")
188+
189+
with pytest.raises(RuntimeError):
190+
await service.create_schedule(agent, request, {"user_id": "u1"})
191+
192+
# The Spark registration is compensated, the row is removed, and the
193+
# Temporal clock is never created if the legacy grant cannot be written.
194+
service.authorization_service.register_resource.assert_called_once()
195+
service.authorization_service.deregister_resource.assert_called_once()
196+
service.authorization_service.revoke.assert_not_called()
197+
service.schedule_repository.delete.assert_called_once_with(id=persisted.id)
198+
service.temporal_adapter.create_schedule.assert_not_called()
199+
200+
async def test_create_skips_auth_when_no_creator(self, service, agent):
201+
type(service.authorization_service).principal_context = PropertyMock(
202+
return_value={}
203+
)
204+
request = _request()
205+
persisted = _persisted(agent.id, request)
206+
service.schedule_repository.get_by_agent_id_and_name.return_value = None
207+
service.schedule_repository.create.return_value = persisted
208+
209+
await service.create_schedule(agent, request, {})
210+
211+
service.authorization_service.register_resource.assert_not_called()
212+
service.authorization_service.grant.assert_not_called()
166213

167214

168215
@pytest.mark.unit
@@ -255,6 +302,11 @@ async def test_delete_tolerates_missing_temporal_schedule(self, service, agent):
255302
service.schedule_repository.update.assert_called_once()
256303
tombstoned = service.schedule_repository.update.call_args.args[0]
257304
assert tombstoned.deleted_at is not None
305+
authz_selector = build_run_schedule_authz_selector(agent.id, row.name)
306+
schedule_resource = AgentexResource.schedule(authz_selector)
307+
service.authorization_service.revoke.assert_called_once_with(
308+
resource=schedule_resource
309+
)
258310
service.authorization_service.deregister_resource.assert_called_once()
259311

260312

0 commit comments

Comments
 (0)