scriptgini 1.0.7__tar.gz → 1.2.0__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 (62) hide show
  1. {scriptgini-1.0.7 → scriptgini-1.2.0}/PKG-INFO +3 -3
  2. {scriptgini-1.0.7 → scriptgini-1.2.0}/README.md +2 -2
  3. scriptgini-1.2.0/app/__init__.py +3 -0
  4. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/main.py +4 -2
  5. scriptgini-1.2.0/app/models/membership.py +54 -0
  6. scriptgini-1.2.0/app/models/organization.py +24 -0
  7. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/project.py +3 -1
  8. scriptgini-1.2.0/app/routers/organizations.py +36 -0
  9. scriptgini-1.2.0/app/routers/projects.py +113 -0
  10. scriptgini-1.2.0/app/schemas/membership.py +22 -0
  11. scriptgini-1.2.0/app/schemas/organization.py +19 -0
  12. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/project.py +3 -0
  13. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/services/auth_dependencies.py +19 -0
  14. scriptgini-1.2.0/app/services/rbac.py +118 -0
  15. {scriptgini-1.0.7 → scriptgini-1.2.0}/pyproject.toml +1 -1
  16. {scriptgini-1.0.7 → scriptgini-1.2.0}/scriptgini.egg-info/PKG-INFO +3 -3
  17. {scriptgini-1.0.7 → scriptgini-1.2.0}/scriptgini.egg-info/SOURCES.txt +8 -1
  18. scriptgini-1.2.0/tests/test_sprint2_rbac.py +295 -0
  19. scriptgini-1.0.7/app/routers/projects.py +0 -51
  20. scriptgini-1.0.7/app/schemas/__init__.py +0 -0
  21. {scriptgini-1.0.7/app → scriptgini-1.2.0/app/agents}/__init__.py +0 -0
  22. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/agents/prompts.py +0 -0
  23. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/agents/script_gini_agent.py +0 -0
  24. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/cache.py +0 -0
  25. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/celery_app.py +0 -0
  26. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/config.py +0 -0
  27. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/database.py +0 -0
  28. {scriptgini-1.0.7/app/agents → scriptgini-1.2.0/app/llm}/__init__.py +0 -0
  29. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/llm/provider.py +0 -0
  30. {scriptgini-1.0.7/app/llm → scriptgini-1.2.0/app/models}/__init__.py +0 -0
  31. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/api_key.py +0 -0
  32. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/bulk_job.py +0 -0
  33. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/generated_script.py +0 -0
  34. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/script_run.py +0 -0
  35. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/test_case.py +0 -0
  36. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/user.py +0 -0
  37. {scriptgini-1.0.7/app/models → scriptgini-1.2.0/app/routers}/__init__.py +0 -0
  38. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/analytics.py +0 -0
  39. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/api_key.py +0 -0
  40. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/auth.py +0 -0
  41. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/bulk_jobs.py +0 -0
  42. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/demo.py +0 -0
  43. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/scripts.py +0 -0
  44. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/test_cases.py +0 -0
  45. {scriptgini-1.0.7/app/routers → scriptgini-1.2.0/app/schemas}/__init__.py +0 -0
  46. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/analytics.py +0 -0
  47. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/api_key.py +0 -0
  48. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/auth.py +0 -0
  49. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/bulk_job.py +0 -0
  50. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/generated_script.py +0 -0
  51. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/test_case.py +0 -0
  52. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/services/api_key.py +0 -0
  53. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/services/auth.py +0 -0
  54. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/services/git_export.py +0 -0
  55. {scriptgini-1.0.7 → scriptgini-1.2.0}/app/tasks.py +0 -0
  56. {scriptgini-1.0.7 → scriptgini-1.2.0}/scriptgini.egg-info/dependency_links.txt +0 -0
  57. {scriptgini-1.0.7 → scriptgini-1.2.0}/scriptgini.egg-info/top_level.txt +0 -0
  58. {scriptgini-1.0.7 → scriptgini-1.2.0}/setup.cfg +0 -0
  59. {scriptgini-1.0.7 → scriptgini-1.2.0}/tests/test_api.py +0 -0
  60. {scriptgini-1.0.7 → scriptgini-1.2.0}/tests/test_auth.py +0 -0
  61. {scriptgini-1.0.7 → scriptgini-1.2.0}/tests/test_coverage.py +0 -0
  62. {scriptgini-1.0.7 → scriptgini-1.2.0}/tests/test_infra_services_coverage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptgini
3
- Version: 1.0.7
3
+ Version: 1.2.0
4
4
  Summary: Agentic AI system that converts functional test cases into automation test scripts.
5
5
  Author: ScriptGini Team
6
6
  License: Proprietary
@@ -436,8 +436,8 @@ The project follows an **enterprise-grade development roadmap** with 6 sprints c
436
436
 
437
437
  | Sprint | Focus | Effort | Status |
438
438
  |--------|-------|--------|--------|
