scriptgini 1.5.2__tar.gz → 1.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {scriptgini-1.5.2 → scriptgini-1.5.4}/PKG-INFO +2 -2
  2. {scriptgini-1.5.2 → scriptgini-1.5.4}/README.md +1 -1
  3. scriptgini-1.5.4/app/__init__.py +3 -0
  4. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/analytics.py +14 -1
  5. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/api_key.py +2 -0
  6. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/bulk_jobs.py +13 -1
  7. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/projects.py +38 -16
  8. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/scripts.py +62 -6
  9. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/test_cases.py +39 -5
  10. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/services/auth_dependencies.py +63 -19
  11. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/services/rbac.py +37 -2
  12. {scriptgini-1.5.2 → scriptgini-1.5.4}/pyproject.toml +2 -1
  13. {scriptgini-1.5.2 → scriptgini-1.5.4}/scriptgini.egg-info/PKG-INFO +2 -2
  14. {scriptgini-1.5.2 → scriptgini-1.5.4}/tests/test_auth.py +55 -0
  15. {scriptgini-1.5.2 → scriptgini-1.5.4}/tests/test_infra_services_coverage.py +26 -0
  16. {scriptgini-1.5.2 → scriptgini-1.5.4}/tests/test_sprint2_rbac.py +87 -1
  17. {scriptgini-1.5.2 → scriptgini-1.5.4}/tests/test_sprint3_execution.py +2 -3
  18. {scriptgini-1.5.2 → scriptgini-1.5.4}/tests/test_sprint5_reporting_analytics.py +28 -21
  19. {scriptgini-1.5.2 → scriptgini-1.5.4}/tests/test_sprint6_coverage_lifecycle.py +0 -5
  20. scriptgini-1.5.2/app/__init__.py +0 -3
  21. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/agents/__init__.py +0 -0
  22. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/agents/prompts.py +0 -0
  23. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/agents/script_gini_agent.py +0 -0
  24. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/cache.py +0 -0
  25. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/celery_app.py +0 -0
  26. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/config.py +0 -0
  27. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/database.py +0 -0
  28. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/llm/__init__.py +0 -0
  29. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/llm/provider.py +0 -0
  30. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/main.py +0 -0
  31. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/__init__.py +0 -0
  32. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/api_key.py +0 -0
  33. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/bulk_job.py +0 -0
  34. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/execution_job.py +0 -0
  35. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/generated_script.py +0 -0
  36. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/membership.py +0 -0
  37. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/organization.py +0 -0
  38. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/project.py +0 -0
  39. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/script_revision.py +0 -0
  40. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/script_run.py +0 -0
  41. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/test_case.py +0 -0
  42. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/models/user.py +0 -0
  43. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/__init__.py +0 -0
  44. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/auth.py +0 -0
  45. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/demo.py +0 -0
  46. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/execution.py +0 -0
  47. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/organizations.py +0 -0
  48. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/routers/reports.py +0 -0
  49. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/__init__.py +0 -0
  50. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/analytics.py +0 -0
  51. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/api_key.py +0 -0
  52. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/auth.py +0 -0
  53. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/bulk_job.py +0 -0
  54. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/execution.py +0 -0
  55. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/generated_script.py +0 -0
  56. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/membership.py +0 -0
  57. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/organization.py +0 -0
  58. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/project.py +0 -0
  59. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/reports.py +0 -0
  60. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/script_revision.py +0 -0
  61. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/schemas/test_case.py +0 -0
  62. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/services/api_key.py +0 -0
  63. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/services/auth.py +0 -0
  64. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/services/git_export.py +0 -0
  65. {scriptgini-1.5.2 → scriptgini-1.5.4}/app/tasks.py +0 -0
  66. {scriptgini-1.5.2 → scriptgini-1.5.4}/scriptgini.egg-info/SOURCES.txt +0 -0
  67. {scriptgini-1.5.2 → scriptgini-1.5.4}/scriptgini.egg-info/dependency_links.txt +0 -0
  68. {scriptgini-1.5.2 → scriptgini-1.5.4}/scriptgini.egg-info/top_level.txt +0 -0
  69. {scriptgini-1.5.2 → scriptgini-1.5.4}/setup.cfg +0 -0
  70. {scriptgini-1.5.2 → scriptgini-1.5.4}/tests/test_api.py +0 -0
  71. {scriptgini-1.5.2 → scriptgini-1.5.4}/tests/test_coverage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptgini
