Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion src/api/deployments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 13 additions & 7 deletions src/services/deployment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
30 changes: 28 additions & 2 deletions tests/unit/test_approval_only_for_public.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -347,19 +349,43 @@ 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

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

Expand Down
Loading