439
- | **Sprint 1** | IAM Core | 30-36pts | 🔲 Pending (User model, JWT, auth middleware) |
440
- | **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts | 🔲 Pending (Org/team models, RBAC enforcement) |
439
+ | **Sprint 1** | IAM Core | 30-36pts | 🟡 Core delivered (auth hardening pending) |
440
+ | **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts | 🟡 Core delivered (RBAC hardening pending) |
441
441
  | **Sprint 3** | Durable Execution | 34-40pts | 🔲 Pending (Redis + Celery/Arq setup) |
442
442
  | **Sprint 4** | Security & Hardening | 30-36pts | 🔲 Pending (Container sandbox, audit logging) |
443
443
  | **Sprint 5** | Reporting & Analytics | 28-34pts | 🔲 Pending (Artifact storage, dashboards) |
@@ -422,8 +422,8 @@ The project follows an **enterprise-grade development roadmap** with 6 sprints c
422
422
 
423
423
  | Sprint | Focus | Effort | Status |
424
424
  |--------|-------|--------|--------|
425
- | **Sprint 1** | IAM Core | 30-36pts | 🔲 Pending (User model, JWT, auth middleware) |
426
- | **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts | 🔲 Pending (Org/team models, RBAC enforcement) |
425
+ | **Sprint 1** | IAM Core | 30-36pts | 🟡 Core delivered (auth hardening pending) |
426
+ | **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts | 🟡 Core delivered (RBAC hardening pending) |
427
427
  | **Sprint 3** | Durable Execution | 34-40pts | 🔲 Pending (Redis + Celery/Arq setup) |
428
428
  | **Sprint 4** | Security & Hardening | 30-36pts | 🔲 Pending (Container sandbox, audit logging) |
429
429
  | **Sprint 5** | Reporting & Analytics | 28-34pts | 🔲 Pending (Artifact storage, dashboards) |
@@ -0,0 +1,3 @@
1
+ __version__ = "1.2.0"
2
+ __api_version__ = "v1.2.0"
3
+
@@ -7,9 +7,10 @@ from fastapi.responses import FileResponse
7
7
  from fastapi.middleware.cors import CORSMiddleware
8
8
  from fastapi.staticfiles import StaticFiles
9
9
 
10
+ from app import __version__
10
11
  from app.config import settings
11
12
  from app.llm.provider import get_llm_diagnostics
12
- from app.routers import projects, test_cases, scripts, bulk_jobs, analytics, demo, auth, api_key
13
+ from app.routers import projects, test_cases, scripts, bulk_jobs, analytics, demo, auth, api_key, organizations
13
14
 
14
15
  logging.basicConfig(level=logging.DEBUG if settings.DEBUG else logging.INFO)
15
16
  logger = logging.getLogger(__name__)
@@ -36,7 +37,7 @@ app = FastAPI(
36
37
  "Enterprise-grade Agentic AI system that converts functional test cases "
37
38
  "into high-quality automation scripts."
38
39
  ),
39
- version="1.0.7",
40
+ version=__version__,
40
41
  lifespan=lifespan,
41
42
  )
42
43
 
@@ -56,6 +57,7 @@ app.include_router(analytics.router, prefix="/api/v1")
56
57
  app.include_router(demo.router, prefix="/api/v1")
57
58
  app.include_router(auth.router, prefix="/api/v1")
58
59
  app.include_router(api_key.router, prefix="/api/v1")
60
+ app.include_router(organizations.router, prefix="/api/v1")
59
61
  app.mount("/static", StaticFiles(directory=static_dir), name="static")
60
62
 
61
63
 
@@ -0,0 +1,54 @@
1
+ import enum
2
+ from datetime import datetime, timezone
3
+
4
+ from sqlalchemy import DateTime, Boolean, ForeignKey, Enum as SAEnum, UniqueConstraint
5
+ from sqlalchemy.orm import Mapped, mapped_column
6
+
7
+ from app.database import Base
8
+
9
+
10
+ class Role(str, enum.Enum):
11
+ owner = "owner"
12
+ admin = "admin"
13
+ member = "member"
14
+ viewer = "viewer"
15
+
16
+
17
+ class OrganizationMembership(Base):
18
+ __tablename__ = "organization_memberships"
19
+ __table_args__ = (UniqueConstraint("organization_id", "user_id", name="uq_org_membership_org_user"),)
20
+
21
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
22
+ organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), nullable=False, index=True)
23
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
24
+ role: Mapped[Role] = mapped_column(SAEnum(Role), nullable=False, default=Role.member)
25
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
26
+ created_at: Mapped[datetime] = mapped_column(
27
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
28
+ )
29
+ updated_at: Mapped[datetime] = mapped_column(
30
+ DateTime(timezone=True),
31
+ default=lambda: datetime.now(timezone.utc),
32
+ onupdate=lambda: datetime.now(timezone.utc),
33
+ )
34
+
35
+
36
+
37
+ class ProjectMembership(Base):
38
+ __tablename__ = "project_memberships"
39
+ __table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_membership_project_user"),)
40
+
41
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
42
+ project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
43
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
44
+ role: Mapped[Role] = mapped_column(SAEnum(Role), nullable=False, default=Role.member)
45
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
46
+ created_at: Mapped[datetime] = mapped_column(
47
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
48
+ )
49
+ updated_at: Mapped[datetime] = mapped_column(
50
+ DateTime(timezone=True),
51
+ default=lambda: datetime.now(timezone.utc),
52
+ onupdate=lambda: datetime.now(timezone.utc),
53
+ )
54
+
@@ -0,0 +1,24 @@
1
+ from datetime import datetime, timezone
2
+
3
+ from sqlalchemy import String, Text, DateTime, ForeignKey
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+
6
+ from app.database import Base
7
+
8
+
9
+ class Organization(Base):
10
+ __tablename__ = "organizations"
11
+
12
+ id: Mapped[int] = mapped_column(primary_key=True, index=True)
13
+ name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
14
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
15
+ created_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
16
+ created_at: Mapped[datetime] = mapped_column(
17
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
18
+ )
19
+ updated_at: Mapped[datetime] = mapped_column(
20
+ DateTime(timezone=True),
21
+ default=lambda: datetime.now(timezone.utc),
22
+ onupdate=lambda: datetime.now(timezone.utc),
23
+ )
24
+
@@ -1,7 +1,7 @@
1
1
  import enum