3
- Version: 1.5.2
3
+ Version: 1.5.4
4
4
  Summary: Agentic AI system that converts functional test cases into automation test scripts.
5
5
  Author: ScriptGini Team
6
6
  License: Proprietary
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
16
16
 
17
17
  > **Enterprise-grade Agentic AI system that converts functional test cases into high-quality, review-ready automation test scripts.**
18
18
 
19
- Current release: v1.5.2 (Sprint 6 hardening + API key audit/revocation patch)
19
+ Current release: v1.5.4 (Sprint 6 patch - RBAC enforcement hardening with 100% passing tests and 100% statement coverage)
20
20
 
21
21
  ---
22
22
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Enterprise-grade Agentic AI system that converts functional test cases into high-quality, review-ready automation test scripts.**
4
4
 
5
- Current release: v1.5.2 (Sprint 6 hardening + API key audit/revocation patch)
5
+ Current release: v1.5.4 (Sprint 6 patch - RBAC enforcement hardening with 100% passing tests and 100% statement coverage)
6
6
 
7
7
  ---
8
8
 
@@ -0,0 +1,3 @@
1
+ __version__ = "1.5.4"
2
+ __api_version__ = "v1.5.4"
3
+
@@ -20,16 +20,23 @@ from app.schemas.analytics import (
20
20
  TrendPointResponse,
21
21
  TrendsResponse,
22
22
  )
23
+ from app.services import rbac as rbac_service
24
+ from app.services.auth_dependencies import require_optional_auth_with_scopes
23
25
 
24
26
  router = APIRouter(prefix="/projects/{project_id}/analytics", tags=["Run Analytics"])
25
27
  insights_router = APIRouter(prefix="/analytics", tags=["Run Analytics"])
26
28
 
27
29
 
28
30
  @router.get("/runs", response_model=RunAnalyticsResponse)
29
- def get_run_analytics(project_id: int, db: Session = Depends(get_db)):
31
+ def get_run_analytics(
32
+ project_id: int,
33
+ current_user=Depends(require_optional_auth_with_scopes({"analytics:read"})),
34
+ db: Session = Depends(get_db),
35
+ ):
30
36
  project = db.query(Project).filter(Project.id == project_id).first()
31
37
  if not project:
32
38
  raise HTTPException(status_code=404, detail="Project not found")
39
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
33
40
 
