Skip to content

Commit

Permalink
Merge branch 'feature/presign-configuration-version-internal' into 'm…
Browse files Browse the repository at this point in the history
…ain'

fix: Use pre-signed keys for API authentication to upload configuration version

Closes #177 and #161

See merge request pub/terra/terrarun!91
  • Loading branch information
MatthewJohn committed Aug 2, 2024
2 parents a7fb0b8 + 9caee80 commit 6bfdf76
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 22 deletions.
20 changes: 13 additions & 7 deletions terrarun/models/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@
# SPDX-License-Identifier: GPL-2.0

import os
import subprocess
from enum import Enum
from typing import Optional
from tarfile import TarFile
from tempfile import NamedTemporaryFile, TemporaryDirectory, TemporaryFile
from tempfile import NamedTemporaryFile, TemporaryDirectory

import sqlalchemy
import sqlalchemy.orm
from terrarun.api_error import ApiError
from terrarun.api_request import ApiRequest
import terrarun.config
from terrarun.logger import get_logger

from terrarun.models.base_object import BaseObject
Expand All @@ -21,6 +20,9 @@
import terrarun.models.run
import terrarun.models.workspace
from terrarun.object_storage import ObjectStorage
import terrarun.presign
import terrarun.models.user
import terrarun.models.run_queue


logger = get_logger(__name__)
Expand Down Expand Up @@ -343,11 +345,15 @@ def can_create_run(self, speculative):
# Allow run
return True

def get_upload_url(self):
def get_upload_url(self, effective_user: terrarun.models.user.User):
"""Return URL for terraform to upload configuration."""
return f'{terrarun.config.Config().BASE_URL}/api/v2/upload-configuration/{self.api_id}'

upload_path = f'/api/v2/upload-configuration/{self.api_id}'

url_generator = terrarun.presign.PresignedUrlGenerator()
return url_generator.create_url(effective_user, upload_path)

def get_api_details(self, api_request: ApiRequest=None):
def get_api_details(self, effective_user: terrarun.models.user.User, api_request: Optional[ApiRequest] = None):
"""Return API details."""

