blob: e56ffe030f10574cfc96ed6c9c4a0dc51ea500e6 [file] [log] [blame]
import typing
from common.django_utils import amap
from ninja.errors import HttpError
from trackingserver_auth.models import APIKey, Team
from trackingserver_base.permissions.base import allowed
from trackingserver_projects.models import Project, ProjectTeamMembership, ProjectUserMembership
from trackingserver_projects.schema import ProjectIn, ProjectUpdate, Visibility
from trackingserver_run_tracking.models import DAGRun
from trackingserver_template.models import DAGTemplate
"""The basic security design is as follows:
1. [Authentication] The user is authenticated, either by:
a. A valid API key
b. A valid JWT token (with Auth: bearer)
2. [Permission] We have a check on every API endpoint that asks if the user has permission
to access the endpoint with any provided parameters. This is done by the decorator @permission,
which takes a function that takes the request and any parameters and returns whether or not
the user has access permission to any in there
3. [Visibility] Inside any "get" endpoints, we only access the items to which the user has visibility.
This is cumbersome, but multiple layers will help for now, and are easy to implement.
TODO -- add a test that every endpoint is decorated with @permission.
"""
# Curently any user can create a project
# This might change as we have more complex billing/auth scheme
# Filtering is done in the endpoint as
# it is a filtering operation rather than a permission operation
# E.G. all users can grab projects they own, so there's nothing
# worth filtering from the endpoint parameters
user_can_get_projects = allowed
user_can_get_api_keys = allowed
# All users can grab their own API keys/create their own
user_can_get_whoami = allowed
user_can_phone_home = allowed
user_can_create_api_key = allowed
async def user_can_delete_api_key(request: typing.Any, api_key_id: int) -> typing.Tuple[bool, str]:
"""Checks if a user can delete an API key.
@param request:
@return:
"""
error = f"Could not delete API key: {api_key_id}"
user, _ = request.auth
try:
api_key = await APIKey.objects.aget(id=api_key_id)
except APIKey.DoesNotExist:
return False, error
if api_key.user_id != user.id:
return False, error
return True, ""
async def _visibility_valid_for_user(request, visibility: Visibility) -> bool:
# TODO -- double-check that the user is contained within the visibility or a team thereof
user, orgs = request.auth
if "dagworks" in {org.name.lower() for org in orgs}:
return True
public_org = await Team.objects.aget(name="Public")
visibility = await visibility.resolve_ids()
for org in visibility.team_ids_visible + visibility.team_ids_writable:
if org == public_org.id:
return False
return True
async def user_can_create_project(request, project: ProjectIn) -> typing.Tuple[bool, str]:
"""Any user can create a project. They just have to be logged in.
The only exception is if they have "public" on their project, then it can't be created
unless they're a DAGWorks member.
@param request:
@return:
"""
visibility_valid = await _visibility_valid_for_user(request, project.visibility)
if not visibility_valid:
return False, "Cannot create/edit a project with public visibility"
return True, ""
async def user_project_visibility(request, project: Project) -> typing.Optional[str]:
user_memberships = await amap(
lambda membership: membership.role,
ProjectUserMembership.objects.filter(project=project.id, user=request.auth[0]).all(),
)
public_team = await Team.objects.aget(name="Public")
team_memberships = await amap(
lambda membership: membership.role,
ProjectTeamMembership.objects.filter(
project=project.id, team__in=request.auth[1] + [public_team]
).all(),
)
if "write" in team_memberships or "write" in user_memberships:
return "write"
if "read" in team_memberships or "read" in user_memberships:
return "read"
return None
async def user_can_get_project_by_id(request, project_id: int) -> typing.Tuple[bool, str]:
"""Checks if a user can get a project by ID, given a certain ID
@param request:
@param project_id:
@return:
"""
error_message = f"Could not find project with ID: {project_id}"
try:
project = await Project.objects.aget(id=project_id)
except Project.DoesNotExist:
return False, error_message
visibility = await user_project_visibility(request, project)
if visibility is None:
return False, error_message
return True, ""
async def user_can_write_to_project(request, project_id: int):
error_message = f"Could not update project with ID: {project_id}"
try:
project_in_db = await Project.objects.aget(id=project_id)
except Project.DoesNotExist:
return False, error_message
visibility = await user_project_visibility(request, project_in_db)
if visibility != "write":
return False, error_message
return True, ""
async def user_can_update_project(
request, project: ProjectUpdate, project_id: int
) -> typing.Tuple[bool, str]:
"""Checks if a user can get a project by ID, given a certain ID
@param request: Request with auth information
@param project_id: Project ID to update
@return: Tuple of (can_update, error_message)
"""
can_edit, message = await user_can_write_to_project(request, project_id)
if not can_edit:
return False, message
if project.visibility is not None:
visibility_valid = await _visibility_valid_for_user(request, project.visibility)
if not visibility_valid:
return False, "Invalid project visibility settings"
return True, ""
async def user_can_get_dag_template(
request: typing.Any, dag_template_ids: str
) -> typing.Tuple[bool, str]:
"""Tells whether or not the user can get a DAG template. This is only visible
iff the project with which the corresponding project version is associated is visible.
@param request:
@param dag_template_id:
@return:
"""
dag_template_ids_parsed = [int(item) for item in dag_template_ids.split(",")]
templates = [item async for item in DAGTemplate.objects.filter(id__in=dag_template_ids_parsed)]
# TODO -- make this call more efficient.
# We should have a bulk project version read that does one DB call?
for template in templates:
can_read_project_version, message = await user_can_get_project_by_id(
request, template.project_id
)
if not can_read_project_version:
return False, f"Could not find DAG template with ID: {dag_template_ids}"
return True, ""
async def user_can_update_dag_template(request: typing.Any, dag_template_id: int):
dag_template = await DAGTemplate.objects.aget(id=dag_template_id)
can_write_project, message = await user_can_write_to_project(request, dag_template.project_id)
if not can_write_project:
return False, f"Could not write to DAG template with ID: {dag_template_id}"
return True, ""
async def user_can_get_dag_templates(
request: typing.Any, project_id: int
) -> typing.Tuple[bool, str]:
if project_id is not None:
can_read_project, message = await user_can_get_project_by_id(request, project_id)
if not can_read_project:
return False, f"Could not find project with ID: {project_id}"
return True, ""
async def user_can_write_to_dag_template(request: typing.Any, dag_template_id: int):
"""Tells whether or not the user can update a DAG template. This is true
iff the user can write to the associated template's project version's project.
@param request: Django request
@param dag_template_id: DAG Template ID in question
@return: Tuple of (can_write, error_message)
"""
templates = [item async for item in DAGTemplate.objects.filter(id=dag_template_id)]
if len(templates) == 0:
return False, f"Could not write to DAG template with ID: {dag_template_id}"
(template,) = templates
can_write_project, message = await user_can_write_to_project(request, template.project_id)
if not can_write_project:
return False, f"Could not write to DAG template with ID: {dag_template_id}"
return True, ""
async def user_can_get_dag_runs(request: typing.Any, dag_run_ids: str):
"""Tells whether or not the user can update a DAG run. This is true
iff the user can write to the associated template's project version's project.
@param request: Django request
@param dag_run_id: DAG run ID in question
@return: Tuple of (can_write, error_message)
"""
dag_run_ids_parsed = [int(item) for item in dag_run_ids.split(",")]
dag_runs = [
item
async for item in DAGRun.objects.filter(id__in=dag_run_ids_parsed).prefetch_related(
"dag_template"
)
]
if len(dag_runs) != len(dag_run_ids_parsed):
return False, f"Could not read DAG run with ID: {dag_runs}"
# we could make this more efficient but its fine for now
project_ids = {dag_run.dag_template.project_id for dag_run in dag_runs}
if len(project_ids) != 1:
raise HttpError(422, "DAG runs must be from the same project")
(project_id,) = list(project_ids)
can_write_project, message = await user_can_get_project_by_id(request, project_id)
if not can_write_project:
return False, f"Could not write to DAG run with ID: {dag_run_ids_parsed}"
return True, ""
async def user_can_write_to_dag_run(request: typing.Any, dag_run_id: int):
"""Tells whether or not the user can update a DAG run. This is true
iff the user can write to the associated template's project version's project.
@param request: Django request
@param dag_run_id: DAG run ID in question
@return: Tuple of (can_write, error_message)
"""
dag_runs = [
item async for item in DAGRun.objects.filter(id=dag_run_id).prefetch_related("dag_template")
]
if len(dag_runs) == 0:
return False, f"Could not write to DAG run with ID: {dag_run_id}"
(dag_run,) = dag_runs
can_write_project, message = await user_can_write_to_project(
request, dag_run.dag_template.project_id
)
if not can_write_project:
return False, f"Could not write to DAG run with ID: {dag_run_id}"
return True, ""
async def user_can_get_latest_dag_runs(
request: typing.Any,
project_id: int = None,
dag_template_id: int = None,
):
"""Tells whether or not the user can get a list of latest DAG runs. This is true
if the user can get the associated project, project version, or DAG template
(the one that is specified by the request).
@param request: Django request
@param project_id: Project ID in question
@param project_version_id: Project version ID in question
@param dag_template_id: DAG template ID in question
@return: Tuple of (can_get, error_message)
"""
if project_id is not None:
can_read_project, message = await user_can_get_project_by_id(request, project_id)
if not can_read_project:
return False, f"Could not find project with ID: {project_id}"
if dag_template_id is not None:
can_read_dag_template, message = await user_can_get_dag_template(
request, str(dag_template_id)
)
if not can_read_dag_template:
return False, f"Could not find DAG template with ID: {dag_template_id}"
return True, ""