Skip to content

Commit

Permalink
Merge pull request #520 from wri/develop
Browse files Browse the repository at this point in the history
Merge to production
  • Loading branch information
jterry64 authored May 16, 2024
2 parents 2d2a5be + 6d407b2 commit 6ce63f9
Show file tree
Hide file tree
Showing 32 changed files with 2,054 additions and 1,221 deletions.
2,130 changes: 1,120 additions & 1,010 deletions Pipfile.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ High-performance Async REST API, in Python. FastAPI + GINO + Uvicorn (powered by
* Generate a DB Migration: `./scripts/migrate` (note `app/settings/prestart.sh` will run migrations automatically when running `/scripts/develop`)
* Run tests: `./scripts/test`
* `--no_build` - don't rebuild the containers
* `--moto-port=<port_number>` - explicitly sets the motoserver port (default `5000`)
* `--moto-port=<port_number>` - explicitly sets the motoserver port (default `50000`)
* Run specific tests: `./scripts/test tasks/test_vector_source_assets.py::test_vector_source_asset`
* Each development branch app instance gets its isolated database in AWS dev account that's cloned from `geostore` database. This database is named with the branch suffix (like `geostore_<branch_name>`). If a PR includes a database migration, once the change is merged to higher environments, the `geostore` database needs to also be updated with the migration. This can be done by manually replacing the existing database by a copy of a cleaned up version of the branch database (see `./prestart.sh` script for cloning command).
* Debug memory usage of Batch jobs with memory_profiler:
Expand Down
76 changes: 51 additions & 25 deletions app/authentication/token.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
from typing import Tuple, cast
from typing import cast

from fastapi import Depends, HTTPException
from fastapi.logger import logger
from fastapi.security import OAuth2PasswordBearer
from httpx import Response
from ..routes import dataset_dependency

from ..utils.rw_api import who_am_i
from ..models.pydantic.authentication import User
from ..routes import dataset_dependency
from ..settings.globals import PROTECTED_QUERY_DATASETS
from ..utils.rw_api import who_am_i

# token dependency where we immediately cause an exception if there is no auth token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
# token dependency where we don't cause exception if there is no auth token
oauth2_scheme_no_auto = OAuth2PasswordBearer(tokenUrl="/token", auto_error=False)


async def is_service_account(token: str = Depends(oauth2_scheme)) -> bool:
"""Calls GFW API to authorize user.
Expand All @@ -40,27 +42,38 @@ async def is_admin(token: str = Depends(oauth2_scheme)) -> bool:

return await is_app_admin(token, "gfw", "Unauthorized")

async def is_gfwpro_admin_for_query(dataset: str = Depends(dataset_dependency),
token: str | None = Depends(oauth2_scheme_no_auto)) -> bool:

async def is_gfwpro_admin_for_query(
dataset: str = Depends(dataset_dependency),
token: str | None = Depends(oauth2_scheme_no_auto),
) -> bool:
"""If the dataset is protected dataset, calls GFW API to authorize user by
requiring the user must be ADMIN for gfw-pro app. If the dataset is not
protected, just returns True without any required token or authorization.
requiring the user must be ADMIN for gfw-pro app.
If the dataset is not protected, just returns True without any
required token or authorization.
"""

if dataset in PROTECTED_QUERY_DATASETS:
if token == None:
raise HTTPException(status_code=401, detail="Unauthorized query on a restricted dataset")
if token is None:
raise HTTPException(
status_code=401, detail="Unauthorized query on a restricted dataset"
)
else:
return await is_app_admin(cast(str, token), "gfw-pro",
error_str="Unauthorized query on a restricted dataset")
return await is_app_admin(
cast(str, token),
"gfw-pro",
error_str="Unauthorized query on a restricted dataset",
)

return True


async def is_app_admin(token: str, app: str, error_str: str) -> bool:
"""Calls GFW API to authorize user.
User must be an ADMIN for the specified app, else it will throw
an exception with the specified error string.
User must be an ADMIN for the specified app, else it will throw an
exception with the specified error string.
"""

response: Response = await who_am_i(token)
Expand All @@ -75,19 +88,32 @@ async def is_app_admin(token: str, app: str, error_str: str) -> bool:
return True


async def get_user(token: str = Depends(oauth2_scheme)) -> Tuple[str, str]:
"""Calls GFW API to authorize user.
This functions check is user of any level is associated with the GFW
app and returns the user ID
"""
async def get_user(token: str = Depends(oauth2_scheme)) -> User:
"""Get the details for authenticated user."""

response: Response = await who_am_i(token)

if response.status_code == 401 or not (
"gfw" in response.json()["extraUserData"]["apps"]
):
if response.status_code == 401:
logger.info("Unauthorized user")
raise HTTPException(status_code=401, detail="Unauthorized")
raise HTTPException(status_code=401, detail="Unauthorized access - this operation requires user authentication via a token")
else:
return response.json()["id"], response.json()["role"]
return User(**response.json())


async def get_admin(user: User = Depends(get_user)) -> User:
"""Get the details for authenticated ADMIN user."""

if user.role != "ADMIN":
raise HTTPException(status_code=401, detail="Unauthorized access - this operation requires authentication as a user that is an admin")

return user


async def get_manager(user: User = Depends(get_user)) -> User:
"""Get the details for authenticated MANAGER for data-api application or
ADMIN user."""

if user.role != "ADMIN" and user.role != "MANAGER":
raise HTTPException(status_code=401, detail="Unauthorized write access to a dataset/version/asset by a user who is not an admin or data manager")

return user
1 change: 1 addition & 0 deletions app/models/orm/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ class Dataset(Base):
__tablename__ = "datasets"
dataset = db.Column(db.String, primary_key=True)
is_downloadable = db.Column(db.Boolean, nullable=False, default=True)
owner_id = db.Column(db.String, nullable=True, default=None)
# metadata = db.Column(db.JSONB, default=dict())
26 changes: 26 additions & 0 deletions app/models/orm/migrations/versions/d767b6dd2c4c_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""empty message.
Revision ID: d767b6dd2c4c
Revises: 04fcb4f2408a
Create Date: 2024-04-25 19:38:35.223004
"""
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "d767b6dd2c4c"
down_revision = "04fcb4f2408a"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("datasets", sa.Column("owner_id", sa.String(), nullable=True))
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("datasets", "owner_id")
# ### end Alembic commands ###
8 changes: 5 additions & 3 deletions app/models/pydantic/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from uuid import UUID

from fastapi import Query
from pydantic import EmailStr
from pydantic import BaseModel, EmailStr

from app.models.pydantic.base import BaseRecord, StrictBaseModel
from app.models.pydantic.responses import Response
Expand All @@ -14,17 +14,19 @@ class SignUpRequestIn(StrictBaseModel):
email: EmailStr = Query(..., description="User's email address")


class SignUp(StrictBaseModel):
class User(BaseModel):
id: str
name: str
email: EmailStr
createdAt: datetime
role: str
provider: str
providerId: Optional[str]
extraUserData: Dict[str, Any]


class SignUpResponse(Response):
data: SignUp
data: User


class APIKeyRequestIn(StrictBaseModel):
Expand Down
3 changes: 2 additions & 1 deletion app/models/pydantic/datasets.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import List, Optional, Union

from pydantic import Field, BaseModel
from pydantic import BaseModel, Field

from .base import BaseRecord, StrictBaseModel
from .metadata import DatasetMetadata, DatasetMetadataOut, DatasetMetadataUpdate
Expand All @@ -27,6 +27,7 @@ class DatasetCreateIn(StrictBaseModel):
class DatasetUpdateIn(StrictBaseModel):
is_downloadable: Optional[bool]
metadata: Optional[DatasetMetadataUpdate]
owner_id: Optional[str]


class DatasetResponse(Response):
Expand Down
6 changes: 6 additions & 0 deletions app/routes/analysis/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ async def _zonal_statistics(
start_date: Optional[str],
end_date: Optional[str],
):
if geometry.type != "Polygon" and geometry.type != "MultiPolygon":
raise HTTPException(
status_code=400,
detail=f"Geometry must be a Polygon or MultiPolygon for raster analysis"
)

# OTF will just not apply a base filter
base = "data"

Expand Down
66 changes: 60 additions & 6 deletions app/routes/assets/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
cannot rely on full integrity. We can only assume that unmanaged are
based on the same version and do not know the processing history.
"""

from typing import List, Optional, Union
from uuid import UUID

Expand Down Expand Up @@ -70,6 +71,10 @@
from ..assets import asset_response
from ..tasks import paginated_tasks_response, tasks_response

from ...authentication.token import get_manager
from ...models.pydantic.authentication import User
from ..datasets.dataset import get_owner

router = APIRouter()


Expand Down Expand Up @@ -102,9 +107,20 @@ async def update_asset(
*,
asset_id: UUID = Path(...),
request: AssetUpdateIn,
is_authorized: bool = Depends(is_admin),
user: User = Depends(get_manager),
) -> AssetResponse:
"""Update Asset metadata."""
"""Update Asset metadata.
Only the dataset's owner or a user with `ADMIN` user role can do this operation.
"""

try:
asset_row: ORMAsset = await assets.get_asset(asset_id)
except RecordNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))

# This is the actual check that the user is either the dataset owner or an admin
_ = await get_owner(asset_row.dataset, user)

input_data = request.dict(exclude_none=True, by_alias=True)

Expand All @@ -129,20 +145,25 @@ async def update_asset(
async def delete_asset(
*,
asset_id: UUID = Path(...),
is_authorized: bool = Depends(is_admin),
user: User = Depends(get_manager),
background_tasks: BackgroundTasks,
) -> AssetResponse:
"""Delete selected asset.
For managed assets, all resources will be deleted. For non-managed
assets, only the link will be deleted.
Only the dataset's owner or a user with `ADMIN` user role can do this operation.
"""

try:
row: ORMAsset = await assets.get_asset(asset_id)
except RecordNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))

# This is the actual check that the user is either the dataset owner or an admin
_ = await get_owner(row.dataset, user)

if row.is_default:
raise HTTPException(
status_code=409,
Expand Down Expand Up @@ -340,8 +361,22 @@ async def get_field_metadata(*, asset_id: UUID = Path(...), field_name: str):
response_model=FieldMetadataResponse,
)
async def update_field_metadata(
*, asset_id: UUID = Path(...), field_name: str, request: FieldMetadataUpdate
*, asset_id: UUID = Path(...), field_name: str, request: FieldMetadataUpdate,
user: User = Depends(get_manager),
):
"""Update the field metadata for an asset.
Only the dataset's owner or a user with `ADMIN` user role can do this operation.
"""

try:
asset_row: ORMAsset = await assets.get_asset(asset_id)
except RecordNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))

# This is the actual check that the user is either the dataset owner or an admin
_ = await get_owner(asset_row.dataset, user)

input_data = request.dict(exclude_none=True, by_alias=True)
metadata = await metadata_crud.get_asset_metadata(asset_id)
field_metadata: ORMFieldMetadata = await metadata_crud.update_field_metadata(
Expand Down Expand Up @@ -375,6 +410,10 @@ async def get_metadata(asset_id: UUID = Path(...)):
response_model=AssetMetadataResponse,
)
async def create_metadata(*, asset_id: UUID = Path(...), request: AssetMetadata):
"""Create metadata record for an asset.
Only the dataset's owner or a user with `ADMIN` user role can do this operation.
"""
input_data = request.dict(exclude_none=True, by_alias=True)
asset = await assets.get_asset(asset_id)

Expand All @@ -396,7 +435,12 @@ async def create_metadata(*, asset_id: UUID = Path(...), request: AssetMetadata)
tags=["Assets"],
response_model=AssetMetadataResponse,
)
async def update_metadata(*, asset_id: UUID = Path(...), request: AssetMetadataUpdate):
async def update_metadata(*, asset_id: UUID = Path(...), request: AssetMetadataUpdate,
user: User = Depends(get_manager)):
"""Update metadata record for an asset.
Only the dataset's owner or a user with `ADMIN` user role can do this operation.
"""

input_data = request.dict(exclude_none=True, by_alias=True)

Expand All @@ -408,6 +452,9 @@ async def update_metadata(*, asset_id: UUID = Path(...), request: AssetMetadataU
except RecordNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))

# This is the actual check that the user is either the dataset owner or an admin
_ = await get_owner(asset.dataset, user)

validated_metadata = asset_metadata_factory(asset)

return Response(data=validated_metadata)
Expand All @@ -419,14 +466,21 @@ async def update_metadata(*, asset_id: UUID = Path(...), request: AssetMetadataU
tags=["Assets"],
response_model=AssetMetadataResponse,
)
async def delete_metadata(asset_id: UUID = Path(...)):
async def delete_metadata(asset_id: UUID = Path(...),
user: User = Depends(get_manager)):
"""Delete an asset's metadata record.
Only the dataset's owner or a user with `ADMIN` user role can do this operation.
"""
try:
asset = await assets.get_asset(asset_id)
asset.metadata = await metadata_crud.delete_asset_metadata(asset_id)
except RecordNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))

# This is the actual check that the user is either the dataset owner or an admin
_ = await get_owner(asset.dataset, user)

validated_metadata = asset_metadata_factory(asset)

return Response(data=validated_metadata)
Loading

0 comments on commit 6ce63f9

Please sign in to comment.