if api_request and \
Expand All @@ -366,7 +372,7 @@ def get_api_details(self, api_request: ApiRequest=None):
"speculative": self.speculative,
"status": self.status.value,
"status-timestamps": {},
"upload-url": self.get_upload_url()
"upload-url": self.get_upload_url(effective_user=effective_user)
},
"relationships": {
"ingress-attributes": {
Expand Down
4 changes: 2 additions & 2 deletions terrarun/models/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ def plan(self) -> Optional['terrarun.models.plan.Plan']:
return self.plans[-1]
return None

def get_api_details(self, api_request: ApiRequest=None):
def get_api_details(self, effective_user: terrarun.models.user.User, api_request: ApiRequest | None = None):
"""Return API details."""
# Get status change audit events
session = Database.get_session()
Expand All @@ -524,7 +524,7 @@ def get_api_details(self, api_request: ApiRequest=None):

# @TODO Remove check for api_request object once all APIs use this methodology
if api_request and api_request.has_include(ApiRequest.Includes.CONFIGURATION_VERSION) and self.configuration_version:
api_request.add_included(self.configuration_version.get_api_details(api_request))
api_request.add_included(self.configuration_version.get_api_details(api_request=api_request, effective_user=effective_user))

return {
"id": self.api_id,
Expand Down
88 changes: 87 additions & 1 deletion terrarun/presign.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@


import base64
import json
from dataclasses import dataclass
from datetime import datetime

from cryptography.fernet import Fernet
from werkzeug.wrappers.request import Request

import terrarun.config
from terrarun.models.user import User
from terrarun.utils import datetime_from_json


class Presign:
"""Interface to encrypt/decrypt pre-sign keys"""
Expand All @@ -20,10 +28,88 @@ def fernet(self):
def encrypt(self, input):
"""Encrypt token"""
return self.fernet.encrypt(input.encode()).hex()

def decrypt(self, input):
"""Decrypt token"""
try:
return self.fernet.decrypt(bytes.fromhex(input)).decode()
except ValueError:
return None


@dataclass
class RequestSignature:
"""Class containing the request signature data."""

user_id: str
path: str
created_at: datetime

def serialize(self) -> str:
data = {
"user_id": self.user_id,
"path": self.path,
"created_at": self.created_at.isoformat(),
}
return json.dumps(data)

@staticmethod
def deserialise(serialized: str):
data = json.loads(serialized)

if data is None:
return None

return RequestSignature(
user_id=data.get("user_id"),
path=data.get("path"),
created_at=datetime_from_json(data.get("created_at")),
)


class PresignedUrlGenerator:
"""Interface to sign and verify requests"""

ARG_NAME = "sigkey"

def create_url(self, effective_user: User, path: str) -> str:
"""Return signature for the url"""

signature_data = RequestSignature(effective_user.id, path, datetime.now())

presign = Presign()
signature = presign.encrypt(signature_data.serialize())

return f"{terrarun.config.Config().BASE_URL}{path}?{self.ARG_NAME}={signature}"


class PresignedRequestValidatorError(Exception):
pass


class PresignedRequestValidator:
def validate(self, request: Request) -> str:
"""Verify the request and return the effective user id"""

signature_list = request.args.getlist(PresignedUrlGenerator.ARG_NAME)
if len(signature_list) < 1:
raise PresignedRequestValidatorError("Signature not found.")
if len(signature_list) > 1:
raise PresignedRequestValidatorError("Multiple signatures found.")

presign = Presign()
signature_data_json = presign.decrypt(signature_list[0])

if signature_data_json is None:
raise PresignedRequestValidatorError("Failed to decode signature.")

signature_data = RequestSignature.deserialise(signature_data_json)
if signature_data is None:
raise PresignedRequestValidatorError("Failed to parse signature data.")

if signature_data.path != request.path:
raise PresignedRequestValidatorError("Signature path does not match.")

# @TODO Check the creation date and add a time limit

return signature_data.user_id
29 changes: 17 additions & 12 deletions terrarun/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from terrarun.job_processor import JobProcessor
import terrarun.config
from terrarun.models import workspace
import terrarun.models
from terrarun.models.agent import Agent, AgentStatus
from terrarun.models.agent_pool import AgentPool
from terrarun.models.agent_token import AgentToken
Expand All @@ -34,6 +35,7 @@
from terrarun.models.oauth_token import OauthToken
from terrarun.models.project import Project
from terrarun.models.organisation import Organisation
import terrarun.models.run_queue
from terrarun.models.state_version_output import StateVersionOutput
from terrarun.models.tool import Tool, ToolType
from terrarun.permissions.organisation import OrganisationPermissions
Expand All @@ -58,9 +60,11 @@
from terrarun.presign import Presign
from terrarun.api_error import ApiError, api_error_response
from terrarun.server.authenticated_endpoint import AuthenticatedEndpoint
from terrarun.server.signature_authenticated_endpoint import SignatureAuthenticatedEndpoint
from terrarun.server.route_registration import RouteRegistration
from terrarun.server.routes import *
from terrarun.logger import get_logger
from terrarun.api_entities.base_entity import ApiErrorView
from terrarun.api_entities.organization import (
OrganizationUpdateEntity, OrganizationView, OrganizationCreateEntity,
OrganisationListView
Expand Down Expand Up @@ -1780,7 +1784,7 @@ def _get(self, workspace_id, current_user, current_job):
)

for configuration_version in workspace.get_configuration_versions(api_request):
api_request.set_data(configuration_version.get_api_details(api_request))
api_request.set_data(configuration_version.get_api_details(effective_user=current_user, api_request=api_request))

return api_request.get_response()

Expand Down Expand Up @@ -1811,7 +1815,7 @@ def _post(self, workspace_id, current_user, current_job):
auto_queue_runs=attributes.get('auto-queue-runs', True),
speculative=attributes.get('speculative', False)
)
api_request.set_data(cv.get_api_details())
api_request.set_data(cv.get_api_details(effective_user=current_user))

return api_request.get_response()

Expand Down Expand Up @@ -1891,24 +1895,25 @@ def _get(self, configuration_version_id, current_user, current_job):
return {}, 404

api_request = ApiRequest(request)
api_request.set_data(cv.get_api_details())
api_request.set_data(cv.get_api_details(effective_user=current_user))
return api_request.get_response()


class ApiTerraformConfigurationVersionUpload(AuthenticatedEndpoint):
class ApiTerraformConfigurationVersionUpload(SignatureAuthenticatedEndpoint):
"""Configuration version upload endpoint"""

def check_permissions_put(self, current_user, current_job, configuration_version_id):
"""Check permissions"""
cv = ConfigurationVersion.get_by_api_id(configuration_version_id)
if not cv:
return False
return WorkspacePermissions(current_user=current_user,
workspace=cv.workspace).check_access_type(
runs=TeamWorkspaceRunsPermission.PLAN)

wp = WorkspacePermissions(current_user=current_user, workspace=cv.workspace)
return wp.check_access_type(runs=TeamWorkspaceRunsPermission.PLAN)

def _put(self, configuration_version_id, current_user, current_job):
def _put(self, current_user, current_job, configuration_version_id):
"""Handle upload of configuration version data."""

cv = ConfigurationVersion.get_by_api_id(configuration_version_id)
if not cv:
return {}, 404
Expand Down Expand Up @@ -1967,7 +1972,7 @@ def _get(self, current_user, current_job, run_id=None):
run = Run.get_by_api_id(run_id)
if not run:
return {}, 404
return {"data": run.get_api_details()}
return {"data": run.get_api_details(effective_user=current_user)}

def check_permissions_post(self, current_user, current_job, run_id=None,):
"""Check permissions to view run"""
Expand Down Expand Up @@ -2070,7 +2075,7 @@ def _post(self, current_user, current_job, run_id=None):
api_request.add_error(exc)
return api_request.get_response()

return {"data": run.get_api_details()}
return {"data": run.get_api_details(effective_user=current_user, api_request=api_request)}


class ApiTerraformRunRunEvents(AuthenticatedEndpoint):
Expand Down Expand Up @@ -2197,7 +2202,7 @@ def _get(self, workspace_id, current_user, current_job):
api_request = ApiRequest(request, list_data=True)

for run in workspace.runs:
api_request.set_data(run.get_api_details(api_request))
api_request.set_data(run.get_api_details(effective_user=current_user, api_request=api_request))

return api_request.get_response()

Expand Down Expand Up @@ -2238,7 +2243,7 @@ def _get(self, organisation_name, current_user, current_job):
if not organisation:
return {}, 404

return {"data": [run.get_api_details() for run in organisation.get_run_queue()]}
return {"data": [run.get_api_details(effective_user=current_user) for run in organisation.get_run_queue()]}


class ApiTerraformOrganisationOauthClients(AuthenticatedEndpoint):
Expand Down
31 changes: 31 additions & 0 deletions terrarun/server/signature_authenticated_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright (C) 2024 Matt Comben - All Rights Reserved
# SPDX-License-Identifier: GPL-2.0


from typing import Tuple

from flask import request

from terrarun.logger import get_logger
from terrarun.models.user import User
from terrarun.presign import PresignedRequestValidator, PresignedRequestValidatorError
from terrarun.server.authenticated_endpoint import AuthenticatedEndpoint

logger = get_logger(__name__)


class SignatureAuthenticatedEndpoint(AuthenticatedEndpoint):
"""Authenticated endpoint"""

def _get_current_user(self) -> Tuple[User | None, None]:
"""Verify the signature and return the effective user"""

try:
user_id = PresignedRequestValidator().validate(request)
except PresignedRequestValidatorError as e:
logger.warning("Failed to authenticate with signature. Error: %s", e)
return None, None

effective_user: User | None = User.get_by_id(user_id)

return effective_user, None

0 comments on commit 6bfdf76

Please sign in to comment.