Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions azure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ requiredVersions:
azd: '>=1.18.2 != 1.23.9'

hooks:
preprovision:
windows:
run: |
Write-Host ""
Write-Host " ---------------------------------------------------------------------------------------------------" -ForegroundColor Yellow
Write-Host " Note: If deployment fails or you encounter an issue, please open an issue with the deployment logs." -ForegroundColor Yellow
Write-Host " https://github.com/microsoft/Container-Migration-Solution-Accelerator/issues" -ForegroundColor Cyan
Write-Host " ---------------------------------------------------------------------------------------------------" -ForegroundColor Yellow
Write-Host ""
shell: pwsh
interactive: true
posix:
run: |
printf '\n'
printf '\033[33m ---------------------------------------------------------------------------------------------------\033[0m\n'
printf '\033[33m Note: If deployment fails or you encounter an issue, please open an issue with the deployment logs.\033[0m\n'
printf '\033[36m https://github.com/microsoft/Container-Migration-Solution-Accelerator/issues\033[0m\n'
printf '\033[33m ---------------------------------------------------------------------------------------------------\033[0m\n'
printf '\n'
shell: sh
interactive: true
postdeploy:
posix:
shell: sh
Expand Down
6 changes: 3 additions & 3 deletions src/backend-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ dependencies = [
"httpx==0.28.1",
"pydantic-settings==2.13.1",
"python-dotenv==1.2.2",
"python-multipart==0.0.27",
"python-multipart==0.0.31",
"protobuf==7.34.0",
"sas-cosmosdb==0.1.5",
"semantic-kernel[azure]==1.41.1",
Expand All @@ -34,14 +34,14 @@ omit = ["src/tests/*"]
override-dependencies = [
"av==16.0.0",
"starlette==0.49.1",
"aiohttp==3.13.4",
"aiohttp==3.14.1",
"azure-core==1.38.0",
"urllib3==2.7.0",
"requests==2.33.0",
"werkzeug==3.1.6",
"pygments==2.20.0",
"black==26.3.1",
"cryptography==46.0.7",
"cryptography==48.0.1",
"pyjwt==2.12.0",
"pyopenssl==26.0.0",
"idna==3.15",
Expand Down
45 changes: 45 additions & 0 deletions src/backend-api/src/app/libs/services/authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from fastapi import HTTPException

from libs.base.typed_fastapi import TypedFastAPI
from libs.models.entities import Process
from libs.repositories.process_repository import ProcessRepository


async def verify_process_ownership(
app: TypedFastAPI, process_id: str, user_id: str
) -> Process:
"""Ensure the authenticated caller owns the requested process.

Fetches the Process entity and compares its stored ``user_id`` against the
authenticated caller. A missing process and a process owned by a different
user are treated identically and both raise a 404, so callers cannot probe
for the existence of other users' processes.

Args:
app: The FastAPI application carrying the dependency-injection context.
process_id: The identifier of the process being accessed.
user_id: The authenticated caller's user principal id.

Returns:
The owned Process entity.

Raises:
HTTPException: 404 if the process does not exist or is not owned by the
authenticated caller.
"""
if not process_id:
raise HTTPException(status_code=400, detail="Process ID is required")

if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

async with app.app_context.create_scope() as scope:
process_repository = scope.get_service(ProcessRepository)
process = await process_repository.get_async(process_id)

if not process or process.user_id != user_id:
# Return 404 (not 403) so the response does not confirm the existence of
# processes belonging to other users.
raise HTTPException(status_code=404, detail="Process not found")

return process
4 changes: 3 additions & 1 deletion src/backend-api/src/app/routers/router_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ async def upload_file(
processRepository = scope.get_service(ProcessRepository)
fileRepository = scope.get_service(FileRepository)

process_record = await processRepository.get_async(process_id)
from libs.services.authorization import verify_process_ownership

process_record = await verify_process_ownership(app, process_id, user_id)

file_id = str(uuid4())
file_name = re.sub(r"[^\w.-]", "_", file.filename)
Expand Down
46 changes: 46 additions & 0 deletions src/backend-api/src/app/routers/router_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from libs.models.entities import Process
from libs.repositories.process_repository import ProcessRepository
from libs.services.auth import get_authenticated_user
from libs.services.authorization import verify_process_ownership
from libs.services.interfaces import ILoggerService
from libs.services.process_services import ProcessService
from routers.models.files import FileInfo
Expand Down Expand Up @@ -83,6 +84,11 @@ async def status(process_id: str, request: Request):
f"Process router status endpoint called for process_id: {process_id}"
)

# Authenticate the caller and verify they own this process
authenticated_user = get_authenticated_user(request)
user_id = authenticated_user.user_principal_id
await verify_process_ownership(app, process_id, user_id)

# loading business component for process
processService = app.app_context.get_service(ProcessService)

Expand All @@ -99,6 +105,11 @@ async def render_status(process_id: str, request: Request):
f"Process router render status endpoint called for process_id: {process_id}"
)

# Authenticate the caller and verify they own this process
authenticated_user = get_authenticated_user(request)
user_id = authenticated_user.user_principal_id
await verify_process_ownership(app, process_id, user_id)

# loading business component for process
processService = app.app_context.get_service(ProcessService)

Expand Down Expand Up @@ -138,6 +149,9 @@ async def upload_files(
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Verify the caller owns this process before touching its blob storage
await verify_process_ownership(app, process_id, user_id)

# Make uploaded files list
uploaded_files: list[FileInfo] = []

Expand Down Expand Up @@ -195,6 +209,8 @@ async def upload_files(
response.headers["Location"] = f"/process/{process_id}/"

return result_response
except HTTPException:
raise
except Exception as e:
logger_service.log_error(f"Error in upload_files: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error uploading files: {str(e)}")
Expand Down Expand Up @@ -233,6 +249,9 @@ async def delete_file(
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Verify the caller owns this process before deleting any file
await verify_process_ownership(app, process_id, user_id)

# Get process service
processService = app.app_context.get_service(ProcessService)

Expand Down Expand Up @@ -274,6 +293,8 @@ async def delete_file(
status_code=404,
detail=f"File '{file_name}' not found for process '{process_id}'",
)
except HTTPException:
raise
except Exception as e:
logger_service.log_error(f"Error in delete_file: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
Expand Down Expand Up @@ -309,6 +330,9 @@ async def delete_process(
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Verify the caller owns this process before deleting its files
await verify_process_ownership(app, process_id, user_id)

# Get process service
processService = app.app_context.get_service(ProcessService)

Expand All @@ -333,6 +357,8 @@ async def delete_process(

return result_response

except HTTPException:
raise
except Exception as e:
logger_service.log_error(f"Error in delete_process: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error deleting process: {str(e)}")
Expand Down Expand Up @@ -365,6 +391,9 @@ async def start_processing(
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Verify the caller owns this process before queueing it for processing
await verify_process_ownership(app, process_id, user_id)

# Get process service
processService = app.app_context.get_service(ProcessService)

Expand Down Expand Up @@ -394,6 +423,8 @@ async def start_processing(
"user_id": str(user_id),
"status": "queued",
}
except HTTPException:
raise
except Exception as e:
logger_service.log_error(f"Error in start_processing: {str(e)}")
raise HTTPException(
Expand Down Expand Up @@ -425,6 +456,9 @@ async def download_process_files(
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Verify the caller owns this process before returning its files
await verify_process_ownership(app, process_id, user_id)

# Get process service
processService = app.app_context.get_service(ProcessService)

Expand Down Expand Up @@ -492,6 +526,9 @@ async def get_process_summary(
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Verify the caller owns this process before returning its summary
await verify_process_ownership(app, process_id, user_id)

# Get process service
processService = app.app_context.get_service(ProcessService)

Expand Down Expand Up @@ -549,6 +586,9 @@ async def get_file_content(
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Verify the caller owns this process before returning file content
await verify_process_ownership(app, process_id, user_id)

# Get process service
processService = app.app_context.get_service(ProcessService)

Expand Down Expand Up @@ -607,6 +647,9 @@ async def cancel_process(
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Verify the caller owns this process before forwarding the kill request
await verify_process_ownership(app, process_id, user_id)

# Get processor control URL from configuration
config = app.app_context.configuration
processor_url = config.processor_control_url or "http://processor:8080"
Expand Down Expand Up @@ -704,6 +747,9 @@ async def get_cancel_status(
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")

# Verify the caller owns this process before reading its cancel status
await verify_process_ownership(app, process_id, user_id)

# Get processor control URL from configuration
config = app.app_context.configuration
processor_url = config.processor_control_url or "http://processor:8080"
Expand Down
42 changes: 41 additions & 1 deletion src/backend-api/src/tests/routers/test_router_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ def _make_app(*, process_record=None, file_count=1, blob_helper=None):
process_repo = MagicMock()
process_repo.get_async = AsyncMock(
return_value=process_record
or SimpleNamespace(id="p-1", source_file_count=0, status="initialized")
or SimpleNamespace(
id="p-1",
user_id="user-1",
source_file_count=0,
status="initialized",
)
)
process_repo.update_async = AsyncMock(return_value=None)

Expand Down Expand Up @@ -159,3 +164,38 @@ def test_returns_500_when_blob_upload_fails(self):
headers=AUTH_HEADERS,
)
assert res.status_code == 500

def test_returns_404_when_caller_does_not_own_process(self):
# Process exists but is owned by a different user than the caller.
app, mocks = _make_app(
process_record=SimpleNamespace(
id="p-1",
user_id="someone-else",
source_file_count=0,
status="initialized",
)
)
client = TestClient(app)
res = client.post(
"/api/file/upload",
files={"file": ("a.txt", b"x", "text/plain")},
data={"process_id": VALID_PROCESS_ID},
headers=AUTH_HEADERS,
)
assert res.status_code == 404
mocks["blob_helper"].upload_blob.assert_not_awaited()
mocks["process_repo"].update_async.assert_not_awaited()

def test_returns_404_when_process_missing(self):
app, mocks = _make_app(process_record=False)
# Force get_async to return None (no such process).
mocks["process_repo"].get_async = AsyncMock(return_value=None)
client = TestClient(app)
res = client.post(
"/api/file/upload",
files={"file": ("a.txt", b"x", "text/plain")},
data={"process_id": VALID_PROCESS_ID},
headers=AUTH_HEADERS,
)
assert res.status_code == 404
mocks["blob_helper"].upload_blob.assert_not_awaited()
Loading