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
3 changes: 2 additions & 1 deletion .devcontainer/Dockerfile.devcontainer
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
FROM python:3.9 as test-builder

# Install Poetry
RUN curl -sSL https://install.python-poetry.org | python3 -
RUN curl -sSL https://install.python-poetry.org | python3 - --version 2.2.1 && \
export PATH="/root/.local/bin:$PATH"

ENV PATH="${PATH}:/root/.local/bin" \
POETRY_NO_INTERACTION=1 \
Expand Down
95 changes: 95 additions & 0 deletions api/endpoints/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
from datetime import datetime
import json
from api.utils import job_queue
from api.utils import s3_access
from api.utils.http_util import err_response
from api.models.organization_s3_access import OrganizationS3Access

log = logging.getLogger(__name__)
ns = api.namespace('admin', description='Operations related to the MAAP admin')
Expand Down Expand Up @@ -201,3 +203,96 @@ def delete(self, email):
raise

return {"code": status.HTTP_200_OK, "message": "Successfully deleted {}.".format(email)}


@ns.route('/s3-access')
class S3AccessList(Resource):

@api.doc(security='ApiKeyAuth')
@login_required(role=Role.ROLE_ADMIN)
def get(self):
"""
Lists all organization S3 bucket access entries
:return:
"""
return s3_access.get_all_s3_access()

@api.doc(security='ApiKeyAuth')
@login_required(role=Role.ROLE_ADMIN)
def post(self):
"""
Create new organization S3 bucket access entry.

Format of JSON to post:
{
"org_id": 1,
"bucket_name": "my-bucket",
"bucket_prefix": "optional/prefix",
"readonly": false
}
"""

req_data = request.get_json()
if not isinstance(req_data, dict):
return err_response("Valid JSON body object required.")

org_id = req_data.get("org_id")
if not isinstance(org_id, int) or not org_id:
return err_response("Valid org_id is required.")

bucket_name = req_data.get("bucket_name", "")
if not isinstance(bucket_name, str) or not bucket_name:
return err_response("Valid bucket_name is required.")

bucket_prefix = req_data.get("bucket_prefix")
readonly = req_data.get("readonly", False)

new_entry = s3_access.create_s3_access(org_id, bucket_name, bucket_prefix, readonly)
return new_entry


@ns.route('/s3-access/<int:access_id>')
class S3AccessEntry(Resource):

@api.doc(security='ApiKeyAuth')
@login_required(role=Role.ROLE_ADMIN)
def put(self, access_id):
"""
Update organization S3 bucket access entry. Only supplied fields are updated.
"""

if not access_id:
return err_response("S3 access id is required.")

req_data = request.get_json()
if not isinstance(req_data, dict):
return err_response("Valid JSON body object required.")

access = db.session.query(OrganizationS3Access).filter_by(id=access_id).first()

if access is None:
return err_response(msg="No S3 access entry found with id " + str(access_id), code=status.HTTP_404_NOT_FOUND)

org_id = req_data.get("org_id")
bucket_name = req_data.get("bucket_name")
bucket_prefix = req_data.get("bucket_prefix")
readonly = req_data.get("readonly")

updated_entry = s3_access.update_s3_access(access, org_id, bucket_name, bucket_prefix, readonly)
return updated_entry

@api.doc(security='ApiKeyAuth')
@login_required(role=Role.ROLE_ADMIN)
def delete(self, access_id):
"""
Delete organization S3 bucket access entry
"""

access = db.session.query(OrganizationS3Access).filter_by(id=access_id).first()

if access is None:
return err_response(msg="S3 access entry does not exist", code=status.HTTP_404_NOT_FOUND)

s3_access.delete_s3_access(access_id)

return {"code": status.HTTP_200_OK, "message": "Successfully deleted S3 access entry {}.".format(access_id)}
4 changes: 2 additions & 2 deletions api/endpoints/environments.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"auth_server": "auth.uat.maap-project.org",
"mas_server": "repo.uat.maap-project.org",
"edsc_server": "ade.uat.maap-project.org:30052",
"workspace_bucket": "maap-staging-workspace",
"workspace_bucket": "maap-uat-workspace",
"default_host": false
},
{
Expand All @@ -35,7 +35,7 @@
"api_server": "api.dit.maap-project.org",
"auth_server": "auth.dit.maap-project.org",
"mas_server": "repo.dit.maap-project.org",
"workspace_bucket": "maap-staging-workspace",
"workspace_bucket": "maap-uat-workspace",
"default_host": false
},
{
Expand Down
56 changes: 12 additions & 44 deletions api/endpoints/members.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
send_user_status_update_suspended_user_email, send_user_status_change_email, \
send_welcome_to_maap_active_user_email, send_welcome_to_maap_suspended_user_email
from api.endpoints.environment import get_config_from_api
from api.utils.s3_access import build_user_s3_policy
from api.models.pre_approved import PreApproved
from datetime import datetime, timezone
import json
Expand Down Expand Up @@ -737,62 +738,29 @@ def get(self):
if not maap_user:
return Response('Unauthorized', status=401)

# Guaranteed to at least always return default
# Guaranteed to at least always return default
config = get_config_from_api(request.host)
workspace_bucket = config["workspace_bucket"]

# Allow bucket access to just the user's workspace directory
policy = f'''{{"Version": "2012-10-17",
"Statement": [
{{
"Sid": "GrantAccessToUserFolder",
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:DeleteObject",
"s3:GetObject",
"s3:PutObject",
"s3:RestoreObject",
"s3:ListMultipartUploadParts",
"s3:AbortMultipartUpload"
],
"Resource": [
"arn:aws:s3:::{workspace_bucket}/{maap_user.username}/*"
]
}},
{{
"Sid": "GrantListAccess",
"Effect": "Allow",
"Action": [
"s3:ListBucket"
],
"Resource": "arn:aws:s3:::{workspace_bucket}",
"Condition": {{
"StringLike": {{
"s3:prefix": [
"{maap_user.username}/*"
]
}}
}}
}}
]
}}'''
# Build the STS policy and collect authorized paths
policy, authorized_s3_paths = build_user_s3_policy(workspace_bucket, maap_user.username, maap_user.id)

# Call the assume_role method of the STSConnection object
assumed_role_object = sts_client.assume_role(
RoleArn=settings.WORKSPACE_BUCKET_ARN,
RoleSessionName=f'workspace-session-{maap_user.username}',
Policy=policy,
DurationSeconds=(60 * 60)
DurationSeconds=(60 * 60 * 12) # 12 hours, which is the max allowed by AWS for a custom policy with assume_role
)

response = jsonify(
aws_bucket_name=workspace_bucket,
aws_bucket_prefix=maap_user.username,
aws_access_key_id=assumed_role_object['Credentials']['AccessKeyId'],
aws_secret_access_key=assumed_role_object['Credentials']['SecretAccessKey'],
aws_session_token=assumed_role_object['Credentials']['SessionToken'],
aws_session_expiration=assumed_role_object['Credentials']['Expiration'].strftime("%Y-%m-%d %H:%M:%S%z")
credentials={
"aws_access_key_id": assumed_role_object['Credentials']['AccessKeyId'],
"aws_secret_access_key": assumed_role_object['Credentials']['SecretAccessKey'],
"aws_session_token": assumed_role_object['Credentials']['SessionToken'],
"expires_at": assumed_role_object['Credentials']['Expiration'].strftime("%Y-%m-%dT%H:%M:%S%z")
},
authorized_s3_paths=authorized_s3_paths
)

response.headers.add('Access-Control-Allow-Origin', '*')
Expand Down
16 changes: 16 additions & 0 deletions api/models/organization_s3_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from api.models import Base
from api.maap_database import db


class OrganizationS3Access(Base):
__tablename__ = 'organization_s3_access'

id = db.Column(db.Integer, primary_key=True)
org_id = db.Column(db.Integer, db.ForeignKey('organization.id'), nullable=False)
bucket_name = db.Column(db.String(), nullable=False)
bucket_prefix = db.Column(db.String(), nullable=True)
readonly = db.Column(db.Boolean(), nullable=False, default=False)
creation_date = db.Column(db.DateTime())

def __repr__(self):
return "<OrganizationS3Access(id={self.id!r})>".format(self=self)
9 changes: 9 additions & 0 deletions api/schemas/organization_s3_access_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from api.models.organization_s3_access import OrganizationS3Access
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema


class OrganizationS3AccessSchema(SQLAlchemyAutoSchema):
class Meta:
model = OrganizationS3Access
include_fk = True
load_instance = True
12 changes: 12 additions & 0 deletions api/utils/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from api.models.organization import Organization
from api.models.organization_job_queue import OrganizationJobQueue
from api.models.organization_membership import OrganizationMembership
from api.models.organization_s3_access import OrganizationS3Access
from api.schemas.organization_schema import OrganizationSchema

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -207,6 +208,17 @@ def delete_organization(org_id):
app.logger.error(f"Failed to clear job queues for organization {org_id}: {e}")
raise

# Clear S3 access entries
try:
db.session.execute(
db.delete(OrganizationS3Access).filter_by(org_id=org_id)
)
db.session.commit()
except Exception as e:
db.session.rollback()
app.logger.error(f"Failed to clear S3 access for organization {org_id}: {e}")
raise

try:
db.session.query(Organization).filter_by(id=org_id).delete()
db.session.commit()
Expand Down
Loading