2
2
  from datetime import datetime, timezone
3
3
 
4
- from sqlalchemy import String, Text, DateTime, Enum as SAEnum
4
+ from sqlalchemy import String, Text, DateTime, Enum as SAEnum, ForeignKey
5
5
  from sqlalchemy.orm import Mapped, mapped_column
6
6
 
7
7
  from app.database import Base
@@ -26,6 +26,7 @@ class Project(Base):
26
26
  __tablename__ = "projects"
27
27
 
28
28
  id: Mapped[int] = mapped_column(primary_key=True, index=True)
29
+ organization_id: Mapped[int | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
29
30
  name: Mapped[str] = mapped_column(String(255), nullable=False)
30
31
  description: Mapped[str | None] = mapped_column(Text, nullable=True)
31
32
  aut_base_url: Mapped[str] = mapped_column(String(2048), nullable=False)
@@ -44,3 +45,4 @@ class Project(Base):
44
45
  default=lambda: datetime.now(timezone.utc),
45
46
  onupdate=lambda: datetime.now(timezone.utc),
46
47
  )
48
+
@@ -0,0 +1,36 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.exc import IntegrityError
3
+ from sqlalchemy.orm import Session
4
+
5
+ from app.database import get_db
6
+ from app.schemas.organization import OrganizationCreate, OrganizationResponse
7
+ from app.services.auth_dependencies import require_auth_with_scopes
8
+ from app.services import rbac as rbac_service
9
+
10
+ router = APIRouter(prefix="/organizations", tags=["Organizations"])
11
+
12
+
13
+ @router.post("", response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED)
14
+ def create_organization(
15
+ payload: OrganizationCreate,
16
+ current_user=Depends(require_auth_with_scopes({"org:write"})),
17
+ db: Session = Depends(get_db),
18
+ ):
19
+ try:
20
+ return rbac_service.create_organization(
21
+ db=db,
22
+ user_id=current_user.id,
23
+ name=payload.name,
24
+ description=payload.description,
25
+ )
26
+ except IntegrityError as exc:
27
+ db.rollback()
28
+ raise HTTPException(status_code=400, detail="Organization already exists") from exc
29
+
30
+
31
+ @router.get("", response_model=list[OrganizationResponse])
32
+ def list_organizations(
33
+ current_user=Depends(require_auth_with_scopes({"org:read"})),
34
+ db: Session = Depends(get_db),
35
+ ):
36
+ return rbac_service.list_user_organizations(db, current_user.id)
@@ -0,0 +1,113 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app.database import get_db
5
+ from app.models.project import Project
6
+ from app.schemas.membership import ProjectMemberResponse, ProjectMemberUpsertRequest
7
+ from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
8
+ from app.services import rbac as rbac_service
9
+ from app.services.auth_dependencies import require_auth_with_scopes
10
+
11
+ router = APIRouter(prefix="/projects", tags=["Projects"])
12
+
13
+
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")
22
+
23
+
24
+ @router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
25
+ def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
26
+ project = Project(**payload.model_dump())
27
+ db.add(project)
28
+ db.commit()
29
+ db.refresh(project)
30
+ return project
31
+
32
+
33
+ @router.get("/", response_model=list[ProjectResponse])
34
+ def list_projects(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)):
35
+ return db.query(Project).offset(skip).limit(limit).all()
36
+
37
+
38
+ @router.get("/{project_id}", response_model=ProjectResponse)
39
+ def get_project(project_id: int, db: Session = Depends(get_db)):
40
+ project = db.query(Project).filter(Project.id == project_id).first()
41
+ if not project:
42
+ raise HTTPException(status_code=404, detail="Project not found")
43
+ return project
44
+
45
+
46
+ @router.patch("/{project_id}", response_model=ProjectResponse)
47
+ def update_project(project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db)):
48
+ project = db.query(Project).filter(Project.id == project_id).first()
49
+ if not project:
50
+ raise HTTPException(status_code=404, detail="Project not found")
51
+ for field, value in payload.model_dump(exclude_none=True).items():
52
+ setattr(project, field, value)
53
+ db.commit()
54
+ db.refresh(project)
55
+ return project
56
+
57
+
58
+ @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
59
+ def delete_project(project_id: int, db: Session = Depends(get_db)):
60
+ project = db.query(Project).filter(Project.id == project_id).first()
61
+ if not project:
62
+ raise HTTPException(status_code=404, detail="Project not found")
63
+ db.delete(project)
64
+ db.commit()
65
+
66
+
67
+ @router.get("/{project_id}/members", response_model=list[ProjectMemberResponse])
68
+ def list_project_members(
69
+ project_id: int,
70
+ current_user=Depends(require_auth_with_scopes({"members:read"})),
71
+ db: Session = Depends(get_db),
72
+ ):
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")
77
+ return rbac_service.list_project_members(db, project_id)
78
+
79
+
80
+ @router.put("/{project_id}/members/{user_id}", response_model=ProjectMemberResponse)
81
+ def upsert_project_member(
82
+ project_id: int,
83
+ user_id: int,
84
+ payload: ProjectMemberUpsertRequest,
85
+ current_user=Depends(require_auth_with_scopes({"members:write"})),
86
+ db: Session = Depends(get_db),
87
+ ):
88
+ rbac_service.ensure_project_exists(db, project_id)
89
+ _ensure_project_manager(db, project_id, current_user.id)
90
+
91
+ return rbac_service.upsert_project_member(
92
+ db=db,
93
+ project_id=project_id,
94
+ user_id=user_id,
95
+ role=payload.role,
96
+ is_active=payload.is_active,
97
+ )
98
+
99
+
100
+ @router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
101
+ def delete_project_member(
102
+ project_id: int,
103
+ user_id: int,
104
+ current_user=Depends(require_auth_with_scopes({"members:write"})),
105
+ db: Session = Depends(get_db),
106
+ ):
107
+ rbac_service.ensure_project_exists(db, project_id)
108
+ _ensure_project_manager(db, project_id, current_user.id)
109
+
110
+ if not rbac_service.remove_project_member(db, project_id, user_id):
111
+ raise HTTPException(status_code=404, detail="Project member not found")
112
+
113
+ return None
@@ -0,0 +1,22 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from app.models.membership import Role
6
+
7
+
8
+ class ProjectMemberUpsertRequest(BaseModel):
9
+ role: Role
10
+ is_active: bool = True
11
+
12
+
13
+ class ProjectMemberResponse(BaseModel):
14
+ id: int
15
+ project_id: int
16
+ user_id: int
17
+ role: Role
18
+ is_active: bool
19
+ created_at: datetime
20
+ updated_at: datetime
21
+
22
+ model_config = {"from_attributes": True}
@@ -0,0 +1,19 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class OrganizationCreate(BaseModel):
7
+ name: str = Field(..., min_length=2, max_length=255)
8
+ description: str | None = None
9
+
10
+
11
+ class OrganizationResponse(BaseModel):
12
+ id: int
13
+ name: str
14
+ description: str | None
15
+ created_by_user_id: int
16
+ created_at: datetime
17
+ updated_at: datetime
18
+
19
+ model_config = {"from_attributes": True}
@@ -5,6 +5,7 @@ from app.models.project import TestFramework, SelectorPreference
5
5
 