34
41
  aggregate = (
35
42
  db.query(
@@ -98,12 +105,14 @@ def get_trends(
98
105
  project_id: int | None = None,
99
106
  start_date: date | None = None,
100
107
  end_date: date | None = None,
108
+ current_user=Depends(require_optional_auth_with_scopes({"analytics:read"})),
101
109
  db: Session = Depends(get_db),
102
110
  ):
103
111
  if project_id is not None:
104
112
  project = db.query(Project).filter(Project.id == project_id).first()
105
113
  if not project:
106
114
  raise HTTPException(status_code=404, detail="Project not found")
115
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
107
116
 
108
117
  start_dt, end_dt = _resolve_datetime_range(start_date, end_date)
109
118
  bucket_expr = func.date(ScriptRun.created_at)
@@ -148,12 +157,14 @@ def get_flakiness(
148
157
  start_date: date | None = None,
149
158
  end_date: date | None = None,
150
159
  min_runs: int = 3,
160
+ current_user=Depends(require_optional_auth_with_scopes({"analytics:read"})),
151
161
  db: Session = Depends(get_db),
152
162
  ):
153
163
  if project_id is not None:
154
164
  project = db.query(Project).filter(Project.id == project_id).first()
155
165
  if not project:
156
166
  raise HTTPException(status_code=404, detail="Project not found")
167
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
157
168
 
158
169
  min_runs = max(1, min_runs)
159
170
  start_dt, end_dt = _resolve_datetime_range(start_date, end_date)
@@ -205,6 +216,7 @@ def get_flakiness(
205
216
  @insights_router.get("/coverage", response_model=CoverageResponse)
206
217
  def get_coverage(
207
218
  project_id: int | None = None,
219
+ current_user=Depends(require_optional_auth_with_scopes({"analytics:read"})),
208
220
  db: Session = Depends(get_db),
209
221
  ):
210
222
  """Map test cases to scripted/executed/passed state, grouped by project module (derived from test case title prefix)."""
@@ -212,6 +224,7 @@ def get_coverage(
212
224
  project = db.query(Project).filter(Project.id == project_id).first()
213
225
  if not project:
214
226
  raise HTTPException(status_code=404, detail="Project not found")
227
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
215
228
 
216
229
  tc_query = db.query(TestCase)
217
230
  if project_id is not None:
@@ -118,6 +118,7 @@ def update_api_key(
118
118
 
119
119
  # Check that the key belongs to the current user
120
120
  if existing_key.user_id != current_user.id:
121
+ _audit_api_key_event("api_key_revoked", current_user.id, existing_key.id, existing_key.prefix, "forbidden")
121
122
  raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to update this API key")
122
123
 
123
124
  api_key = api_key_service.update_api_key(
@@ -141,6 +142,7 @@ def delete_api_key(
141
142
  existing_key = api_key_service.get_api_key_by_id(db, key_id)
142
143
 
143
144
  if not existing_key:
145
+ _audit_api_key_event("api_key_revoked", current_user.id, key_id, None, "not_found")
144
146
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
145
147
 
146
148
  # Check that the key belongs to the current user
@@ -19,6 +19,8 @@ from app.routers.scripts import (
19
19
  _get_project_or_404,
20
20
  _run_python_script,
21
21
  )
22
+ from app.services import rbac as rbac_service
23
+ from app.services.auth_dependencies import require_optional_auth_with_scopes
22
24
 
23
25
  router = APIRouter(prefix="/projects/{project_id}/scripts", tags=["Bulk Jobs"])
24
26
  logger = logging.getLogger(__name__)
@@ -219,9 +221,11 @@ def _run_bulk_execution(job_id: int, request: BulkRunRequest):
219
221
  def bulk_generate_scripts(
220
222
  project_id: int,
221
223
  payload: BulkGenerateRequest,
224
+ current_user=Depends(require_optional_auth_with_scopes({"bulk-jobs:write"})),
222
225
  db: Session = Depends(get_db),
223
226
  ):
224
227
  _get_project_or_404(project_id, db)
228
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
225
229
  test_cases = _resolve_test_cases(project_id, payload.test_case_ids, db)
226
230
  if not test_cases:
227
231
  raise HTTPException(status_code=400, detail="No test cases found for bulk generation")
@@ -253,9 +257,11 @@ def bulk_generate_scripts(
253
257
  def bulk_run_scripts(
254
258
  project_id: int,
255
259
  payload: BulkRunRequest,
260
+ current_user=Depends(require_optional_auth_with_scopes({"bulk-jobs:write"})),
256
261
  db: Session = Depends(get_db),
257
262
  ):
258
263
  _get_project_or_404(project_id, db)
264
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
259
265
  test_cases = _resolve_test_cases(project_id, payload.test_case_ids, db)
260
266
  if not test_cases:
261
267
  raise HTTPException(status_code=400, detail="No test cases found for bulk run")
@@ -284,7 +290,13 @@ def bulk_run_scripts(
284
290
 
285
291
 
286
292
  @router.get("/bulk-jobs/{job_id}", response_model=BulkJobResponse)
287
- def get_bulk_job(project_id: int, job_id: int, db: Session = Depends(get_db)):
293
+ def get_bulk_job(
294
+ project_id: int,
295
+ job_id: int,
296
+ current_user=Depends(require_optional_auth_with_scopes({"bulk-jobs:read"})),
297
+ db: Session = Depends(get_db),
298
+ ):
288
299
  _get_project_or_404(project_id, db)
300
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
289
301
  job = _get_bulk_job_or_404(project_id, job_id, db)
290
302
  return _serialize_bulk_job(job, db)
@@ -6,27 +6,36 @@ from app.models.project import Project
6
6
  from app.schemas.membership import ProjectMemberResponse, ProjectMemberUpsertRequest
7
7
  from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
8
8
  from app.services import rbac as rbac_service
9
- from app.services.auth_dependencies import require_auth_with_scopes
9
+ from app.services.auth_dependencies import require_auth_with_scopes, require_optional_auth_with_scopes
10
10
 
11
11
  router = APIRouter(prefix="/projects", tags=["Projects"])
12
12
 
13
13
 
14
14
  def _ensure_project_manager(db: Session, project_id: int, user_id: int) -> None:
15
- existing_members = rbac_service.list_project_members(db, project_id)
16
- if not existing_members:
17
- return
18
-
19
- membership = rbac_service.get_user_project_membership(db, project_id, user_id)
20
- if membership is None or membership.role not in rbac_service.MANAGER_ROLES:
21
- raise HTTPException(status_code=403, detail="Insufficient project role")
15
+ rbac_service.ensure_project_manager_access(db, project_id, user_id)
22
16
 
23
17
 
24
18
  @router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
25
- def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
19
+ def create_project(
20
+ payload: ProjectCreate,
21
+ current_user=Depends(require_optional_auth_with_scopes({"projects:write"})),
22
+ db: Session = Depends(get_db),
23
+ ):
26
24
  project = Project(**payload.model_dump())
27
25
  db.add(project)
28
26
  db.commit()
29
27
  db.refresh(project)
28
+
29
+ # Auto-bootstrap project owner membership for authenticated creators.
30
+ if current_user is not None:
31
+ rbac_service.upsert_project_member(
32
+ db=db,
33
+ project_id=project.id,
34
+ user_id=current_user.id,
35
+ role=rbac_service.Role.owner,
36
+ is_active=True,
37
+ )
38
+
30
39
  return project
31
40
 
32
41
 
@@ -36,18 +45,29 @@ def list_projects(skip: int = 0, limit: int = 50, db: Session = Depends(get_db))
36
45
 
37
46
 
38
47
  @router.get("/{project_id}", response_model=ProjectResponse)
39
- def get_project(project_id: int, db: Session = Depends(get_db)):
48
+ def get_project(
49
+ project_id: int,
50
+ current_user=Depends(require_optional_auth_with_scopes({"projects:read"})),
51
+ db: Session = Depends(get_db),
52
+ ):
40
53
  project = db.query(Project).filter(Project.id == project_id).first()
41
54
  if not project:
42
55
  raise HTTPException(status_code=404, detail="Project not found")
56
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
43
57
  return project
44
58
 
45
59
 
46
60
  @router.patch("/{project_id}", response_model=ProjectResponse)
47
- def update_project(project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db)):
61
+ def update_project(
62
+ project_id: int,
63
+ payload: ProjectUpdate,
64
+ current_user=Depends(require_optional_auth_with_scopes({"projects:write"})),
65
+ db: Session = Depends(get_db),
66
+ ):
48
67
  project = db.query(Project).filter(Project.id == project_id).first()
49
68
  if not project:
50
69
  raise HTTPException(status_code=404, detail="Project not found")
70
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
51
71
  for field, value in payload.model_dump(exclude_none=True).items():
52
72
  setattr(project, field, value)
53
73
  db.commit()
@@ -56,10 +76,15 @@ def update_project(project_id: int, payload: ProjectUpdate, db: Session = Depend
56
76
 
57
77
 
58
78
  @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
59
- def delete_project(project_id: int, db: Session = Depends(get_db)):
79
+ def delete_project(
80
+ project_id: int,
81
+ current_user=Depends(require_optional_auth_with_scopes({"projects:write"})),
82
+ db: Session = Depends(get_db),
83
+ ):
60
84
  project = db.query(Project).filter(Project.id == project_id).first()
61
85
  if not project:
62
86
  raise HTTPException(status_code=404, detail="Project not found")
87
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
63
88
  db.delete(project)
64
89
  db.commit()
65
90
 
@@ -70,10 +95,7 @@ def list_project_members(
70
95
  current_user=Depends(require_auth_with_scopes({"members:read"})),
71
96
  db: Session = Depends(get_db),
72
97
  ):
73
- rbac_service.ensure_project_exists(db, project_id)
74
- membership = rbac_service.get_user_project_membership(db, project_id, current_user.id)
75
- if membership is None or membership.role not in rbac_service.READ_ROLES:
76
- raise HTTPException(status_code=403, detail="Insufficient project role")
98
+ rbac_service.ensure_project_read_access(db, project_id, current_user.id)
77
99
  return rbac_service.list_project_members(db, project_id)
78
100
 
79
101
 
@@ -33,6 +33,8 @@ from app.models.script_revision import ScriptRevision, RevisionChangeType
33
33
  from app.agents.script_gini_agent import run_agent
34
34
  from app.llm.provider import LLMProvider, get_llm_diagnostics
35
35
  from app.services.git_export import export_generated_script
36
+ from app.services import rbac as rbac_service
37
+ from app.services.auth_dependencies import require_optional_auth_with_scopes
36
38
  from app.tasks import process_script_generation_job
37
39
 
38
40
  logger = logging.getLogger(__name__)
@@ -174,8 +176,15 @@ def _provider_readiness(provider: LLMProvider) -> tuple[bool, str]:
174
176
 
175
177
 
176
178
  @router.get("/providers/{provider}/ready")
177
- def provider_ready(project_id: int, tc_id: int, provider: LLMProvider, db: Session = Depends(get_db)):
179
+ def provider_ready(
180
+ project_id: int,
181
+ tc_id: int,
182
+ provider: LLMProvider,
183
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
184
+ db: Session = Depends(get_db),
185
+ ):
178
186
  _get_project_or_404(project_id, db)
187
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
179
188
  _get_tc_or_404(project_id, tc_id, db)
180
189
  ok, detail = _provider_readiness(provider)
181
190
  return {"ready": ok, "detail": detail}
@@ -464,10 +473,12 @@ def generate_script(
464
473
  project_id: int,
465
474
  tc_id: int,
466
475
  payload: GenerateScriptRequest,
476
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
467
477
  db: Session = Depends(get_db),
468
478
  ):
469
479
  """Kick off async script generation. Poll GET /scripts/{id} for the result."""
470
480
  project = _get_project_or_404(project_id, db)
481
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
471
482
  _get_tc_or_404(project_id, tc_id, db)
472
483
 
473
484
  framework = (payload.framework or project.default_framework).value
@@ -506,9 +517,11 @@ def list_scripts(
506
517
  tc_id: int,
507
518
  skip: int = 0,
508
519
  limit: int = 50,
520
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
509
521
  db: Session = Depends(get_db),
510
522
  ):
511
523
  _get_project_or_404(project_id, db)
524
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
512
525
  _get_tc_or_404(project_id, tc_id, db)
513
526
  return (
514
527
  db.query(GeneratedScript)
@@ -521,15 +534,29 @@ def list_scripts(
521
534
 
522
535
 
523
536
  @router.get("/{script_id}", response_model=GeneratedScriptResponse)
524
- def get_script(project_id: int, tc_id: int, script_id: int, db: Session = Depends(get_db)):
537
+ def get_script(
538
+ project_id: int,
539
+ tc_id: int,
540
+ script_id: int,
541
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
542
+ db: Session = Depends(get_db),
543
+ ):
525
544
  _get_project_or_404(project_id, db)
545
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
526
546
  _get_tc_or_404(project_id, tc_id, db)
527
547
  return _get_script_or_404(project_id, tc_id, script_id, db)
528
548
 
529
549
 
530
550
  @router.get("/{script_id}/runs", response_model=list[ScriptRunResponse])
531
- def list_script_runs(project_id: int, tc_id: int, script_id: int, db: Session = Depends(get_db)):
551
+ def list_script_runs(
552
+ project_id: int,
553
+ tc_id: int,
554
+ script_id: int,
555
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
556
+ db: Session = Depends(get_db),
557
+ ):
532
558
  _get_project_or_404(project_id, db)
559
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
533
560
  _get_tc_or_404(project_id, tc_id, db)
534
561
  _get_script_or_404(project_id, tc_id, script_id, db)
535
562
  return (
@@ -541,8 +568,15 @@ def list_script_runs(project_id: int, tc_id: int, script_id: int, db: Session =
541
568
 
542
569
 
543
570
  @router.post("/{script_id}/run", response_model=ScriptRunResponse)
544
- def run_script(project_id: int, tc_id: int, script_id: int, db: Session = Depends(get_db)):
571
+ def run_script(
572
+ project_id: int,
573
+ tc_id: int,
574
+ script_id: int,
575
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
576
+ db: Session = Depends(get_db),
577
+ ):
545
578
  project = _get_project_or_404(project_id, db)
579
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
546
580
  _get_tc_or_404(project_id, tc_id, db)
547
581
  script = _get_script_or_404(project_id, tc_id, script_id, db)
548
582
 
@@ -582,8 +616,15 @@ def run_script(project_id: int, tc_id: int, script_id: int, db: Session = Depend
582
616
 
583
617
 
584
618
  @router.post("/{script_id}/github-export")
585
- def github_export_script(project_id: int, tc_id: int, script_id: int, db: Session = Depends(get_db)):
619
+ def github_export_script(
620
+ project_id: int,
621
+ tc_id: int,
622
+ script_id: int,
623
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
624
+ db: Session = Depends(get_db),
625
+ ):
586
626
  project = _get_project_or_404(project_id, db)
627
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
587
628
  tc = _get_tc_or_404(project_id, tc_id, db)
588
629
  script = _get_script_or_404(project_id, tc_id, script_id, db)
589
630
 
@@ -619,8 +660,15 @@ def github_export_script(project_id: int, tc_id: int, script_id: int, db: Sessio
619
660
 
620
661
 
621
662
  @router.delete("/{script_id}", status_code=status.HTTP_204_NO_CONTENT)
622
- def delete_script(project_id: int, tc_id: int, script_id: int, db: Session = Depends(get_db)):
663
+ def delete_script(
664
+ project_id: int,
665
+ tc_id: int,
666
+ script_id: int,
667
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
668
+ db: Session = Depends(get_db),
669
+ ):
623
670
  _get_project_or_404(project_id, db)
671
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
624
672
  script = _get_script_or_404(project_id, tc_id, script_id, db)
625
673
  db.delete(script)
626
674
  db.commit()
@@ -666,10 +714,12 @@ def refactor_script(
666
714
  tc_id: int,
667
715
  script_id: int,
668
716
  payload: RefactorScriptRequest,
717
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
669
718
  db: Session = Depends(get_db),
670
719
  ):
671
720
  """Submit an error log; the LLM heals the script and saves a new revision."""
672
721
  project = _get_project_or_404(project_id, db)
722
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
673
723
  tc = _get_tc_or_404(project_id, tc_id, db)
674
724
  script = _get_script_or_404(project_id, tc_id, script_id, db)
675
725
 
@@ -727,9 +777,11 @@ def get_version_history(
727
777
  project_id: int,
728
778
  tc_id: int,
729
779
  script_id: int,
780
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
730
781
  db: Session = Depends(get_db),
731
782
  ):
732
783
  _get_project_or_404(project_id, db)
784
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
733
785
  _get_tc_or_404(project_id, tc_id, db)
734
786
  _get_script_or_404(project_id, tc_id, script_id, db)
735
787
 
@@ -752,10 +804,12 @@ def get_revision_diff(
752
804
  tc_id: int,
753
805
  script_id: int,
754
806
  revision_id: int,
807
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
755
808
  db: Session = Depends(get_db),
756
809
  ):
757
810
  """Return a unified diff between the revision just before `revision_id` and `revision_id`."""
758
811
  _get_project_or_404(project_id, db)
812
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
759
813
  _get_tc_or_404(project_id, tc_id, db)
760
814
  _get_script_or_404(project_id, tc_id, script_id, db)
761
815
 
@@ -803,10 +857,12 @@ def rollback_script(
803
857
  tc_id: int,
804
858
  script_id: int,
805
859
  revision_id: int,
860
+ current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
806
861
  db: Session = Depends(get_db),
807
862
  ):
808
863
  """Restore a script to a previously saved revision and record a rolled_back snapshot."""
809
864
  _get_project_or_404(project_id, db)
865
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
810
866
  _get_tc_or_404(project_id, tc_id, db)
811
867
  script = _get_script_or_404(project_id, tc_id, script_id, db)
812
868
 
@@ -5,6 +5,8 @@ from app.database import get_db
5
5
  from app.models.project import Project
6
6
  from app.models.test_case import TestCase
7
7
  from app.schemas.test_case import TestCaseCreate, TestCaseUpdate, TestCaseResponse
8
+ from app.services import rbac as rbac_service
9
+ from app.services.auth_dependencies import require_optional_auth_with_scopes
8
10
 
9
11
  router = APIRouter(prefix="/projects/{project_id}/test-cases", tags=["Test Cases"])
10
12
 
@@ -17,8 +19,14 @@ def _get_project_or_404(project_id: int, db: Session) -> Project:
17
19
 
18
20
 
19
21
  @router.post("/", response_model=TestCaseResponse, status_code=status.HTTP_201_CREATED)
20
- def create_test_case(project_id: int, payload: TestCaseCreate, db: Session = Depends(get_db)):
22
+ def create_test_case(
23
+ project_id: int,
24
+ payload: TestCaseCreate,
25
+ current_user=Depends(require_optional_auth_with_scopes({"test-cases:write"})),
26
+ db: Session = Depends(get_db),
27
+ ):
21
28
  _get_project_or_404(project_id, db)
29
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
22
30
  tc = TestCase(project_id=project_id, **payload.model_dump())
23
31
  db.add(tc)
24
32
  db.commit()
@@ -27,14 +35,27 @@ def create_test_case(project_id: int, payload: TestCaseCreate, db: Session = Dep
27
35
 
28
36
 
29
37
  @router.get("/", response_model=list[TestCaseResponse])
30
- def list_test_cases(project_id: int, skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
38
+ def list_test_cases(
39
+ project_id: int,
40
+ skip: int = 0,
41
+ limit: int = 100,
42
+ current_user=Depends(require_optional_auth_with_scopes({"test-cases:read"})),
43
+ db: Session = Depends(get_db),
44
+ ):
31
45
  _get_project_or_404(project_id, db)
46
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
32
47
  return db.query(TestCase).filter(TestCase.project_id == project_id).offset(skip).limit(limit).all()
33
48
 
34
49
 
35
50
  @router.get("/{tc_id}", response_model=TestCaseResponse)
36
- def get_test_case(project_id: int, tc_id: int, db: Session = Depends(get_db)):
51
+ def get_test_case(
52
+ project_id: int,
53
+ tc_id: int,
54
+ current_user=Depends(require_optional_auth_with_scopes({"test-cases:read"})),
55
+ db: Session = Depends(get_db),
56
+ ):
37
57
  _get_project_or_404(project_id, db)
58
+ rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
38
59
  tc = db.query(TestCase).filter(TestCase.project_id == project_id, TestCase.id == tc_id).first()
39
60
  if not tc:
40
61
  raise HTTPException(status_code=404, detail="Test case not found")
@@ -42,8 +63,15 @@ def get_test_case(project_id: int, tc_id: int, db: Session = Depends(get_db)):
42
63
 
43
64
 
44
65
  @router.patch("/{tc_id}", response_model=TestCaseResponse)
45
- def update_test_case(project_id: int, tc_id: int, payload: TestCaseUpdate, db: Session = Depends(get_db)):
66
+ def update_test_case(
67
+ project_id: int,
68
+ tc_id: int,
69
+ payload: TestCaseUpdate,
70
+ current_user=Depends(require_optional_auth_with_scopes({"test-cases:write"})),
71
+ db: Session = Depends(get_db),
72
+ ):
46
73
  _get_project_or_404(project_id, db)
74
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
47
75
  tc = db.query(TestCase).filter(TestCase.project_id == project_id, TestCase.id == tc_id).first()
48
76
  if not tc:
49
77
  raise HTTPException(status_code=404, detail="Test case not found")
@@ -55,8 +83,14 @@ def update_test_case(project_id: int, tc_id: int, payload: TestCaseUpdate, db: S
55
83
 
56
84
 
57
85
  @router.delete("/{tc_id}", status_code=status.HTTP_204_NO_CONTENT)
58
- def delete_test_case(project_id: int, tc_id: int, db: Session = Depends(get_db)):
86
+ def delete_test_case(
87
+ project_id: int,
88
+ tc_id: int,
89
+ current_user=Depends(require_optional_auth_with_scopes({"test-cases:write"})),
90
+ db: Session = Depends(get_db),
91
+ ):
59
92
  _get_project_or_404(project_id, db)
93
+ rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
60
94
  tc = db.query(TestCase).filter(TestCase.project_id == project_id, TestCase.id == tc_id).first()
61
95
  if not tc:
62
96
  raise HTTPException(status_code=404, detail="Test case not found")
@@ -9,6 +9,28 @@ from app.services import auth as auth_service
9
9
  from app.services import api_key as api_key_service
10
10
 
11
11
  security = HTTPBearer()
12
+ optional_security = HTTPBearer(auto_error=False)
13
+
14
+
15
+ def _resolve_user_or_api_key_identity(token: str, db: Session):
16
+ # First try to verify as JWT token.
17
+ token_data = auth_service.verify_token(token, token_type="access")
18
+ if token_data:
19
+ user = auth_service.get_user_by_id(db, token_data.user_id)
20
+ if user and user.is_active:
21
+ return user
22
+
23
+ # Then try key_id:key_secret API key format.
24
+ if ":" in token:
25
+ key_id, key_secret = token.split(":", 1)
26
+ api_key = api_key_service.verify_api_key(db, key_id, key_secret)
27
+ if api_key:
28
+ user = auth_service.get_user_by_id(db, api_key.user_id)
29
+ if user and user.is_active:
30
+ user._api_key = api_key
31
+ return user
32
+
33
+ return None
12
34
 
13
35
 
14
36
  def get_current_user(
@@ -66,25 +88,9 @@ def get_current_user_or_api_key(
66
88
  return {"user_id": current_user.id}
67
89
  """
68
90
  token = credentials.credentials
69
-
70
- # First try to verify as JWT token
71
- token_data = auth_service.verify_token(token, token_type="access")
72
- if token_data:
73
- user = auth_service.get_user_by_id(db, token_data.user_id)
74
- if user and user.is_active:
75
- return user
76
-
77
- # Try to parse as API key (key_id:key_secret format)
78
- if ":" in token:
79
- key_id, key_secret = token.split(":", 1)
80
- api_key = api_key_service.verify_api_key(db, key_id, key_secret)
81
- if api_key:
82
- # Return the user associated with the API key
83
- user = auth_service.get_user_by_id(db, api_key.user_id)
84
- if user and user.is_active:
85
- # Attach api_key to user for scope checking in routes
86
- user._api_key = api_key
87
- return user
91
+ user = _resolve_user_or_api_key_identity(token, db)
92
+ if user is not None:
93
+ return user
88
94
 
89
95
  raise HTTPException(
90
96
  status_code=status.HTTP_401_UNAUTHORIZED,
@@ -93,6 +99,24 @@ def get_current_user_or_api_key(
93
99
  )
94
100
 
95
101
 
102
+ def get_optional_current_user_or_api_key(
103
+ credentials: HTTPAuthorizationCredentials | None = Depends(optional_security),
104
+ db: Session = Depends(get_db),
105
+ ):
106
+ if credentials is None:
107
+ return None
108
+
109
+ user = _resolve_user_or_api_key_identity(credentials.credentials, db)
110
+ if user is not None:
111
+ return user
112
+
113
+ raise HTTPException(
114
+ status_code=status.HTTP_401_UNAUTHORIZED,
115
+ detail="Invalid or expired credentials",
116
+ headers={"WWW-Authenticate": "Bearer"},
117
+ )
118
+
119
+
96
120
  def require_auth_with_scopes(required_scopes: set[str] | None = None) -> Callable:
97
121
  required_scopes = required_scopes or set()
98
122
 
@@ -108,3 +132,23 @@ def require_auth_with_scopes(required_scopes: set[str] | None = None) -> Callabl
108
132
  return current_user
109
133
 
110
134
  return _dependency
135
+
136
+
137
+ def require_optional_auth_with_scopes(required_scopes: set[str] | None = None) -> Callable:
138
+ required_scopes = required_scopes or set()
139
+
140
+ def _dependency(current_user=Depends(get_optional_current_user_or_api_key)):
141
+ if current_user is None:
142
+ return None
143
+
144
+ api_key = getattr(current_user, "_api_key", None)
145
+ if api_key is not None:
146
+ granted_scopes = set(api_key.scopes or [])
147
+ if not required_scopes.issubset(granted_scopes):
148
+ raise HTTPException(
149
+ status_code=status.HTTP_403_FORBIDDEN,
150
+ detail="API key does not have required scopes",
151
+ )
152
+ return current_user
153
+
154
+ return _dependency