diff --git a/src/api/deployments.py b/src/api/deployments.py index 3cf4aa0..6b67ed2 100644 --- a/src/api/deployments.py +++ b/src/api/deployments.py @@ -297,9 +297,11 @@ async def create_deployment( Created deployment with status QUEUED """ service = DeploymentService(db) + is_admin = UserRole.ADMIN.value in user.get("roles", []) deployment = service.create_deployment( deployment_data, - request_id=request_id + request_id=request_id, + is_admin=is_admin, ) # Convert SQLAlchemy model to response schema diff --git a/src/services/deployment_service.py b/src/services/deployment_service.py index 87666c7..1930743 100644 --- a/src/services/deployment_service.py +++ b/src/services/deployment_service.py @@ -30,7 +30,7 @@ def __init__(self, db: Session): self.openstack_repo = OpenstackProjectRepository(db) self.log_service = DeploymentLogService(db) - def create_deployment(self, deployment_data: DeploymentCreate, request_id: Union[str, None] = None) -> Deployment: + def create_deployment(self, deployment_data: DeploymentCreate, request_id: Union[str, None] = None, is_admin: bool = False) -> Deployment: """Create a new deployment and trigger async deployment task. Args: @@ -79,14 +79,20 @@ def create_deployment(self, deployment_data: DeploymentCreate, request_id: Union f"Teacher user not found for Keycloak ID {deployment_data.teacher.id}" ) - # Gate: private templates can only be deployed by their owner. Admins - # and other lecturers cannot run private templates even if they + # Gate: private templates can only be deployed by their owner OR by + # an admin. Other lecturers cannot run private templates even if they # somehow obtained the template_version_id — that's the whole point of - # "private". For public templates the visibility/approval system - # already controls who sees the template at all, no extra gate here. - if template.visibility != TemplateVisibility.PUBLIC and template.owner_id != teacher_user.id: + # "private". Admins are the system-wide bypass for management actions + # (delete/edit) and we extend the same trust to running deploys. For + # public templates the visibility/approval system already controls + # who sees the template at all, no extra gate here. + if ( + template.visibility != TemplateVisibility.PUBLIC + and template.owner_id != teacher_user.id + and not is_admin + ): raise ForbiddenException( - "Only the template owner can deploy a private template version" + "Only the template owner or an admin can deploy a private template version" ) # Validate template parameters required by the template version diff --git a/tests/unit/test_approval_only_for_public.py b/tests/unit/test_approval_only_for_public.py index 304552a..6b5a416 100644 --- a/tests/unit/test_approval_only_for_public.py +++ b/tests/unit/test_approval_only_for_public.py @@ -326,11 +326,13 @@ def test_other_lecturer_cannot_deploy_private_template(self): attacker_user.id = attacker_local attacker_user.external_id = attacker_kc - # Verify the gate logic itself — same expression as deployment_service:86. - # If visibility is private AND owner != caller -> forbidden. + # Verify the gate logic itself — same expression as deployment_service: + # private AND not-owner AND not-admin -> forbidden. + is_admin = False is_blocked = ( template.visibility != TemplateVisibility.PUBLIC and template.owner_id != attacker_user.id + and not is_admin ) assert is_blocked is True @@ -347,9 +349,31 @@ def test_owner_can_deploy_own_private_template(self): owner_user.external_id = owner_kc # Gate evaluates to "not blocked". + is_admin = False is_blocked = ( template.visibility != TemplateVisibility.PUBLIC and template.owner_id != owner_user.id + and not is_admin + ) + assert is_blocked is False + + def test_admin_can_deploy_private_template_of_other_owner(self): + """Admins bypass the owner-only gate on private templates — same + admin-trust model used for delete/edit elsewhere in the service.""" + from src.models.user import User + + template = _make_template( + TemplateVisibility.PRIVATE, owner_id=str(uuid4()), versions=[] + ) + admin_user = User() + admin_user.id = str(uuid4()) # NOT the owner + admin_user.external_id = "admin-keycloak-id" + + is_admin = True + is_blocked = ( + template.visibility != TemplateVisibility.PUBLIC + and template.owner_id != admin_user.id + and not is_admin ) assert is_blocked is False @@ -357,9 +381,11 @@ def test_public_template_gate_does_not_apply(self): """For public templates the owner-check is irrelevant; visibility + approval drive who can see/deploy what.""" template = _make_template(TemplateVisibility.PUBLIC, owner_id="someone-else", versions=[]) + is_admin = False is_blocked = ( template.visibility != TemplateVisibility.PUBLIC and template.owner_id != "some-attacker" + and not is_admin ) assert is_blocked is False