6
6
 
7
7
  class ProjectCreate(BaseModel):
8
+ organization_id: int | None = None
8
9
  name: str = Field(..., max_length=255)
9
10
  description: str | None = None
10
11
  aut_base_url: str = Field(..., description="Base URL of the Application Under Test")
@@ -14,6 +15,7 @@ class ProjectCreate(BaseModel):
14
15
 
15
16
 
16
17
  class ProjectUpdate(BaseModel):
18
+ organization_id: int | None = None
17
19
  name: str | None = Field(None, max_length=255)
18
20
  description: str | None = None
19
21
  aut_base_url: str | None = None
@@ -24,6 +26,7 @@ class ProjectUpdate(BaseModel):
24
26
 
25
27
  class ProjectResponse(BaseModel):
26
28
  id: int
29
+ organization_id: int | None
27
30
  name: str
28
31
  description: str | None
29
32
  aut_base_url: str
@@ -1,3 +1,5 @@
1
+ from collections.abc import Callable
2
+
1
3
  from fastapi import Depends, HTTPException, status
2
4
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
3
5
  from sqlalchemy.orm import Session
@@ -89,3 +91,20 @@ def get_current_user_or_api_key(
89
91
  detail="Invalid or expired credentials",
90
92
  headers={"WWW-Authenticate": "Bearer"},
91
93
  )
