Skip to content

Commit

Permalink
Merge pull request #217 from nathanegillett/api-auth
Browse files Browse the repository at this point in the history
Require authorization for upload and publish APIs [RHELDST-4729]
  • Loading branch information
negillett committed Mar 3, 2021
2 parents f9c7280 + 6c99888 commit f7e699a
Show file tree
Hide file tree
Showing 7 changed files with 86 additions and 15 deletions.
10 changes: 7 additions & 3 deletions exodus_gw/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,14 @@ def needs_role(rolename):
> "If caller does not have role xyz, they will never get here."
"""

async def check_roles(roles: Set[str] = Depends(caller_roles)):
if rolename not in roles:
async def check_roles(
env: Optional[str] = None, roles: Set[str] = Depends(caller_roles)
):
role = env + "-" + rolename if env else rolename

if role not in roles:
raise HTTPException(
403, "this operation requires role '%s'" % rolename
403, "this operation requires role '%s'" % role
)

return Depends(check_roles)
14 changes: 12 additions & 2 deletions exodus_gw/routers/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
from fastapi import APIRouter, Body, HTTPException
from sqlalchemy.orm import Session

from .. import deps, models, schemas, worker
from .. import auth, deps, models, schemas, worker
from ..aws.util import validate_object_key
from ..settings import Environment, Settings

Expand Down Expand Up @@ -100,11 +100,15 @@
},
}
},
dependencies=[auth.needs_role("publisher")],
)
def publish(
env: Environment = deps.env, db: Session = deps.db
) -> models.Publish:
"""Creates and returns a new publish object."""
"""Creates and returns a new publish object.
**Required roles**: `{env}-publisher`
"""

db_publish = models.Publish(id=uuid4(), env=env.name, state="PENDING")
db.add(db_publish)
Expand All @@ -116,6 +120,7 @@ def publish(
"/{env}/publish/{publish_id}",
status_code=200,
response_model=schemas.EmptyResponse,
dependencies=[auth.needs_role("publisher")],
)
def update_publish_items(
items: Union[schemas.ItemBase, List[schemas.ItemBase]] = Body(
Expand All @@ -137,6 +142,8 @@ def update_publish_items(
) -> Dict[None, None]:
"""Add publish items to an existing publish object.
**Required roles**: `{env}-publisher`
Publish items primarily are a mapping between a URI relative to the root of the CDN,
and the key of a binary object which should be exposed from that URI.
Expand Down Expand Up @@ -176,6 +183,7 @@ def update_publish_items(
"/{env}/publish/{publish_id}/commit",
status_code=200,
response_model=schemas.Task,
dependencies=[auth.needs_role("publisher")],
)
def commit_publish(
publish_id: UUID = schemas.PathPublishId,
Expand All @@ -185,6 +193,8 @@ def commit_publish(
) -> models.Task:
"""Commit an existing publish object.
**Required roles**: `{env}-publisher`
Committing a publish has the following effects:
- All URIs contained within the publish become accessible from the CDN,
Expand Down
17 changes: 15 additions & 2 deletions exodus_gw/routers/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
from botocore.exceptions import ClientError
from fastapi import APIRouter, HTTPException, Path, Query, Request, Response

from .. import deps
from .. import auth, deps
from ..aws.client import S3ClientWrapper as s3_client
from ..aws.util import (
RequestReader,
Expand Down Expand Up @@ -96,6 +96,7 @@
"/upload/{env}/{key}",
summary="Create/complete multipart upload",
response_class=Response,
dependencies=[auth.needs_role("blob-uploader")],
)
async def multipart_upload(
request: Request,
Expand Down Expand Up @@ -126,6 +127,8 @@ async def multipart_upload(
):
"""Create or complete a multi-part upload.
**Required roles**: `{env}-blob-uploader`
To create a multi-part upload:
- include ``uploads`` in query string, with no value (e.g. ``POST /upload/{env}/{key}?uploads``)
- see also: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html
Expand Down Expand Up @@ -156,6 +159,7 @@ async def multipart_upload(
"/upload/{env}/{key}",
summary="Upload bytes",
response_class=Response,
dependencies=[auth.needs_role("blob-uploader")],
)
async def upload(
request: Request,
Expand All @@ -170,6 +174,8 @@ async def upload(
):
"""Write to an object, either as a standalone operation or within a multi-part upload.
**Required roles**: `{env}-blob-uploader`
To upload an entire object:
- include all object bytes in request body
- see also: https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
Expand Down Expand Up @@ -283,6 +289,7 @@ async def multipart_put(
summary="Abort multipart upload",
response_description="Empty response",
response_class=Response,
dependencies=[auth.needs_role("blob-uploader")],
)
async def abort_multipart_upload(
env: Environment = deps.env,
Expand All @@ -291,6 +298,8 @@ async def abort_multipart_upload(
):
"""Abort a multipart upload.
**Required roles**: `{env}-blob-uploader`
If an upload cannot be completed, explicitly aborting it is recommended in order
to free up resources as early as possible, although this is not mandatory.
Expand All @@ -312,12 +321,16 @@ async def abort_multipart_upload(
"/upload/{env}/{key}",
summary="Request head object",
response_class=Response,
dependencies=[auth.needs_role("blob-uploader")],
)
async def head(
env: Environment = deps.env,
key: str = Path(..., description="S3 object key"),
):
"""Retrieve metadata from an S3 object."""
"""Retrieve metadata from an S3 object.
**Required roles**: `{env}-blob-uploader`
"""

validate_object_key(key)

Expand Down
8 changes: 8 additions & 0 deletions scripts/systemd/exodus-gw-sidecar.service
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ com.redhat.api.platform:\n\
|\n$(sed -r -e 's|^| |' %S/exodus-gw-dev/service.pem)\n\
private-key-pkcs8-pem:\
|\n$(sed -r -e 's|^| |' %S/exodus-gw-dev/service-key.pem)\n\
security:\n\
roles:\n\
test-blob-uploader:\n\
users:\n\
byInternalUsername: [${USER}]\n\
test-publisher:\n\
users:\n\
byInternalUsername: [${USER}]\n\
\nEND\n\
"

Expand Down
6 changes: 6 additions & 0 deletions scripts/systemd/install
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ EXODUS_GW_SRC_PATH=$(realpath -L ../..)
# You'll have to change this too if you change the localstack port.
#EXODUS_GW_S3_ENDPOINT_URL=https://localhost:3377
# Disable migrations during development
#EXODUS_GW_DB_MIGRATION_MODE=model
# Drop and recreate tables on restart
#EXODUS_GW_DB_RESET=true
END
fi

Expand Down
24 changes: 23 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import base64
import json
import os
import uuid
from typing import List

import mock
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm.session import Session

from exodus_gw import database, main, models, schemas, settings # noqa
from exodus_gw import auth, database, main, models, schemas, settings # noqa

from .async_utils import BlockDetector

Expand Down Expand Up @@ -139,3 +142,22 @@ def fake_publish():
),
]
yield publish


