Add project-level authorization checks

Authorization:
- Add AuthorizationService for checking project access
- Implement get_user_access_level() with admin, owner, and permission checks
- Add check_project_access() helper for route handlers
- Add grant_access() and revoke_access() methods
- Add ProjectAccessChecker dependency class

Routes:
- Add authorization checks to project CRUD (read, update, delete)
- Add authorization checks to package create
- Add authorization checks to upload endpoint (requires write)
- Add authorization checks to download endpoint (requires read)
- Add authorization checks to tag create

Tests:
- Fix pagination flakiness in test_list_projects
- Fix pagination flakiness in test_projects_search
- Add API key authentication to concurrent upload test
This commit is contained in:
Mondo Diaz
2026-01-08 16:20:42 -06:00
parent b1c17e8ab7
commit d61c7a71fb
5 changed files with 316 additions and 37 deletions

View File

@@ -371,6 +371,8 @@ from .auth import (
validate_password_strength,
PasswordTooShortError,
MIN_PASSWORD_LENGTH,
check_project_access,
AuthorizationService,
)
@@ -1064,10 +1066,13 @@ def create_project(
@router.get("/api/v1/projects/{project_name}", response_model=ProjectResponse)
def get_project(project_name: str, db: Session = Depends(get_db)):
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
def get_project(
project_name: str,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""Get a single project by name. Requires read access for private projects."""
project = check_project_access(db, project_name, current_user, "read")
return project
@@ -1077,13 +1082,11 @@ def update_project(
project_update: ProjectUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""Update a project's metadata."""
user_id = get_user_id(request)
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
"""Update a project's metadata. Requires admin access."""
project = check_project_access(db, project_name, current_user, "admin")
user_id = current_user.username if current_user else get_user_id(request)
# Track changes for audit log
changes = {}
@@ -1130,14 +1133,16 @@ def delete_project(
project_name: str,
request: Request,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""
Delete a project and all its packages.
Delete a project and all its packages. Requires admin access.
Decrements ref_count for all artifacts referenced by tags in all packages
within this project.
"""
user_id = get_user_id(request)
check_project_access(db, project_name, current_user, "admin")
user_id = current_user.username if current_user else get_user_id(request)
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
@@ -1453,10 +1458,10 @@ def create_package(
package: PackageCreate,
request: Request,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
):
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
"""Create a new package in a project. Requires write access."""
project = check_project_access(db, project_name, current_user, "write")
# Validate format
if package.format not in PACKAGE_FORMATS:
@@ -1680,14 +1685,12 @@ def upload_artifact(
- Authorization: Bearer <api-key> for authentication
"""
start_time = time.time()
user_id = get_user_id_from_request(request, db, current_user)
settings = get_settings()
storage_result = None
# Get project and package
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Check authorization (write access required for uploads)
project = check_project_access(db, project_name, current_user, "write")
user_id = current_user.username if current_user else get_user_id_from_request(request, db, current_user)
package = (
db.query(Package)
@@ -2312,6 +2315,7 @@ def download_artifact(
request: Request,
db: Session = Depends(get_db),
storage: S3Storage = Depends(get_storage),
current_user: Optional[User] = Depends(get_current_user_optional),
range: Optional[str] = Header(None),
mode: Optional[Literal["proxy", "redirect", "presigned"]] = Query(
default=None,
@@ -2347,10 +2351,8 @@ def download_artifact(
"""
settings = get_settings()
# Get project and package
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Check authorization (read access required for downloads)
project = check_project_access(db, project_name, current_user, "read")
package = (
db.query(Package)
@@ -2568,10 +2570,8 @@ def get_artifact_url(
"""
settings = get_settings()
# Get project and package
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Check authorization (read access required for downloads)
project = check_project_access(db, project_name, current_user, "read")
package = (
db.query(Package)
@@ -2826,12 +2826,11 @@ def create_tag(
tag: TagCreate,
request: Request,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
):
user_id = get_user_id(request)
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
"""Create or update a tag. Requires write access."""
project = check_project_access(db, project_name, current_user, "write")
user_id = current_user.username if current_user else get_user_id(request)
package = (
db.query(Package)