94
+
95
+
96
+ def require_auth_with_scopes(required_scopes: set[str] | None = None) -> Callable:
97
+ required_scopes = required_scopes or set()
98
+
99
+ def _dependency(current_user=Depends(get_current_user_or_api_key)):
100
+ api_key = getattr(current_user, "_api_key", None)
101
+ if api_key is not None:
102
+ granted_scopes = set(api_key.scopes or [])
103
+ if not required_scopes.issubset(granted_scopes):
104
+ raise HTTPException(
105
+ status_code=status.HTTP_403_FORBIDDEN,
106
+ detail="API key does not have required scopes",
107
+ )
108
+ return current_user
109
+
110
+ return _dependency
@@ -0,0 +1,118 @@
1
+ from sqlalchemy.orm import Session
2
+
3
+ from app.models.membership import ProjectMembership, Role, OrganizationMembership
4
+ from app.models.organization import Organization
5
+ from app.models.project import Project
6
+
7
+
8
+ MANAGER_ROLES = {Role.owner, Role.admin}
9
+ READ_ROLES = {Role.owner, Role.admin, Role.member, Role.viewer}
10
+
11
+
12
+ def create_organization(db: Session, user_id: int, name: str, description: str | None = None) -> Organization:
13
+ organization = Organization(name=name, description=description, created_by_user_id=user_id)
14
+ db.add(organization)
15
+ db.flush()
16
+
17
+ owner_membership = OrganizationMembership(
18
+ organization_id=organization.id,
19
+ user_id=user_id,
20
+ role=Role.owner,
21
+ is_active=True,
22
+ )
23
+ db.add(owner_membership)
24
+ db.commit()
25
+ db.refresh(organization)
26
+ return organization
27
+
28
+
29
+ def list_user_organizations(db: Session, user_id: int) -> list[Organization]:
30
+ return (
31
+ db.query(Organization)
32
+ .join(OrganizationMembership, OrganizationMembership.organization_id == Organization.id)
33
+ .filter(
34
+ OrganizationMembership.user_id == user_id,
35
+ OrganizationMembership.is_active.is_(True),
36
+ )
37
+ .all()
38
+ )
39
+
40
+
41
+ def get_user_project_membership(db: Session, project_id: int, user_id: int) -> ProjectMembership | None:
42
+ return (
43
+ db.query(ProjectMembership)
44
+ .filter(
45
+ ProjectMembership.project_id == project_id,
46
+ ProjectMembership.user_id == user_id,
47
+ ProjectMembership.is_active.is_(True),
48
+ )
49
+ .first()
50
+ )
51
+
52
+
53
+ def ensure_project_exists(db: Session, project_id: int) -> Project:
54
+ project = db.query(Project).filter(Project.id == project_id).first()
55
+ if not project:
56
+ from fastapi import HTTPException
57
+
58
+ raise HTTPException(status_code=404, detail="Project not found")
59
+ return project
60
+
61
+
62
+ def list_project_members(db: Session, project_id: int) -> list[ProjectMembership]:
63
+ return (
64
+ db.query(ProjectMembership)
65
+ .filter(ProjectMembership.project_id == project_id)
66
+ .order_by(ProjectMembership.id.asc())
67
+ .all()
68
+ )
69
+
70
+
71
+ def upsert_project_member(
72
+ db: Session,
73
+ project_id: int,
74
+ user_id: int,
75
+ role: Role,
76
+ is_active: bool,
77
+ ) -> ProjectMembership:
78
+ membership = (
79
+ db.query(ProjectMembership)
80
+ .filter(
81
+ ProjectMembership.project_id == project_id,
82
+ ProjectMembership.user_id == user_id,
83
+ )
84
+ .first()
85
+ )
86
+
87
+ if membership is None:
88
+ membership = ProjectMembership(
89
+ project_id=project_id,
90
+ user_id=user_id,
91
+ role=role,
92
+ is_active=is_active,
93
+ )
94
+ db.add(membership)
95
+ else:
96
+ membership.role = role
97
+ membership.is_active = is_active
98
+
99
+ db.commit()
100
+ db.refresh(membership)
101
+ return membership
102
+
103
+
104
+ def remove_project_member(db: Session, project_id: int, user_id: int) -> bool:
105
+ membership = (
106
+ db.query(ProjectMembership)
107
+ .filter(
108
+ ProjectMembership.project_id == project_id,
109
+ ProjectMembership.user_id == user_id,
110
+ )
111
+ .first()
112
+ )
113
+ if not membership:
114
+ return False
115
+
116
+ db.delete(membership)
117
+ db.commit()
118
+ return True
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scriptgini"
7
- version = "1.0.7"
7
+ version = "1.2.0"
8
8
  description = "Agentic AI system that converts functional test cases into automation test scripts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptgini
3
- Version: 1.0.7
3
+ Version: 1.2.0
4
4
  Summary: Agentic AI system that converts functional test cases into automation test scripts.
5
5
  Author: ScriptGini Team
6
6
  License: Proprietary
@@ -436,8 +436,8 @@ The project follows an **enterprise-grade development roadmap** with 6 sprints c
436
436
 
437
437
  | Sprint | Focus | Effort | Status |
438
438
  |--------|-------|--------|--------|
439
- | **Sprint 1** | IAM Core | 30-36pts | 🔲 Pending (User model, JWT, auth middleware) |
440
- | **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts | 🔲 Pending (Org/team models, RBAC enforcement) |
439
+ | **Sprint 1** | IAM Core | 30-36pts | 🟡 Core delivered (auth hardening pending) |
440
+ | **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts | 🟡 Core delivered (RBAC hardening pending) |
441
441
  | **Sprint 3** | Durable Execution | 34-40pts | 🔲 Pending (Redis + Celery/Arq setup) |
442
442
  | **Sprint 4** | Security & Hardening | 30-36pts | 🔲 Pending (Container sandbox, audit logging) |