@pytest.fixture
def auth_header():
def _auth_header(roles: List[str] = []):
raw_context = {
"user": {
"authenticated": True,
"internalUsername": "fake-user",
"roles": roles,
}
}

json_context = json.dumps(raw_context).encode("utf-8")
b64_context = base64.b64encode(json_context)

return {"X-RhApiPlatform-CallContext": b64_context.decode("utf-8")}

return _auth_header
22 changes: 15 additions & 7 deletions tests/routers/test_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
"test3",
],
)
def test_publish_env_exists(env, db):
def test_publish_env_exists(env, db, auth_header):
with TestClient(app) as client:
r = client.post("/%s/publish" % env)
r = client.post(
"/%s/publish" % env,
headers=auth_header(roles=["%s-publisher" % env]),
)

# Should succeed
assert r.ok
Expand All @@ -33,9 +36,11 @@ def test_publish_env_exists(env, db):
assert publishes.count() == 1


def test_publish_env_doesnt_exist():
def test_publish_env_doesnt_exist(auth_header):
with TestClient(app) as client:
r = client.post("/foo/publish")
r = client.post(
"/foo/publish", headers=auth_header(roles=["foo-publisher"])
)

# It should fail
assert r.status_code == 404
Expand All @@ -58,7 +63,7 @@ def test_publish_links(mock_db_session):
}


def test_update_publish_items_typical(db):
def test_update_publish_items_typical(db, auth_header):
"""PUTting some items on a publish creates expected objects in DB."""

publish_id = "11224567-e89b-12d3-a456-426614174000"
Expand All @@ -85,6 +90,7 @@ def test_update_publish_items_typical(db):
"object_key": "2" * 64,
},
],
headers=auth_header(roles=["test-publisher"]),
)

# It should have succeeded
Expand All @@ -106,7 +112,7 @@ def test_update_publish_items_typical(db):
]


def test_update_publish_items_single_item(db):
def test_update_publish_items_single_item(db, auth_header):
"""PUTting a single item on a publish creates expected object in DB."""

publish_id = "11224567-e89b-12d3-a456-426614174000"
Expand All @@ -127,6 +133,7 @@ def test_update_publish_items_single_item(db):
"web_uri": "/uri1",
"object_key": "1" * 64,
},
headers=auth_header(roles=["test-publisher"]),
)

# It should have succeeded
Expand All @@ -144,7 +151,7 @@ def test_update_publish_items_single_item(db):
assert item_dicts == [{"web_uri": "/uri1", "object_key": "1" * 64}]


def test_update_pubish_items_invalid_publish(db):
def test_update_pubish_items_invalid_publish(db, auth_header):
"""PUTting items on a completed publish fails with code 409."""

publish_id = "11224567-e89b-12d3-a456-426614174000"
Expand All @@ -171,6 +178,7 @@ def test_update_pubish_items_invalid_publish(db):
"object_key": "2" * 64,
},
],
headers=auth_header(roles=["test-publisher"]),
)

# It should have failed with 409
Expand Down

0 comments on commit f7e699a

Please sign in to comment.