443
443
  | **Sprint 5** | Reporting & Analytics | 28-34pts | 🔲 Pending (Artifact storage, dashboards) |
@@ -16,6 +16,8 @@ app/models/__init__.py
16
16
  app/models/api_key.py
17
17
  app/models/bulk_job.py
18
18
  app/models/generated_script.py
19
+ app/models/membership.py
20
+ app/models/organization.py
19
21
  app/models/project.py
20
22
  app/models/script_run.py
21
23
  app/models/test_case.py
@@ -26,6 +28,7 @@ app/routers/api_key.py
26
28
  app/routers/auth.py
27
29
  app/routers/bulk_jobs.py
28
30
  app/routers/demo.py
31
+ app/routers/organizations.py
29
32
  app/routers/projects.py
30
33
  app/routers/scripts.py
31
34
  app/routers/test_cases.py
@@ -35,12 +38,15 @@ app/schemas/api_key.py
35
38
  app/schemas/auth.py
36
39
  app/schemas/bulk_job.py
37
40
  app/schemas/generated_script.py
41
+ app/schemas/membership.py
42
+ app/schemas/organization.py
38
43
  app/schemas/project.py
39
44
  app/schemas/test_case.py
40
45
  app/services/api_key.py
41
46
  app/services/auth.py
42
47
  app/services/auth_dependencies.py
43
48
  app/services/git_export.py
49
+ app/services/rbac.py
44
50
  scriptgini.egg-info/PKG-INFO
45
51
  scriptgini.egg-info/SOURCES.txt
46
52
  scriptgini.egg-info/dependency_links.txt
@@ -48,4 +54,5 @@ scriptgini.egg-info/top_level.txt
48
54
  tests/test_api.py
49
55
  tests/test_auth.py
50
56
  tests/test_coverage.py
51
- tests/test_infra_services_coverage.py
57
+ tests/test_infra_services_coverage.py
58
+ tests/test_sprint2_rbac.py
@@ -0,0 +1,295 @@
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+ from sqlalchemy import create_engine
4
+ from sqlalchemy.orm import sessionmaker
5
+ from sqlalchemy.pool import StaticPool
6
+
7
+ import app.models.user # noqa: F401
8
+ import app.models.api_key # noqa: F401
9
+ import app.models.project # noqa: F401
10
+ import app.models.organization # noqa: F401
11
+ import app.models.membership # noqa: F401
12
+
13
+ from app.database import Base, get_db
14
+ from app.main import app as fastapi_app
15
+
16
+ TEST_DATABASE_URL = "sqlite:///:memory:"
17
+
18
+ engine = create_engine(
19
+ TEST_DATABASE_URL,
20
+ connect_args={"check_same_thread": False},
21
+ poolclass=StaticPool,
22
+ )
23
+ TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
24
+
25
+
26
+ @pytest.fixture(autouse=True)
27
+ def setup_db():
28
+ Base.metadata.create_all(bind=engine)
29
+
30
+ def override_get_db():
31
+ db = TestingSessionLocal()
32
+ try:
33
+ yield db
34
+ finally:
35
+ db.close()
36
+
37
+ fastapi_app.dependency_overrides[get_db] = override_get_db
38
+ try:
39
+ yield
40
+ finally:
41
+ fastapi_app.dependency_overrides.clear()
42
+ Base.metadata.drop_all(bind=engine)
43
+
44
+
45
+ @pytest.fixture
46
+ def client():
47
+ return TestClient(fastapi_app)
48
+
49
+
50
+ def _register_and_login(client: TestClient, email: str, password: str = "TestPassword123!") -> tuple[int, dict[str, str]]:
51
+ register_resp = client.post(
52
+ "/api/v1/auth/register",
53
+ json={"email": email, "password": password},
54
+ )
55
+ assert register_resp.status_code == 201
56
+ user_id = register_resp.json()["id"]
57
+
58
+ login_resp = client.post(
59
+ "/api/v1/auth/login",
60
+ json={"email": email, "password": password},
61
+ )
62
+ assert login_resp.status_code == 200
63
+ token = login_resp.json()["access_token"]
64
+ return user_id, {"Authorization": f"Bearer {token}"}
65
+
66
+
67
+ def _create_project(client: TestClient) -> int:
68
+ resp = client.post(
69
+ "/api/v1/projects/",
70
+ json={
71
+ "name": "RBAC Project",
72
+ "aut_base_url": "https://example.com",
73
+ },
74
+ )
75
+ assert resp.status_code == 201
76
+ return resp.json()["id"]
77
+
78
+
79
+ class TestOrganizationEndpoints:
80
+ def test_jwt_create_and_list_organizations(self, client: TestClient):
81
+ _, headers = _register_and_login(client, "org-admin@example.com")
82
+
83
+ create_resp = client.post(
84
+ "/api/v1/organizations",
85
+ headers=headers,
86
+ json={"name": "Acme QA", "description": "Org for QA"},
87
+ )
88
+ assert create_resp.status_code == 201
89
+
90
+ list_resp = client.get("/api/v1/organizations", headers=headers)
91
+ assert list_resp.status_code == 200
92
+ assert len(list_resp.json()) == 1
93
+ assert list_resp.json()[0]["name"] == "Acme QA"
94
+
95
+ def test_api_key_scope_enforced_on_organizations(self, client: TestClient):
96
+ _, headers = _register_and_login(client, "api-key-org@example.com")
97
+
98
+ create_key_resp = client.post(
99
+ "/api/v1/auth/api-keys",
100
+ headers=headers,
101
+ json={"name": "Org Writer", "scopes": ["org:write"]},
102
+ )
103
+ assert create_key_resp.status_code == 201
104
+ key_data = create_key_resp.json()
105
+ auth_header = {"Authorization": f"Bearer {key_data['id']}:{key_data['secret_key']}"}
106
+
107
+ create_org = client.post(
108
+ "/api/v1/organizations",
109
+ headers=auth_header,
110
+ json={"name": "Scoped Org", "description": "Scoped create"},
111
+ )
112
+ assert create_org.status_code == 201
113
+
114
+ list_orgs_forbidden = client.get("/api/v1/organizations", headers=auth_header)
115
+ assert list_orgs_forbidden.status_code == 403
116
+
117
+ def test_duplicate_organization_name_returns_400(self, client: TestClient):
118
+ _, headers = _register_and_login(client, "org-dup@example.com")
119
+
120
+ first = client.post(
121
+ "/api/v1/organizations",
122
+ headers=headers,
123
+ json={"name": "Duplicate Org", "description": "first"},
124
+ )
125
+ assert first.status_code == 201
126
+
127
+ second = client.post(
128
+ "/api/v1/organizations",
129
+ headers=headers,
130
+ json={"name": "Duplicate Org", "description": "second"},
131
+ )
132
+ assert second.status_code == 400
133
+
134
+
135
+ class TestProjectMembersRBAC:
136
+ @pytest.mark.parametrize(
137
+ "role,expected_put,expected_delete",
138
+ [
139
+ ("owner", 200, 204),
140
+ ("admin", 200, 204),
141
+ ("member", 403, 403),
142
+ ("viewer", 403, 403),
143
+ ],
144
+ )
145
+ def test_role_matrix_for_member_management(
146
+ self,
147
+ client: TestClient,
148
+ role: str,
149
+ expected_put: int,
150
+ expected_delete: int,
151
+ ):
152
+ owner_id, owner_headers = _register_and_login(client, "owner@example.com")
153
+ actor_id, actor_headers = _register_and_login(client, f"actor-{role}@example.com")
154
+ target_id, _ = _register_and_login(client, f"target-{role}@example.com")
155
+
156
+ project_id = _create_project(client)
157
+
158
+ bootstrap_owner = client.put(
159
+ f"/api/v1/projects/{project_id}/members/{owner_id}",
160
+ headers=owner_headers,
161
+ json={"role": "owner", "is_active": True},
162
+ )
163
+ assert bootstrap_owner.status_code == 200
164
+
165
+ add_actor = client.put(
166
+ f"/api/v1/projects/{project_id}/members/{actor_id}",
167
+ headers=owner_headers,
168
+ json={"role": role, "is_active": True},
169
+ )
170
+ assert add_actor.status_code == 200
171
+
172
+ list_resp = client.get(
173
+ f"/api/v1/projects/{project_id}/members",
174
+ headers=actor_headers,
175
+ )
176
+ assert list_resp.status_code == 200
177
+
178
+ mutate_resp = client.put(
179
+ f"/api/v1/projects/{project_id}/members/{target_id}",
180
+ headers=actor_headers,
181
+ json={"role": "viewer", "is_active": True},
182
+ )
183
+ assert mutate_resp.status_code == expected_put
184
+
185
+ if expected_put == 200:
186
+ delete_resp = client.delete(
187
+ f"/api/v1/projects/{project_id}/members/{target_id}",
188
+ headers=actor_headers,
189
+ )
190
+ assert delete_resp.status_code == expected_delete
191
+ else:
192
+ # Non-manager roles should not be able to delete members either.
193
+ delete_resp = client.delete(
194
+ f"/api/v1/projects/{project_id}/members/{owner_id}",
195
+ headers=actor_headers,
196
+ )
197
+ assert delete_resp.status_code == expected_delete
198
+
199
+ def test_project_members_requires_scoped_api_key(self, client: TestClient):
200
+ owner_id, owner_headers = _register_and_login(client, "owner-api-key@example.com")
201
+ project_id = _create_project(client)
202
+
203
+ bootstrap_owner = client.put(
204
+ f"/api/v1/projects/{project_id}/members/{owner_id}",
205
+ headers=owner_headers,
206
+ json={"role": "owner", "is_active": True},
207
+ )
208
+ assert bootstrap_owner.status_code == 200
209
+
210
+ create_key_resp = client.post(
211
+ "/api/v1/auth/api-keys",
212
+ headers=owner_headers,
213
+ json={"name": "Read members", "scopes": ["members:read"]},
214
+ )
215
+ assert create_key_resp.status_code == 201
216
+ key_data = create_key_resp.json()
217
+
218
+ key_headers = {"Authorization": f"Bearer {key_data['id']}:{key_data['secret_key']}"}
219
+ list_resp = client.get(f"/api/v1/projects/{project_id}/members", headers=key_headers)
220
+ assert list_resp.status_code == 200
221
+
222
+ put_resp = client.put(
223
+ f"/api/v1/projects/{project_id}/members/{owner_id}",
224
+ headers=key_headers,
225
+ json={"role": "owner", "is_active": True},
226
+ )
227
+ assert put_resp.status_code == 403
228
+
229
+ def test_list_members_forbidden_for_non_member(self, client: TestClient):
230
+ owner_id, owner_headers = _register_and_login(client, "owner-forbidden@example.com")
231
+ _, outsider_headers = _register_and_login(client, "outsider@example.com")
232
+ project_id = _create_project(client)
233
+
234
+ bootstrap_owner = client.put(
235
+ f"/api/v1/projects/{project_id}/members/{owner_id}",
236
+ headers=owner_headers,
237
+ json={"role": "owner", "is_active": True},
238
+ )
239
+ assert bootstrap_owner.status_code == 200
240
+
241
+ forbidden = client.get(
242
+ f"/api/v1/projects/{project_id}/members",
243
+ headers=outsider_headers,
244
+ )
245
+ assert forbidden.status_code == 403
246
+
247
+ def test_list_members_missing_project_returns_404(self, client: TestClient):
248
+ _, headers = _register_and_login(client, "owner-missing-project@example.com")
249
+ resp = client.get("/api/v1/projects/99999/members", headers=headers)
250
+ assert resp.status_code == 404
251
+
252
+ def test_delete_missing_member_returns_404(self, client: TestClient):
253
+ owner_id, owner_headers = _register_and_login(client, "owner-delete-missing@example.com")
254
+ project_id = _create_project(client)
255
+
256
+ bootstrap_owner = client.put(
257
+ f"/api/v1/projects/{project_id}/members/{owner_id}",
258
+ headers=owner_headers,
259
+ json={"role": "owner", "is_active": True},
260
+ )
261
+ assert bootstrap_owner.status_code == 200
262
+
263
+ delete_missing = client.delete(
264
+ f"/api/v1/projects/{project_id}/members/99999",
265
+ headers=owner_headers,
266
+ )
267
+ assert delete_missing.status_code == 404
268
+
269
+ def test_upsert_existing_member_updates_role(self, client: TestClient):
270
+ owner_id, owner_headers = _register_and_login(client, "owner-update-member@example.com")
271
+ actor_id, _ = _register_and_login(client, "actor-update-member@example.com")
272
+ project_id = _create_project(client)
273
+
274
+ bootstrap_owner = client.put(
275
+ f"/api/v1/projects/{project_id}/members/{owner_id}",
276
+ headers=owner_headers,
277
+ json={"role": "owner", "is_active": True},
278
+ )
279
+ assert bootstrap_owner.status_code == 200
280
+
281
+ add_actor = client.put(
282
+ f"/api/v1/projects/{project_id}/members/{actor_id}",
283
+ headers=owner_headers,
284
+ json={"role": "viewer", "is_active": True},
285
+ )
286
+ assert add_actor.status_code == 200
287
+ assert add_actor.json()["role"] == "viewer"
288
+
289
+ update_actor = client.put(
290
+ f"/api/v1/projects/{project_id}/members/{actor_id}",
291
+ headers=owner_headers,
292
+ json={"role": "admin", "is_active": True},
293
+ )
294
+ assert update_actor.status_code == 200
295
+ assert update_actor.json()["role"] == "admin"
@@ -1,51 +0,0 @@
1
- from fastapi import APIRouter, Depends, HTTPException, status
2
- from sqlalchemy.orm import Session
3
-
4
- from app.database import get_db
5
- from app.models.project import Project
6
- from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
7
-
8
- router = APIRouter(prefix="/projects", tags=["Projects"])
9
-
10
-
11
- @router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
12
- def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
13
- project = Project(**payload.model_dump())
14
- db.add(project)
15
- db.commit()
16
- db.refresh(project)
17
- return project
18
-
19
-
20
- @router.get("/", response_model=list[ProjectResponse])
21
- def list_projects(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)):
22
- return db.query(Project).offset(skip).limit(limit).all()
23
-
24
-
25
- @router.get("/{project_id}", response_model=ProjectResponse)
26
- def get_project(project_id: int, db: Session = Depends(get_db)):
27
- project = db.query(Project).filter(Project.id == project_id).first()
28
- if not project:
29
- raise HTTPException(status_code=404, detail="Project not found")
30
- return project
31
-
32
-
33
- @router.patch("/{project_id}", response_model=ProjectResponse)
34
- def update_project(project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db)):
35
- project = db.query(Project).filter(Project.id == project_id).first()
36
- if not project:
37
- raise HTTPException(status_code=404, detail="Project not found")
38
- for field, value in payload.model_dump(exclude_none=True).items():
39
- setattr(project, field, value)
40
- db.commit()
41
- db.refresh(project)
42
- return project
43
-
44
-
45
- @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
46
- def delete_project(project_id: int, db: Session = Depends(get_db)):
47
- project = db.query(Project).filter(Project.id == project_id).first()
48
- if not project:
49
- raise HTTPException(status_code=404, detail="Project not found")
50
- db.delete(project)
51
- db.commit()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes