diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 116aac5012e51e7586f1712e19180f38b5ac43a6..e9ed9bdfc1d370ed6aab5a2c3953a6176d33d005 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/python:3.11-slim +image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/python:3.12-slim variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" @@ -51,7 +51,7 @@ integration-test-job: # Runs integration tests with the database MYSQL_DATABASE: "$DB_DATABASE" MYSQL_USER: "$DB_USER" MYSQL_PASSWORD: "$DB_PASSWORD" - - name: $CI_REGISTRY/cmg/clowm/clowm-database:v2.3 + - name: $CI_REGISTRY/cmg/clowm/clowm-database:v3.0 alias: upgrade-db script: - python app/check_database_connection.py @@ -79,7 +79,7 @@ e2e-test-job: # Runs e2e tests on the API endpoints MYSQL_DATABASE: "$DB_DATABASE" MYSQL_USER: "$DB_USER" MYSQL_PASSWORD: "$DB_PASSWORD" - - name: $CI_REGISTRY/cmg/clowm/clowm-database:v2.3 + - name: $CI_REGISTRY/cmg/clowm/clowm-database:v3.0 alias: upgrade-db script: - python app/check_database_connection.py @@ -132,30 +132,30 @@ lint-test-job: # Runs linters checks on code build-publish-dev-docker-container-job: stage: deploy image: - name: gcr.io/kaniko-project/executor:v1.17.0-debug + name: gcr.io/kaniko-project/executor:v1.20.0-debug entrypoint: [""] dependencies: [] only: refs: - - development + - main before_script: - echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(printf "%s:%s" "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64 | tr -d '\n')\"},\"$CI_DEPENDENCY_PROXY_SERVER\":{\"auth\":\"$(printf "%s:%s" ${CI_DEPENDENCY_PROXY_USER} "${CI_DEPENDENCY_PROXY_PASSWORD}" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json script: - /kaniko/executor --context "${CI_PROJECT_DIR}" --dockerfile "${CI_PROJECT_DIR}/Dockerfile" - --destination "${CI_REGISTRY_IMAGE}:dev-${CI_COMMIT_SHA}" - --destination "${CI_REGISTRY_IMAGE}:dev-latest" + --destination "${CI_REGISTRY_IMAGE}:main-${CI_COMMIT_SHA}" + --destination "${CI_REGISTRY_IMAGE}:main-latest" - /kaniko/executor --context "${CI_PROJECT_DIR}" --dockerfile "${CI_PROJECT_DIR}/Dockerfile-Gunicorn" - --destination "${CI_REGISTRY_IMAGE}:dev-${CI_COMMIT_SHA}-gunicorn" - --destination "${CI_REGISTRY_IMAGE}:dev-latest-gunicorn" + --destination "${CI_REGISTRY_IMAGE}:main-${CI_COMMIT_SHA}-gunicorn" + --destination "${CI_REGISTRY_IMAGE}:main-latest-gunicorn" publish-docker-container-job: stage: deploy image: - name: gcr.io/kaniko-project/executor:v1.17.0-debug + name: gcr.io/kaniko-project/executor:v1.20.0-debug entrypoint: [""] dependencies: [] only: diff --git a/Dockerfile b/Dockerfile index b791fde634f0c5dcc5dbbec9286cf43a47feb1f4..c68406e15ee89081d9aa17a4465880e508901f38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,15 @@ -FROM python:3.11-slim -EXPOSE 8000 +FROM python:3.12-slim +ENV PORT=8000 +EXPOSE $PORT # dumb-init forwards the kill signal to the python process -RUN apt-get update && apt-get -y install dumb-init curl +RUN apt-get update && apt-get -y install dumb-init RUN apt-get clean ENTRYPOINT ["/usr/bin/dumb-init", "--"] +STOPSIGNAL SIGINT +RUN pip install --no-cache-dir httpx[cli] "uvicorn>=0.27.0" -HEALTHCHECK --interval=30s --timeout=2s CMD curl -f http://localhost:8000/health || exit 1 +HEALTHCHECK --interval=30s --timeout=2s CMD httpx http://localhost:$PORT/health || exit 1 RUN useradd -m worker USER worker @@ -14,10 +17,13 @@ WORKDIR /home/worker/code ENV PYTHONPATH=/home/worker/code ENV PATH="/home/worker/.local/bin:${PATH}" +COPY ./start_service_uvicorn.sh /home/worker/code/start.sh +COPY ./scripts/prestart.sh /home/worker/code/prestart.sh + COPY --chown=worker:worker requirements.txt ./requirements.txt RUN pip install --user --no-cache-dir --upgrade -r requirements.txt -COPY --chown=worker:worker . . +COPY --chown=worker:worker ./app /home/worker/code/app -CMD ["./start_service.sh"] +CMD ["./start.sh"] diff --git a/Dockerfile-Gunicorn b/Dockerfile-Gunicorn index 79f82be9b4fa45d10d66ed9256a5d9f97337a34a..2da77b1a0f05cf346bcbaefe0f2f4ac2b43e7e80 100644 --- a/Dockerfile-Gunicorn +++ b/Dockerfile-Gunicorn @@ -1,10 +1,14 @@ -FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11-slim -EXPOSE 8000 +FROM python:3.12-slim ENV PORT=8000 +EXPOSE $PORT +WORKDIR /app/ +ENV PYTHONPATH=/app -RUN pip install --no-cache-dir httpx[cli] +RUN pip install --no-cache-dir httpx[cli] "gunicorn<=21.2.0" "uvicorn>=0.27.0" +COPY ./gunicorn_conf.py /app/gunicorn_conf.py +COPY ./start_service_gunicorn.sh /app/start.sh -HEALTHCHECK --interval=30s --timeout=4s CMD httpx http://localhost:$PORT/health || exit 1 +HEALTHCHECK --interval=30s --timeout=2s CMD httpx http://localhost:$PORT/health || exit 1 COPY ./scripts/prestart.sh /app/prestart.sh COPY ./requirements.txt /app/requirements.txt @@ -12,3 +16,5 @@ COPY ./requirements.txt /app/requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY ./app /app/app + +CMD ["./start.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..d645695673349e3947e8e5ae42332d0ac3164cd7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 977c63b0e00418ecd318e5c99a750bdca203a953..7f4f57c555dfbf235a13bf4a87c0006c3ffd22a1 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,7 @@ user-friendly manner. 👠| `SQLALCHEMY_VERBOSE_LOGGER` | `false` | `<"true"|"false">` | Enables verbose SQL output.<br>Should be `false` in production | | `OPA_POLICY_PATH` | `/clowm/authz/allow` | URL path | Path to the OPA Policy for Authorization | | `OTLP_GRPC_ENDPOINT` | unset | <hostname / IP> | OTLP compatible endpoint to send traces via gRPC, e.g. Jaeger | + +## License + +The API is licensed under the [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) license. See the [License](LICENSE) file for more information. diff --git a/app/api/dependencies.py b/app/api/dependencies.py index eae25ba0d8e6deb20454739e4f1df5938333a50c..69be9130118e79c9c33aaa97573f4a6e1e2d8fbc 100644 --- a/app/api/dependencies.py +++ b/app/api/dependencies.py @@ -1,4 +1,5 @@ from typing import TYPE_CHECKING, Annotated, AsyncGenerator, Awaitable, Callable, Dict +from uuid import UUID from authlib.jose.errors import BadSignatureError, DecodeError, ExpiredTokenError from clowmdb.db.session import get_async_session @@ -88,9 +89,9 @@ def get_decode_jwt_function() -> Callable[[str], Dict[str, str]]: # pragma: no @start_as_current_span_async("decode_jwt", tracer=tracer) async def decode_bearer_token( - token: HTTPAuthorizationCredentials = Depends(bearer_token), - decode: Callable[[str], Dict[str, str]] = Depends(get_decode_jwt_function), - db: AsyncSession = Depends(get_db), + token: Annotated[HTTPAuthorizationCredentials, Depends(bearer_token)], + decode: Annotated[Callable[[str], Dict[str, str]], Depends(get_decode_jwt_function)], + db: DBSession, ) -> JWT: """ Get the decoded JWT or reject request if it is not valid. @@ -121,6 +122,37 @@ async def decode_bearer_token( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Malformed JWT") +async def get_current_user(token: Annotated[JWT, Depends(decode_bearer_token)], db: DBSession) -> User: + """ + Get the current user from the database based on the JWT. + + FastAPI Dependency Injection Function. + + Parameters + ---------- + token : app.schemas.security.JWT + The verified and decoded JWT. + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. Dependency Injection. + + Returns + ------- + user : clowmdb.models.User + User associated with the JWT sent with the HTTP request. + """ + try: + uid = UUID(token.sub) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Malformed JWT") + user = await CRUDUser.get(db, uid) + if user: + return user + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + +CurrentUser = Annotated[User, Depends(get_current_user)] + + class AuthorizationDependency: """ Class to parameterize the authorization request with the resource to perform an operation on. @@ -137,16 +169,16 @@ class AuthorizationDependency: def __call__( self, - token: JWT = Depends(decode_bearer_token), - client: AsyncClient = Depends(get_httpx_client), + user: CurrentUser, + client: HTTPXClient, ) -> Callable[[str], Awaitable[AuthzResponse]]: """ Get the function to request the authorization service with the resource, JWT and HTTP Client already injected. Parameters ---------- - token : app.schemas.security.JWT - The verified and decoded JWT. Dependency Injection. + user : clowmdb.models.User + The current user based on the JWT. Dependency Injection. client : httpx.AsyncClient HTTP Client with an open connection. Dependency Injection. @@ -157,43 +189,14 @@ class AuthorizationDependency: """ async def authorization_wrapper(operation: str) -> AuthzResponse: - params = AuthzRequest(operation=operation, resource=self.resource, uid=token.sub) + params = AuthzRequest(operation=operation, resource=self.resource, uid=user.lifescience_id) return await request_authorization(request_params=params, client=client) return authorization_wrapper -async def get_current_user(token: JWT = Depends(decode_bearer_token), db: AsyncSession = Depends(get_db)) -> User: - """ - Get the current user from the database based on the JWT. - - FastAPI Dependency Injection Function. - - Parameters - ---------- - token : app.schemas.security.JWT - The verified and decoded JWT. - db : sqlalchemy.ext.asyncio.AsyncSession. - Async database session to perform query on. Dependency Injection. - - Returns - ------- - user : clowmdb.models.User - User associated with the JWT sent with the HTTP request. - """ - user = await CRUDUser.get(db, token.sub) - if user: - return user - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - - -CurrentUser = Annotated[User, Depends(get_current_user)] - - async def get_user_by_path_uid( - uid: str = Path( - default=..., description="UID of a user", examples=["28c5353b8bb34984a8bd4169ba94c606"], max_length=64 - ), + uid: UUID = Path(default=..., description="UID of a user", examples=["1d3387f3-95c0-4813-8767-2cad87faeebf"]), db: AsyncSession = Depends(get_db), ) -> User: """ @@ -225,8 +228,10 @@ PathUser = Annotated[User, Depends(get_user_by_path_uid)] async def get_current_bucket( - bucket_name: str = Path(..., description="Name of bucket", examples=["test-bucket"], max_length=63, min_length=3), - db: AsyncSession = Depends(get_db), + bucket_name: Annotated[ + str, Path(..., description="Name of bucket", examples=["test-bucket"], max_length=63, min_length=3) + ], + db: DBSession, ) -> Bucket: """ Get the Bucket from the database based on the name in the path. diff --git a/app/api/endpoints/bucket_permissions.py b/app/api/endpoints/bucket_permissions.py index 7ab46c1823bf622c8dcee433a5e8689c684832c1..c3a0eaa65a5e3e9aadc9e2d1cfd7a0e8bc4df18f 100644 --- a/app/api/endpoints/bucket_permissions.py +++ b/app/api/endpoints/bucket_permissions.py @@ -1,9 +1,10 @@ import json -from typing import Annotated, Any, Awaitable, Callable, List, Optional +from typing import Annotated, Any, Awaitable, Callable, List, Union from clowmdb.models import BucketPermission from fastapi import APIRouter, Body, Depends, HTTPException, Query, status from opentelemetry import trace +from pydantic.json_schema import SkipJsonSchema from app.api.dependencies import ( AuthorizationDependency, @@ -30,139 +31,149 @@ tracer = trace.get_tracer_provider().get_tracer(__name__) @router.get( - "/bucket/{bucket_name}/user/{uid}", - response_model=PermissionSchemaOut, - summary="Get permission for bucket and user combination.", + "", + response_model=List[PermissionSchemaOut], + summary="Get all permissions.", response_model_exclude_none=True, ) -@start_as_current_span_async("api_get_bucket_permission", tracer=tracer) -async def get_permission_for_bucket( - bucket: CurrentBucket, +@start_as_current_span_async("api_list_bucket_permission", tracer=tracer) +async def list_permissions( db: DBSession, - current_user: CurrentUser, authorization: Authorization, - user: PathUser, -) -> PermissionSchemaOut: + permission_types: Annotated[ + Union[List[BucketPermission.Permission], SkipJsonSchema[None]], + Query(description="Type of Bucket Permissions to fetch"), + ] = None, + permission_status: Annotated[ + Union[CRUDBucketPermission.PermissionStatus, SkipJsonSchema[None]], + Query(description="Status of Bucket Permissions to fetch"), + ] = None, +) -> List[PermissionSchemaOut]: """ - Get the bucket permissions for the specific combination of bucket and user.\n - The owner of the bucket and the grantee of the permission can view it.\n - Permission "bucket_permission:read" required if current user is the target or owner of the bucket permission, - otherwise "bucket_permission:read_any" required. + List all the bucket permissions in the system.\n + Permission `bucket_permission:list_all` required. \f Parameters ---------- - bucket : clowmdb.models.Bucket - Bucket with the name provided in the URL path. Dependency Injection. + permission_types : List[clowmdb.models.BucketPermission.Permission] | None, default None + Type of Bucket Permissions to fetch. Query Parameter + permission_status : app.crud.crud_bucket_permission.CRUDBucketPermission.PermissionStatus | None, default None + Status of Bucket Permissions to fetch. Query Parameter. db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. Dependency Injection. - user : clowmdb.models.User - User with the uid in the URL. Dependency Injection. authorization : Callable[[str], Awaitable[Any]] Async function to ask the auth service for authorization. Dependency Injection. - current_user : clowmdb.models.User - Current user who will be the owner of the newly created bucket. Dependency Injection. Returns ------- - permissions : app.schemas.bucket_permission.BucketPermissionOut - Permission for this bucket and user combination. + permissions : List[app.schemas.bucket_permission.BucketPermissionOut] + List of all permissions. """ - trace.get_current_span().set_attributes({"bucket_name": bucket.name, "uid": user.uid}) - rbac_operation = "read" if user == current_user or current_user.uid == bucket.owner_id else "read_any" + current_span = trace.get_current_span() + if permission_types is not None and len(permission_types) > 0: # pragma: no cover + current_span.set_attribute("permission_types", [ptype.name for ptype in permission_types]) + if permission_status is not None: # pragma: no cover + current_span.set_attribute("permission_status", permission_status.name) + rbac_operation = "list_all" await authorization(rbac_operation) - bucket_permission = await CRUDBucketPermission.get(db, bucket.name, user.uid) - if bucket_permission: - return PermissionSchemaOut.from_db_model( - bucket_permission, uid=user.uid, grantee_display_name=user.display_name - ) - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail=f"Permission for combination of bucket={bucket.name} and user={user.uid} doesn't exists", + bucket_permissions = await CRUDBucketPermission.list( + db, permission_types=permission_types, permission_status=permission_status ) + return [PermissionSchemaOut.from_db_model(p) for p in bucket_permissions] -@router.delete( - "/bucket/{bucket_name}/user/{uid}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete a bucket permission", +@router.post( + "", + response_model=PermissionSchemaOut, + status_code=status.HTTP_201_CREATED, + summary="Create a permission.", + response_model_exclude_none=True, ) -@start_as_current_span_async("api_delete_bucket_permission", tracer=tracer) -async def delete_permission( - bucket: CurrentBucket, +@start_as_current_span_async("api_create_bucket_permission", tracer=tracer) +async def create_permission( db: DBSession, - s3: S3Resource, current_user: CurrentUser, + s3: S3Resource, authorization: Authorization, - user: PathUser, -) -> None: + permission: Annotated[PermissionSchemaIn, Body(..., description="Permission to create")], +) -> PermissionSchemaOut: """ - Delete the bucket permissions for the specific combination of bucket and user.\n - The owner of the bucket and the grantee of the permission can delete it.\n - Permission "bucket_permission:delete" required if current user is the target or owner of the bucket permission, - otherwise "bucket_permission:delete_any" required. + Create a permission for a bucket and user.\n + Permission `bucket_permission:create` required. \f Parameters ---------- - bucket : clowmdb.models.Bucket - Bucket with the name provided in the URL path. Dependency Injection. + permission : app.schemas.bucket_permission.BucketPermissionOut + Information about the permission which should be created. HTTP Body parameter. db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. Dependency Injection. - user : clowmdb.models.User - User with the uid in the URL. Dependency Injection. + current_user : clowmdb.models.User + Current user. Dependency Injection. s3 : boto3_type_annotations.s3.ServiceResource S3 Service to perform operations on buckets in Ceph. Dependency Injection. authorization : Callable[[str], Awaitable[Any]] Async function to ask the auth service for authorization. Dependency Injection. - current_user : clowmdb.models.User - Current user who will be the owner of the newly created bucket. Dependency Injection. Returns ------- permissions : app.schemas.bucket_permission.BucketPermissionOut - Permission for this bucket and user combination. + Newly created permission. """ - trace.get_current_span().set_attributes({"bucket_name": bucket.name, "uid": user.uid}) - rbac_operation = "delete" if user == current_user or current_user.uid == bucket.owner_id else "delete_any" - await authorization(rbac_operation) - bucket_permission = await CRUDBucketPermission.get(db, bucket.name, user.uid) - if bucket_permission is None: + current_span = trace.get_current_span() + current_span.set_attributes({"uid": str(permission.uid), "bucket_name": permission.bucket_name}) + await authorization("create") + target_bucket = await get_current_bucket(permission.bucket_name, db=db) # Check if the target bucket exists + if target_bucket.owner_id != current_user.uid: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Action forbidden.") + if target_bucket.owner_constraint is not None: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Initial Buckets can be target of Bucket Permissions.") + await get_user_by_path_uid(permission.uid, db) # Check if target user exists + try: + permission_db = await CRUDBucketPermission.create(db, permission) + except ValueError as e: + current_span.record_exception(e) raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail=f"Permission for combination of bucket={bucket.name} and user={user.uid} doesn't exists", + status.HTTP_400_BAD_REQUEST, detail="The owner of the bucket can't get any more permissions" ) - await CRUDBucketPermission.delete(db, bucket_name=bucket_permission.bucket_name, uid=bucket_permission.user_id) - bucket_permission_schema = PermissionSchemaOut.from_db_model(bucket_permission, user.uid, user.display_name) - s3_policy = get_s3_bucket_policy(s3, bucket_name=bucket_permission_schema.bucket_name) - policy = json.loads(s3_policy.policy) - policy["Statement"] = [ - stmt for stmt in policy["Statement"] if stmt["Sid"] != bucket_permission_schema.to_hash(user.uid) - ] - put_s3_bucket_policy(s3, bucket_name=bucket_permission_schema.bucket_name, policy=json.dumps(policy)) + except DuplicateError as e: + current_span.record_exception(e) + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Permission for combination of bucket={permission.bucket_name} and user={permission.uid} already exists", # noqa:E501 + ) + s3_policy = get_s3_bucket_policy(s3, bucket_name=permission.bucket_name) + json_policy = json.loads(s3_policy.policy) + json_policy["Statement"] += permission.map_to_bucket_policy_statement(permission_db.uid) + put_s3_bucket_policy(s3, bucket_name=permission.bucket_name, policy=json.dumps(json_policy)) + + return PermissionSchemaOut.from_db_model(permission_db) @router.get( - "/bucket/{bucket_name}", + "/user/{uid}", response_model=List[PermissionSchemaOut], - summary="Get all permissions for a bucket.", + summary="Get all permissions for a user.", response_model_exclude_none=True, ) -@start_as_current_span_async("api_list_bucket_permission_for_bucket", tracer=tracer) -async def list_permissions_per_bucket( - bucket: CurrentBucket, +@start_as_current_span_async("api_list_bucket_permission_for_user", tracer=tracer) +async def list_permissions_per_user( db: DBSession, current_user: CurrentUser, authorization: Authorization, - permission_types: Optional[List[BucketPermission.Permission]] = Query( - None, description="Type of Bucket Permissions to fetch" - ), - permission_status: Optional[CRUDBucketPermission.PermissionStatus] = Query( - None, description="Status of Bucket Permissions to fetch" - ), + user: PathUser, + permission_types: Annotated[ + Union[List[BucketPermission.Permission], SkipJsonSchema[None]], + Query(description="Type of Bucket Permissions to fetch"), + ] = None, + permission_status: Annotated[ + Union[CRUDBucketPermission.PermissionStatus, SkipJsonSchema[None]], + Query(description="Status of Bucket Permissions to fetch"), + ] = None, ) -> List[PermissionSchemaOut]: """ - List all the bucket permissions for the given bucket.\n - Permission "bucket_permission:read" required if current user is owner of the bucket, - otherwise "bucket_permission:read_any" required. + List all the bucket permissions for the given user.\n + Permission `bucket_permission:list_user` required if current user is the target the bucket permission, + otherwise `bucket_permission:list_all` required. \f Parameters ---------- @@ -170,8 +181,8 @@ async def list_permissions_per_bucket( Type of Bucket Permissions to fetch. Query Parameter permission_status : app.crud.crud_bucket_permission.CRUDBucketPermission.PermissionStatus | None, default None Status of Bucket Permissions to fetch. Query Parameter. - bucket : clowmdb.models.Bucket - Bucket with the name provided in the URL path. Dependency Injection. + user : clowmdb.models.User + User with given uid. Dependency Injection. db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. Dependency Injection. authorization : Callable[[str], Awaitable[Any]] @@ -182,45 +193,47 @@ async def list_permissions_per_bucket( Returns ------- permissions : List[app.schemas.bucket_permission.BucketPermissionOut] - List of all permissions for this bucket. + List of all permissions for this user. """ current_span = trace.get_current_span() - current_span.set_attribute("bucket_name", bucket.name) + current_span.set_attribute("uid", user.uid) if permission_types is not None and len(permission_types) > 0: # pragma: no cover current_span.set_attribute("permission_types", [ptype.name for ptype in permission_types]) if permission_status is not None: # pragma: no cover current_span.set_attribute("permission_status", permission_status.name) - rbac_operation = "list_bucket" if bucket.owner_id == current_user.uid else "list_all" + rbac_operation = "list_user" if user == current_user else "list_all" await authorization(rbac_operation) bucket_permissions = await CRUDBucketPermission.list( - db, bucket_name=bucket.name, permission_types=permission_types, permission_status=permission_status + db, uid=user.uid, permission_types=permission_types, permission_status=permission_status ) return [PermissionSchemaOut.from_db_model(p) for p in bucket_permissions] @router.get( - "/user/{uid}", + "/bucket/{bucket_name}", response_model=List[PermissionSchemaOut], - summary="Get all permissions for a user.", + summary="Get all permissions for a bucket.", response_model_exclude_none=True, ) -@start_as_current_span_async("api_list_bucket_permission_for_user", tracer=tracer) -async def list_permissions_per_user( +@start_as_current_span_async("api_list_bucket_permission_for_bucket", tracer=tracer) +async def list_permissions_per_bucket( + bucket: CurrentBucket, db: DBSession, current_user: CurrentUser, authorization: Authorization, - user: PathUser, - permission_types: Optional[List[BucketPermission.Permission]] = Query( - None, description="Type of Bucket Permissions to fetch" - ), - permission_status: Optional[CRUDBucketPermission.PermissionStatus] = Query( - None, description="Status of Bucket Permissions to fetch" - ), + permission_types: Annotated[ + Union[List[BucketPermission.Permission], SkipJsonSchema[None]], + Query(description="Type of Bucket Permissions to fetch"), + ] = None, + permission_status: Annotated[ + Union[CRUDBucketPermission.PermissionStatus, SkipJsonSchema[None]], + Query(description="Status of Bucket Permissions to fetch"), + ] = None, ) -> List[PermissionSchemaOut]: """ - List all the bucket permissions for the given user.\n - Permission "bucket_permission:read" required if current user is the target the bucket permission, - otherwise "bucket_permission:read_any" required. + List all the bucket permissions for the given bucket.\n + Permission `bucket_permission:list_bucket` required if current user is owner of the bucket, + otherwise `bucket_permission:list_all` required. \f Parameters ---------- @@ -228,8 +241,8 @@ async def list_permissions_per_user( Type of Bucket Permissions to fetch. Query Parameter permission_status : app.crud.crud_bucket_permission.CRUDBucketPermission.PermissionStatus | None, default None Status of Bucket Permissions to fetch. Query Parameter. - user : clowmdb.models.User - User with given uid. Dependency Injection. + bucket : clowmdb.models.Bucket + Bucket with the name provided in the URL path. Dependency Injection. db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. Dependency Injection. authorization : Callable[[str], Awaitable[Any]] @@ -240,90 +253,129 @@ async def list_permissions_per_user( Returns ------- permissions : List[app.schemas.bucket_permission.BucketPermissionOut] - List of all permissions for this user. + List of all permissions for this bucket. """ current_span = trace.get_current_span() - current_span.set_attribute("uid", user.uid) + current_span.set_attribute("bucket_name", bucket.name) if permission_types is not None and len(permission_types) > 0: # pragma: no cover current_span.set_attribute("permission_types", [ptype.name for ptype in permission_types]) if permission_status is not None: # pragma: no cover current_span.set_attribute("permission_status", permission_status.name) - rbac_operation = "list_user" if user == current_user else "list_all" + rbac_operation = "list_bucket" if bucket.owner_id == current_user.uid else "list_all" await authorization(rbac_operation) bucket_permissions = await CRUDBucketPermission.list( - db, uid=user.uid, permission_types=permission_types, permission_status=permission_status + db, bucket_name=bucket.name, permission_types=permission_types, permission_status=permission_status ) - return [ - PermissionSchemaOut.from_db_model(p, uid=user.uid, grantee_display_name=user.display_name) - for p in bucket_permissions - ] + return [PermissionSchemaOut.from_db_model(p) for p in bucket_permissions] -@router.post( - "", +@router.get( + "/bucket/{bucket_name}/user/{uid}", response_model=PermissionSchemaOut, - status_code=status.HTTP_201_CREATED, - summary="Create a permission.", + summary="Get permission for bucket and user combination.", response_model_exclude_none=True, ) -@start_as_current_span_async("api_create_bucket_permission", tracer=tracer) -async def create_permission( +@start_as_current_span_async("api_get_bucket_permission", tracer=tracer) +async def get_permission_for_bucket( + bucket: CurrentBucket, db: DBSession, current_user: CurrentUser, - s3: S3Resource, authorization: Authorization, - permission: PermissionSchemaIn = Body(..., description="Permission to create"), + user: PathUser, ) -> PermissionSchemaOut: """ - Create a permission for a bucket and user.\n - Permission "bucket_permission:create" required. + Get the bucket permissions for the specific combination of bucket and user.\n + The owner of the bucket and the grantee of the permission can view it.\n + Permission `bucket_permission:read` required if current user is the target or owner of the bucket permission, + otherwise `bucket_permission:read_any` required. \f Parameters ---------- - permission : app.schemas.bucket_permission.BucketPermissionOut - Information about the permission which should be created. HTTP Body parameter. + bucket : clowmdb.models.Bucket + Bucket with the name provided in the URL path. Dependency Injection. db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. Dependency Injection. + user : clowmdb.models.User + User with the uid in the URL. Dependency Injection. + authorization : Callable[[str], Awaitable[Any]] + Async function to ask the auth service for authorization. Dependency Injection. current_user : clowmdb.models.User - Current user. Dependency Injection. + Current user who will be the owner of the newly created bucket. Dependency Injection. + + Returns + ------- + permissions : app.schemas.bucket_permission.BucketPermissionOut + Permission for this bucket and user combination. + """ + trace.get_current_span().set_attributes({"bucket_name": bucket.name, "uid": str(user.uid)}) + rbac_operation = "read" if user == current_user or current_user.uid == bucket.owner_id else "read_any" + await authorization(rbac_operation) + bucket_permission = await CRUDBucketPermission.get(db, bucket.name, user.uid) + if bucket_permission: + return PermissionSchemaOut.from_db_model(bucket_permission) + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Permission for combination of bucket={bucket.name} and user={user.uid} doesn't exists", + ) + + +@router.delete( + "/bucket/{bucket_name}/user/{uid}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a bucket permission", +) +@start_as_current_span_async("api_delete_bucket_permission", tracer=tracer) +async def delete_permission( + bucket: CurrentBucket, + db: DBSession, + s3: S3Resource, + current_user: CurrentUser, + authorization: Authorization, + user: PathUser, +) -> None: + """ + Delete the bucket permissions for the specific combination of bucket and user.\n + The owner of the bucket and the grantee of the permission can delete it.\n + Permission `bucket_permission:delete` required if current user is the target or owner of the bucket permission, + otherwise `bucket_permission:delete_any` required. + \f + Parameters + ---------- + bucket : clowmdb.models.Bucket + Bucket with the name provided in the URL path. Dependency Injection. + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. Dependency Injection. + user : clowmdb.models.User + User with the uid in the URL. Dependency Injection. s3 : boto3_type_annotations.s3.ServiceResource S3 Service to perform operations on buckets in Ceph. Dependency Injection. authorization : Callable[[str], Awaitable[Any]] Async function to ask the auth service for authorization. Dependency Injection. + current_user : clowmdb.models.User + Current user who will be the owner of the newly created bucket. Dependency Injection. Returns ------- permissions : app.schemas.bucket_permission.BucketPermissionOut - Newly created permission. + Permission for this bucket and user combination. """ - current_span = trace.get_current_span() - current_span.set_attributes({"uid": permission.uid, "bucket_name": permission.bucket_name}) - await authorization("create") - target_bucket = await get_current_bucket(permission.bucket_name, db=db) # Check if the target bucket exists - if target_bucket.owner_id != current_user.uid: - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Action forbidden.") - if target_bucket.owner_constraint is not None: - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Initial Buckets can be target of Bucket Permissions.") - grantee = await get_user_by_path_uid(permission.uid, db) # Check if target user exists - try: - permission_db = await CRUDBucketPermission.create(db, permission) - except ValueError as e: - current_span.record_exception(e) - raise HTTPException( - status.HTTP_400_BAD_REQUEST, detail="The owner of the bucket can't get any more permissions" - ) - except DuplicateError as e: - current_span.record_exception(e) + trace.get_current_span().set_attributes({"bucket_name": bucket.name, "uid": str(user.uid)}) + rbac_operation = "delete" if user == current_user or current_user.uid == bucket.owner_id else "delete_any" + await authorization(rbac_operation) + bucket_permission = await CRUDBucketPermission.get(db, bucket.name, user.uid) + if bucket_permission is None: raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail=f"Permission for combination of bucket={permission.bucket_name} and user={permission.uid} already exists", # noqa:E501 + status.HTTP_404_NOT_FOUND, + detail=f"Permission for combination of bucket={bucket.name} and user={str(user.uid)} doesn't exists", ) - s3_policy = get_s3_bucket_policy(s3, bucket_name=permission.bucket_name) - json_policy = json.loads(s3_policy.policy) - json_policy["Statement"] += permission.map_to_bucket_policy_statement(permission_db.user_id) - put_s3_bucket_policy(s3, bucket_name=permission.bucket_name, policy=json.dumps(json_policy)) - - return PermissionSchemaOut.from_db_model(permission_db, uid=grantee.uid, grantee_display_name=grantee.display_name) + await CRUDBucketPermission.delete(db, bucket_name=bucket_permission.bucket_name, uid=bucket_permission.uid) + bucket_permission_schema = PermissionSchemaOut.from_db_model(bucket_permission) + s3_policy = get_s3_bucket_policy(s3, bucket_name=bucket_permission_schema.bucket_name) + policy = json.loads(s3_policy.policy) + policy["Statement"] = [ + stmt for stmt in policy["Statement"] if stmt["Sid"] != bucket_permission_schema.to_hash(user.uid) + ] + put_s3_bucket_policy(s3, bucket_name=bucket_permission_schema.bucket_name, policy=json.dumps(policy)) @router.put( @@ -341,12 +393,11 @@ async def update_permission( s3: S3Resource, authorization: Authorization, user: PathUser, - permission_parameters: PermissionParametersSchema = Body(..., description="Permission to create"), + permission_parameters: Annotated[PermissionParametersSchema, Body(..., description="Permission to create")], ) -> PermissionSchemaOut: """ Update a permission for a bucket and user.\n - Permission "bucket_permission:read" required if current user is the target the bucket permission, - otherwise "bucket_permission:update" required. + Permission `bucket_permission:update` required. \f Parameters ---------- @@ -370,7 +421,7 @@ async def update_permission( permissions : app.schemas.bucket_permission.BucketPermissionOut Updated permission. """ - trace.get_current_span().set_attributes({"uid": user.uid, "bucket_name": bucket.name}) + trace.get_current_span().set_attributes({"uid": str(user.uid), "bucket_name": bucket.name}) await authorization("update") if bucket.owner_id != current_user.uid: raise HTTPException(status.HTTP_403_FORBIDDEN, "Action forbidden") @@ -388,6 +439,6 @@ async def update_permission( policy["Statement"] = [ stmt for stmt in policy["Statement"] if stmt["Sid"] != updated_permission_schema.to_hash(user.uid) ] - policy["Statement"] += updated_permission_schema.map_to_bucket_policy_statement(updated_permission.user_id) + policy["Statement"] += updated_permission_schema.map_to_bucket_policy_statement(updated_permission.uid) put_s3_bucket_policy(s3, bucket_name=bucket.name, policy=json.dumps(policy)) return updated_permission_schema diff --git a/app/api/endpoints/buckets.py b/app/api/endpoints/buckets.py index eaad0ef07c1eb9e2f4e7d19432a3e7b4849961fc..6a86650efa43b1b5670a4567140820e7de760033 100644 --- a/app/api/endpoints/buckets.py +++ b/app/api/endpoints/buckets.py @@ -1,11 +1,13 @@ import json from functools import reduce -from typing import TYPE_CHECKING, Annotated, Any, Awaitable, Callable, List, Optional +from typing import TYPE_CHECKING, Annotated, Any, Awaitable, Callable, List, Union +from uuid import UUID from botocore.exceptions import ClientError from clowmdb.models import Bucket from fastapi import APIRouter, Depends, HTTPException, Query, status from opentelemetry import trace +from pydantic.json_schema import SkipJsonSchema from app.api.dependencies import AuthorizationDependency, CurrentBucket, CurrentUser, DBSession, S3Resource from app.ceph.s3 import get_s3_bucket_objects, put_s3_bucket_policy @@ -60,21 +62,25 @@ async def list_buckets( s3: S3Resource, current_user: CurrentUser, authorization: Authorization, - user: Optional[str] = Query( - None, - description="UID of the user for whom to fetch the buckets for. Permission 'bucket:read_any' required if current user is not the target.", # noqa:E501 - ), - bucket_type: CRUDBucket.BucketType = Query( - CRUDBucket.BucketType.ALL, description="Type of the bucket to get. Ignored when `user` parameter not set" - ), + owner_id: Annotated[ + Union[UUID, SkipJsonSchema[None]], + Query( + description="UID of the user for whom to fetch the buckets for. Permission 'bucket:read_any' required if current user is not the target.", # noqa:E501 + examples=["1d3387f3-95c0-4813-8767-2cad87faeebf"], + ), + ] = None, + bucket_type: Annotated[ + CRUDBucket.BucketType, Query(description="Type of the bucket to get. Ignored when `user` parameter not set") + ] = CRUDBucket.BucketType.ALL, ) -> List[BucketOutSchema]: """ List all the buckets in the system or of the desired user where the user has READ permissions for.\n - Permission "bucket:read" required. + Permission `bucket:list` required if the current user is the owner of the bucket, + otherwise `bucket:list_all` required. \f Parameters ---------- - user : clowmdb.models.User + uid : uuid.UUID User for which to retrieve the buckets. Dependency Injection. bucket_type : app.crud.crud_bucket.CRUDBucket.BucketType, default BucketType.ALL Type of the bucket to get. Query Parameter. @@ -92,14 +98,14 @@ async def list_buckets( All the buckets for which the user has READ permissions. """ current_span = trace.get_current_span() - if user is not None: # pragma: no cover - current_span.set_attribute("uid", user) + if owner_id is not None: # pragma: no cover + current_span.set_attribute("uid", str(owner_id)) current_span.set_attribute("bucket_type", bucket_type.name) - await authorization("list_all" if user is None or current_user.uid != user else "list") - if user is None: + await authorization("list_all" if current_user.uid != owner_id else "list") + if owner_id is None: buckets = await CRUDBucket.get_all(db) else: - buckets = await CRUDBucket.get_for_user(db, user, bucket_type) + buckets = await CRUDBucket.get_for_user(db, owner_id, bucket_type) def map_buckets(bucket: Bucket) -> BucketOutSchema: # define function to fetch objects only one time for each bucket @@ -109,7 +115,7 @@ async def list_buckets( "description": bucket.description, "name": bucket.name, "created_at": bucket.created_at, - "owner": bucket.owner_id, + "owner_id": bucket.owner_id, "num_objects": sum(1 for obj in objects if not obj.key.endswith("/")), "size": reduce(lambda x, y: x + y.size, objects, 0), "owner_constraint": bucket.owner_constraint, @@ -138,7 +144,7 @@ async def create_bucket( The name of the bucket has some constraints. For more information see the [Ceph documentation](https://docs.ceph.com/en/quincy/radosgw/s3/bucketops/#constraints)\n - Permission "bucket:create" required. + Permission `bucket:create` required. \f Parameters ---------- @@ -204,7 +210,7 @@ async def create_bucket( "description": db_bucket.description, "name": db_bucket.name, "created_at": db_bucket.created_at, - "owner": db_bucket.owner.uid, + "owner_id": db_bucket.owner.uid, "num_objects": 0, "size": 0, } @@ -222,8 +228,8 @@ async def get_bucket( ) -> BucketOutSchema: """ Get a bucket by its name if the current user has READ permissions for the bucket.\n - Permission "bucket:read" required if the current user is the owner of the bucket, - otherwise "bucket:read_any" required. + Permission `bucket:read` required if the current user is the owner of the bucket, + otherwise `bucket:read_any` required. \f Parameters ---------- @@ -256,7 +262,7 @@ async def get_bucket( "description": bucket.description, "name": bucket.name, "created_at": bucket.created_at, - "owner": bucket.owner_id, + "owner_id": bucket.owner_id, "num_objects": sum(1 for obj in objects if not obj.key.endswith("/")), "size": reduce(lambda x, y: x + y.size, objects, 0), "owner_constraint": bucket.owner_constraint, @@ -272,12 +278,12 @@ async def delete_bucket( current_user: CurrentUser, authorization: Authorization, s3: S3Resource, - force_delete: bool = Query(False, description="Delete even non-empty bucket"), + force_delete: Annotated[bool, Query(description="Delete even non-empty bucket")] = False, ) -> None: """ Delete a bucket by its name. Only the owner of the bucket can delete the bucket.\n - Permission "bucket:delete" required if the current user is the owner of the bucket, - otherwise "bucket:delete_any" required. + Permission `bucket:delete` required if the current user is the owner of the bucket, + otherwise `bucket:delete_any` required. \f Parameters ---------- diff --git a/app/api/endpoints/s3key.py b/app/api/endpoints/s3key.py index 03b9cbff02a6b2255e9453155bb0dd9310da5bad..0ca0b01a8e5429f56210fe6e41c61a6607609a97 100644 --- a/app/api/endpoints/s3key.py +++ b/app/api/endpoints/s3key.py @@ -7,7 +7,7 @@ from rgwadmin.exceptions import RGWAdminException from app.api.dependencies import AuthorizationDependency, CurrentUser, PathUser, RGWAdminResource from app.ceph.rgw import get_s3_keys from app.otlp import start_as_current_span_async -from app.schemas.user import S3Key +from app.schemas.s3key import S3Key router = APIRouter(prefix="/users/{uid}/keys", tags=["S3Key"]) s3key_authorization = AuthorizationDependency(resource="s3_key") @@ -38,7 +38,7 @@ async def get_user_keys( ) -> List[S3Key]: """ Get all the S3 Access keys for a specific user.\n - Permission "s3_key:list" required. + Permission `s3_key:list` required. \f Parameters ---------- @@ -56,7 +56,7 @@ async def get_user_keys( keys : List(app.schemas.user.S3Key) All S3 keys from the user. """ - trace.get_current_span().set_attribute("uid", user.uid) + trace.get_current_span().set_attribute("uid", str(user.uid)) if current_user.uid != user.uid: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Action forbidden.") await authorization("list") @@ -78,7 +78,7 @@ async def create_user_key( ) -> S3Key: """ Create a S3 Access key for a specific user.\n - Permission "s3_key:create" required. + Permission `s3_key:create` required. \f Parameters ---------- @@ -93,23 +93,21 @@ async def create_user_key( Returns ------- - key : app.schemas.user.S3Key + key : app.schemas.s3key.S3Key Newly created S3 key. """ - trace.get_current_span().set_attribute("uid", user.uid) + trace.get_current_span().set_attribute("uid", str(user.uid)) if current_user.uid != user.uid: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Action forbidden.") await authorization("create") - with tracer.start_as_current_span("rgw_list_keys") as span: - span.set_attribute("uid", user.uid) + with tracer.start_as_current_span("rgw_list_keys", attributes={"uid": str(user.uid)}): before_keys_set = set(map(lambda key: key.access_key, get_s3_keys(rgw, user.uid))) - with tracer.start_as_current_span("rgw_create_key") as span: - span.set_attribute("uid", user.uid) + with tracer.start_as_current_span("rgw_create_key", attributes={"uid": str(user.uid)}): # create keys returns all keys for a user including the new one after_keys = rgw.create_key(uid=user.uid, key_type="s3", generate_key=True) new_key_id = list(set(map(lambda key: key["access_key"], after_keys)) - before_keys_set)[0] # find ID of the key index = [key["access_key"] for key in after_keys].index(new_key_id) # find new key by ID - return S3Key(**after_keys[index]) + return S3Key(uid=user.uid, **after_keys[index]) @router.get( @@ -127,7 +125,7 @@ async def get_user_key( ) -> S3Key: """ Get a specific S3 Access Key for a specific user.\n - Permission "s3_key:read" required. + Permission `s3_key:read` required. \f Parameters ---------- @@ -144,10 +142,10 @@ async def get_user_key( Returns ------- - key : app.schemas.user.S3Key + key : app.schemas.s3key.S3Key Requested S3 key. """ - trace.get_current_span().set_attribute("uid", user.uid) + trace.get_current_span().set_attribute("uid", str(user.uid)) if current_user.uid != user.uid: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Action forbidden.") await authorization("read") @@ -174,7 +172,7 @@ async def delete_user_key( ) -> None: """ Delete a specific S3 Access key for a specific user.\n - Permission "s3_key:delete" required if the current user is the target, otherwise "s3_key:delete_any" required. + Permission `s3_key:delete` required if the current user is the target, otherwise `s3_key:delete_any` required. \f Parameters ---------- @@ -189,13 +187,12 @@ async def delete_user_key( current_user : clowmdb.models.User Current user who will be the owner of the newly created bucket. Dependency Injection. """ - trace.get_current_span().set_attribute("uid", user.uid) + trace.get_current_span().set_attribute("uid", str(user.uid)) await authorization("delete" if current_user.uid == user.uid else "delete_any") if len(get_s3_keys(rgw, user.uid)) <= 1: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="It's not possible to delete the last key") try: - with tracer.start_as_current_span("rgw_delete_key") as span: - span.set_attribute("uid", user.uid) - rgw.remove_key(access_key=access_id, uid=user.uid) + with tracer.start_as_current_span("rgw_delete_key", attributes={"uid": str(user.uid)}): + rgw.remove_key(access_key=access_id, uid=str(user.uid)) except RGWAdminException: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Key not found") diff --git a/app/ceph/rgw.py b/app/ceph/rgw.py index 417fae6ec423b0621fc87e9f6918496ad63ac785..a93c7638a5c5d3cf4d00a20c476a59b7bcda12fd 100644 --- a/app/ceph/rgw.py +++ b/app/ceph/rgw.py @@ -1,10 +1,11 @@ from typing import List +from uuid import UUID from opentelemetry import trace from rgwadmin import RGWAdmin from app.core.config import settings -from app.schemas.user import S3Key +from app.schemas.s3key import S3Key tracer = trace.get_tracer_provider().get_tracer(__name__) @@ -16,7 +17,6 @@ rgw = RGWAdmin( ) -def get_s3_keys(rgw: RGWAdmin, uid: str) -> List[S3Key]: - with tracer.start_as_current_span("s3_get_user_keys") as span: - span.set_attribute("uid", uid) - return [S3Key(**key) for key in rgw.get_user(uid=uid, stats=False)["keys"]] +def get_s3_keys(rgw: RGWAdmin, uid: UUID) -> List[S3Key]: + with tracer.start_as_current_span("s3_get_user_keys", attributes={"uid": str(uid)}): + return [S3Key(uid=uid, **key) for key in rgw.get_user(uid=str(uid), stats=False)["keys"]] diff --git a/app/core/security.py b/app/core/security.py index 40e1febb83fc4308892c2ec6a038e04c10858640..a497a343e28b864620742411ef54a792de315c26 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -66,7 +66,7 @@ async def request_authorization(request_params: AuthzRequest, client: AsyncClien f"{settings.OPA_URI}v1/data{settings.OPA_POLICY_PATH}", json={"input": request_params.model_dump()} ) - parsed_response = AuthzResponse(**response.json()) + parsed_response = AuthzResponse.model_validate(response.json()) current_span.set_attribute("decision_id", str(parsed_response.decision_id)) if not parsed_response.result: # pragma: no cover raise HTTPException( diff --git a/app/crud/crud_bucket.py b/app/crud/crud_bucket.py index 875f304ca832d4fa4f463d7292e7b03f49b9b0ce..5327ded8a85bedefe70519b35931ff5ec667fb70 100644 --- a/app/crud/crud_bucket.py +++ b/app/crud/crud_bucket.py @@ -1,5 +1,6 @@ from enum import Enum, unique from typing import Optional, Sequence +from uuid import UUID from clowmdb.models import Bucket from clowmdb.models import BucketPermission as BucketPermissionDB @@ -8,7 +9,6 @@ from sqlalchemy import delete, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.crud import DuplicateError -from app.otlp import start_as_current_span_async from app.schemas.bucket import BucketIn as BucketInSchema tracer = trace.get_tracer_provider().get_tracer(__name__) @@ -30,7 +30,6 @@ class CRUDBucket: PERMISSION: str = "PERMISSION" @staticmethod - @start_as_current_span_async("db_get_bucket", tracer=tracer) async def get(db: AsyncSession, bucket_name: str) -> Optional[Bucket]: """ Get a bucket by its name. @@ -48,19 +47,19 @@ class CRUDBucket: Returns the bucket if it exists, None otherwise. """ stmt = select(Bucket).where(Bucket.name == bucket_name) - trace.get_current_span().set_attributes({"bucket_name": bucket_name, "sql_query": str(stmt)}) - return await db.scalar(stmt) + with tracer.start_as_current_span( + "db_get_bucket", attributes={"bucket_name": bucket_name, "sql_query": str(stmt)} + ): + return await db.scalar(stmt) @staticmethod - @start_as_current_span_async("db_list_all_buckets", tracer=tracer) async def get_all(db: AsyncSession) -> Sequence[Bucket]: stmt = select(Bucket) - trace.get_current_span().set_attribute("sql_query", str(stmt)) - return (await db.scalars(stmt)).all() + with tracer.start_as_current_span("db_list_all_buckets", attributes={"sql_query": str(stmt)}): + return (await db.scalars(stmt)).all() @staticmethod - @start_as_current_span_async("db_list_buckets_for_user", tracer=tracer) - async def get_for_user(db: AsyncSession, uid: str, bucket_type: BucketType = BucketType.ALL) -> Sequence[Bucket]: + async def get_for_user(db: AsyncSession, uid: UUID, bucket_type: BucketType = BucketType.ALL) -> Sequence[Bucket]: """ Get all buckets for a user. Depending on the `bucket_type`, the user is either owner of the bucket or has permission for the bucket @@ -114,8 +113,8 @@ class CRUDBucket: if bucket_type == CRUDBucket.BucketType.ALL: stmt = stmt.where( or_( - Bucket.owner_id == uid, - Bucket.permissions.any(BucketPermissionDB.user_id == uid) + Bucket._owner_id == uid.bytes, + Bucket.permissions.any(BucketPermissionDB._uid == uid.bytes) .where( or_( func.UNIX_TIMESTAMP() >= BucketPermissionDB.from_, @@ -131,10 +130,10 @@ class CRUDBucket: ) ) elif bucket_type == CRUDBucket.BucketType.OWN: - stmt = stmt.where(Bucket.owner_id == uid) + stmt = stmt.where(Bucket._owner_id == uid.bytes) else: stmt = stmt.where( - Bucket.permissions.any(BucketPermissionDB.user_id == uid) + Bucket.permissions.any(BucketPermissionDB._uid == uid.bytes) .where( or_( func.UNIX_TIMESTAMP() >= BucketPermissionDB.from_, @@ -148,12 +147,14 @@ class CRUDBucket: ) ), ) - trace.get_current_span().set_attributes({"sql_query": str(stmt), "uid": uid, "bucket_type": bucket_type.name}) - return (await db.scalars(stmt)).all() + with tracer.start_as_current_span( + "db_list_buckets_for_user", + attributes={"sql_query": str(stmt), "uid": str(uid), "bucket_type": bucket_type.name}, + ): + return (await db.scalars(stmt)).all() @staticmethod - @start_as_current_span_async("db_create_bucket", tracer=tracer) - async def create(db: AsyncSession, bucket_in: BucketInSchema, uid: str) -> Bucket: + async def create(db: AsyncSession, bucket_in: BucketInSchema, uid: UUID) -> Bucket: """ Create a bucket for a given user. @@ -171,18 +172,19 @@ class CRUDBucket: bucket : clowmdb.models.Bucket Returns the created bucket. """ - bucket = Bucket(**bucket_in.model_dump(), owner_id=uid) - current_span = trace.get_current_span() - current_span.set_attribute("bucket_name", bucket.name) - if await CRUDBucket.get(db, bucket.name) is not None: - raise DuplicateError(f"Bucket {bucket.name} exists already") - db.add(bucket) - await db.commit() - await db.refresh(bucket) - return bucket + bucket = Bucket(**bucket_in.model_dump(), _owner_id=uid.bytes) + with tracer.start_as_current_span( + "db_create_bucket", + attributes={"uid": str(uid), "bucket_name": bucket.name}, + ): + if await CRUDBucket.get(db, bucket.name) is not None: + raise DuplicateError(f"Bucket {bucket.name} exists already") + db.add(bucket) + await db.commit() + await db.refresh(bucket) + return bucket @staticmethod - @start_as_current_span_async("db_delete_bucket", tracer=tracer) async def delete(db: AsyncSession, bucket_name: str) -> None: """ Delete a specific bucket. @@ -195,6 +197,9 @@ class CRUDBucket: The name of the bucket to delete. """ stmt = delete(Bucket).where(Bucket.name == bucket_name) - trace.get_current_span().set_attributes({"sql_query": str(stmt), "bucket_name": bucket_name}) - await db.execute(stmt) - await db.commit() + with tracer.start_as_current_span( + "db_delete_bucket", + attributes={"sql_query": str(stmt), "bucket_name": bucket_name}, + ): + await db.execute(stmt) + await db.commit() diff --git a/app/crud/crud_bucket_permission.py b/app/crud/crud_bucket_permission.py index 6b32bf6895e9dedffbffc24370aeba9243509a9d..e41b3c508bb466b238340215d2c8656b93fdb25f 100644 --- a/app/crud/crud_bucket_permission.py +++ b/app/crud/crud_bucket_permission.py @@ -1,5 +1,6 @@ from enum import Enum, unique from typing import List, Optional, Sequence +from uuid import UUID from clowmdb.models import BucketPermission as BucketPermissionDB from opentelemetry import trace @@ -30,20 +31,22 @@ class CRUDBucketPermission: INACTIVE: str = "INACTIVE" @staticmethod - @start_as_current_span_async("db_get_bucket_permission", tracer=tracer) - async def get(db: AsyncSession, bucket_name: str, uid: str) -> Optional[BucketPermissionDB]: + async def get(db: AsyncSession, bucket_name: str, uid: UUID) -> Optional[BucketPermissionDB]: stmt = select(BucketPermissionDB).where( - BucketPermissionDB.user_id == uid, BucketPermissionDB.bucket_name == bucket_name + BucketPermissionDB._uid == uid.bytes, BucketPermissionDB.bucket_name == bucket_name ) - trace.get_current_span().set_attributes({"sql_query": str(stmt), "bucket_name": bucket_name, "uid": uid}) - return await db.scalar(stmt) + with tracer.start_as_current_span( + "db_get_bucket_permission", + attributes={"sql_query": str(stmt), "bucket_name": bucket_name, "uid": str(uid)}, + ): + return await db.scalar(stmt) @staticmethod @start_as_current_span_async("db_list_bucket_permissions", tracer=tracer) async def list( db: AsyncSession, bucket_name: Optional[str] = None, - uid: Optional[str] = None, + uid: Optional[UUID] = None, permission_types: Optional[List[BucketPermissionDB.Permission]] = None, permission_status: Optional[PermissionStatus] = None, ) -> Sequence[BucketPermissionDB]: @@ -77,8 +80,8 @@ class CRUDBucketPermission: BucketPermissionDB.bucket_name == bucket_name ) if uid is not None: - current_span.set_attribute("uid", uid) - stmt = stmt.where(BucketPermissionDB.user_id == uid) + current_span.set_attribute("uid", str(uid)) + stmt = stmt.where(BucketPermissionDB._uid == uid.bytes) if permission_types is not None and len(permission_types) > 0: current_span.set_attribute("permission_types", [ptype.name for ptype in permission_types]) stmt = stmt.where(or_(*[BucketPermissionDB.permissions == p_type for p_type in permission_types])) @@ -89,8 +92,7 @@ class CRUDBucketPermission: return (await db.scalars(stmt)).all() @staticmethod - @start_as_current_span_async("db_check_bucket_permission", tracer=tracer) - async def check_permission(db: AsyncSession, bucket_name: str, uid: str) -> bool: + async def check_permission(db: AsyncSession, bucket_name: str, uid: UUID) -> bool: """ Check if the provided user has any permission to the provided bucket. @@ -108,9 +110,12 @@ class CRUDBucketPermission: permission_check : bool Return True if the user has any permission on the bucket, False otherwise. """ - trace.get_current_span().set_attributes({"uid": uid, "bucket_name": bucket_name}) - buckets = await CRUDBucket.get_for_user(db, uid, bucket_type=CRUDBucket.BucketType.ALL) - return bucket_name in map(lambda x: x.name, buckets) + with tracer.start_as_current_span( + "db_check_bucket_permission", + attributes={"uid": str(uid), "bucket_name": bucket_name}, + ): + buckets = await CRUDBucket.get_for_user(db, uid, bucket_type=CRUDBucket.BucketType.ALL) + return bucket_name in map(lambda x: x.name, buckets) @staticmethod @start_as_current_span_async("db_create_bucket_permission", tracer=tracer) @@ -129,26 +134,26 @@ class CRUDBucketPermission: permission : clowmdb.models.BucketPermission Newly created permission model from the db. """ - trace.get_current_span().set_attributes({"bucket_name": permission.bucket_name, "uid": permission.uid}) + trace.get_current_span().set_attributes({"bucket_name": permission.bucket_name, "uid": str(permission.uid)}) # Check if user exists user = await CRUDUser.get(db, uid=permission.uid) if user is None: raise KeyError( - f"Unknown user with uid {permission.uid}", + f"Unknown user with uid {str(permission.uid)}", ) # Check that grantee is not the owner of the bucket bucket = await CRUDBucket.get(db, permission.bucket_name) if bucket is None or bucket.owner_id == user.uid: - raise ValueError(f"User {permission.uid} is the owner of the bucket {permission.bucket_name}") + raise ValueError(f"User {str(permission.uid)} is the owner of the bucket {permission.bucket_name}") # Check if combination of user and bucket already exists previous_permission = await CRUDBucketPermission.get(db, bucket_name=permission.bucket_name, uid=user.uid) if previous_permission is not None: raise DuplicateError( - f"bucket permission for combination {permission.bucket_name} {permission.uid} already exists." + f"bucket permission for combination {permission.bucket_name} {str(permission.uid)} already exists." ) # Add permission to db permission_db = BucketPermissionDB( - user_id=user.uid, + _uid=user.uid.bytes, bucket_name=permission.bucket_name, from_=permission.from_timestamp, to=permission.to_timestamp, @@ -161,8 +166,7 @@ class CRUDBucketPermission: return permission_db @staticmethod - @start_as_current_span_async("db_delete_bucket_permission", tracer=tracer) - async def delete(db: AsyncSession, bucket_name: str, uid: str) -> None: + async def delete(db: AsyncSession, bucket_name: str, uid: UUID) -> None: """ Delete a permission in the database. @@ -176,14 +180,16 @@ class CRUDBucketPermission: UID of the user. """ stmt = delete(BucketPermissionDB).where( - BucketPermissionDB.user_id == uid, BucketPermissionDB.bucket_name == bucket_name + BucketPermissionDB._uid == uid.bytes, BucketPermissionDB.bucket_name == bucket_name ) - trace.get_current_span().set_attributes({"bucket_name": bucket_name, "uid": uid, "sql_query": str(stmt)}) - await db.execute(stmt) - await db.commit() + with tracer.start_as_current_span( + "db_delete_bucket_permission", + attributes={"bucket_name": bucket_name, "uid": str(uid), "sql_query": str(stmt)}, + ): + await db.execute(stmt) + await db.commit() @staticmethod - @start_as_current_span_async("db_update_bucket_permission", tracer=tracer) async def update_permission( db: AsyncSession, permission: BucketPermissionDB, new_params: BucketPermissionParametersSchema ) -> BucketPermissionDB: @@ -208,7 +214,7 @@ class CRUDBucketPermission: update(BucketPermissionDB) .where( BucketPermissionDB.bucket_name == permission.bucket_name, - BucketPermissionDB.user_id == permission.user_id, + BucketPermissionDB._uid == permission.uid.bytes, ) .values( from_=new_params.from_timestamp, @@ -217,13 +223,14 @@ class CRUDBucketPermission: permissions=new_params.permission, ) ) - trace.get_current_span().set_attributes( - {"sql_query": str(stmt), "bucket_name": permission.bucket_name, "uid": permission.user_id} - ) - await db.execute(stmt) - await db.commit() - await db.refresh(permission) - return permission + with tracer.start_as_current_span( + "db_update_bucket_permission", + attributes={"sql_query": str(stmt), "bucket_name": permission.bucket_name, "uid": str(permission.uid)}, + ): + await db.execute(stmt) + await db.commit() + await db.refresh(permission) + return permission @staticmethod def _filter_permission_status(stmt: SQLSelect, permission_status: PermissionStatus) -> SQLSelect: diff --git a/app/crud/crud_user.py b/app/crud/crud_user.py index 34151eb1b24567eb0eaf1243c0117c2430e07fba..9138d7774ecf8eede39b10c75d664bd0fecdb43b 100644 --- a/app/crud/crud_user.py +++ b/app/crud/crud_user.py @@ -1,4 +1,5 @@ from typing import Optional +from uuid import UUID from clowmdb.models import User from opentelemetry import trace @@ -10,7 +11,7 @@ tracer = trace.get_tracer_provider().get_tracer(__name__) class CRUDUser: @staticmethod - async def get(db: AsyncSession, uid: str) -> Optional[User]: + async def get(db: AsyncSession, uid: UUID) -> Optional[User]: """ Get a user by its UID. @@ -26,7 +27,6 @@ class CRUDUser: user : clowmdb.models.User | None The user for the given UID if he exists, None otherwise """ - with tracer.start_as_current_span("db_get_user") as span: - stmt = select(User).where(User.uid == uid) - span.set_attribute("sql_query", str(stmt)) + stmt = select(User).where(User._uid == uid.bytes) + with tracer.start_as_current_span("db_get_user", attributes={"uid": str(uid), "sql_query": str(stmt)}): return await db.scalar(stmt) diff --git a/app/schemas/bucket.py b/app/schemas/bucket.py index 4e1790d0563eb1b23d692d9bad94183802836328..c30770b7c515f734eac3ace2356c74a78c944116 100644 --- a/app/schemas/bucket.py +++ b/app/schemas/bucket.py @@ -1,5 +1,6 @@ import re from typing import Optional +from uuid import UUID from clowmdb.models import Bucket from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -53,7 +54,7 @@ class BucketOut(_BaseBucket): examples=[1640991600], # 01.01.2022 00:00 description="Time when the bucket was created as UNIX timestamp", ) - owner: str = Field(..., description="UID of the owner", examples=["28c5353b8bb34984a8bd4169ba94c606"]) + owner_id: UUID = Field(..., description="UID of the owner", examples=["1d3387f3-95c0-4813-8767-2cad87faeebf"]) num_objects: int = Field(..., description="Number of Objects in this bucket", examples=[6]) size: int = Field(..., description="Total size of objects in this bucket in bytes", examples=[3256216]) owner_constraint: Optional[Bucket.Constraint] = Field(None, description="Constraint for the owner of the bucket") diff --git a/app/schemas/bucket_permission.py b/app/schemas/bucket_permission.py index e4fa93f6a2c275822da96d22b6e80ef7590cb36b..99217183c251d9d139e0067eda9fc5767d35eaa2 100644 --- a/app/schemas/bucket_permission.py +++ b/app/schemas/bucket_permission.py @@ -1,9 +1,10 @@ import hashlib from datetime import datetime from typing import Any, Dict, List, Optional, Union +from uuid import UUID from clowmdb.models import BucketPermission as BucketPermissionDB -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_serializer class BucketPermissionParameters(BaseModel): @@ -28,16 +29,20 @@ class BucketPermissionParameters(BaseModel): class BucketPermissionIn(BucketPermissionParameters): - uid: str = Field(..., description="UID of the grantee", examples=["28c5353b8bb34984a8bd4169ba94c606"]) + uid: UUID = Field(..., description="UID of the grantee", examples=["1d3387f3-95c0-4813-8767-2cad87faeebf"]) bucket_name: str = Field(..., description="Name of Bucket", examples=["test-bucket"]) - def to_hash(self, user_id: str) -> str: + @field_serializer("uid") + def serialize_uid(self, uid: UUID, _info: Any) -> str: + return str(uid) + + def to_hash(self, uid: UUID) -> str: """ Combine the bucket name and user id and produce the MD5 hash of it. Parameters ---------- - user_id : str + uid : uuid.UUID The unique and unchanging user id Returns @@ -45,17 +50,18 @@ class BucketPermissionIn(BucketPermissionParameters): hash : str The resulting MD5 hash. """ - str_for_id_hash = self.bucket_name + user_id - return hashlib.md5(str_for_id_hash.encode("utf-8")).hexdigest() + hasher = hashlib.md5(self.bucket_name.encode("utf-8")) + hasher.update(uid.bytes) + return hasher.hexdigest() - def map_to_bucket_policy_statement(self, user_id: str) -> List[Dict[str, Any]]: + def map_to_bucket_policy_statement(self, uid: UUID) -> List[Dict[str, Any]]: """ Create a bucket policy statement from the schema and the user_id.\n The Sid is unique for every bucket and user combination. Parameters ---------- - user_id : str + uid : uuid.UUID The unique and unchanging user id belonging to this permission. Returns @@ -63,18 +69,19 @@ class BucketPermissionIn(BucketPermissionParameters): statements : List[Dict[str, Any]] Bucket and object permission statements. """ + own_hash = self.to_hash(uid) obj_policy: Dict[str, Any] = { - "Sid": self.to_hash(user_id), + "Sid": own_hash, "Effect": "Allow", - "Principal": {"AWS": f"arn:aws:iam:::user/{self.uid}"}, + "Principal": {"AWS": f"arn:aws:iam:::user/{str(self.uid)}"}, "Resource": f"arn:aws:s3:::{self.bucket_name}/{'' if self.file_prefix is None else self.file_prefix}*", "Action": [], "Condition": {}, } bucket_policy: Dict[str, Any] = { - "Sid": self.to_hash(user_id), + "Sid": own_hash, "Effect": "Allow", - "Principal": {"AWS": f"arn:aws:iam:::user/{self.uid}"}, + "Principal": {"AWS": f"arn:aws:iam:::user/{str(self.uid)}"}, "Resource": f"arn:aws:s3:::{self.bucket_name}", "Action": [], "Condition": {}, @@ -117,12 +124,8 @@ class BucketPermissionOut(BucketPermissionIn): Schema for the bucket permissions. """ - grantee_display_name: str = Field(..., description="Display Name of the grantee", examples=["Bilbo Baggins"]) - @staticmethod - def from_db_model( - permission: BucketPermissionDB, uid: Optional[str] = None, grantee_display_name: Optional[str] = None - ) -> "BucketPermissionOut": + def from_db_model(permission: BucketPermissionDB) -> "BucketPermissionOut": """ Create a bucket permission schema from the database model. @@ -130,10 +133,6 @@ class BucketPermissionOut(BucketPermissionIn): ---------- permission : clowmdb.models.BucketPermission DB model for the permission. - uid : str | None, default None - Sets the uid in the schema. If None it will be taken from the database model. - grantee_display_name: str | None, default None - Sets the display name of the grantee in the schema. If None it will be taken from the database model. Returns ------- @@ -141,8 +140,7 @@ class BucketPermissionOut(BucketPermissionIn): Schema populated with the values from the database model. """ return BucketPermissionOut( - uid=uid if uid else permission.grantee.uid, - grantee_display_name=grantee_display_name if grantee_display_name else permission.grantee.display_name, + uid=permission.uid, bucket_name=permission.bucket_name, from_timestamp=permission.from_, to_timestamp=permission.to, diff --git a/app/schemas/user.py b/app/schemas/s3key.py similarity index 59% rename from app/schemas/user.py rename to app/schemas/s3key.py index c9ca4704b89afe36a3ac3ece46224fc908927d78..eae80d27a78ccd1eb46149f1a6eed815f2a6e860 100644 --- a/app/schemas/user.py +++ b/app/schemas/s3key.py @@ -1,4 +1,7 @@ -from pydantic import BaseModel, Field +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, Field, field_serializer class S3Key(BaseModel): @@ -6,8 +9,8 @@ class S3Key(BaseModel): Schema for a S3 key associated with a user. """ - user: str = Field( - ..., description="UID of the user of that access key", examples=["28c5353b8bb34984a8bd4169ba94c606"] + uid: UUID = Field( + ..., description="UID of the user of that access key", examples=["1d3387f3-95c0-4813-8767-2cad87faeebf"] ) access_key: str = Field(..., description="ID of the S3 access key", examples=["CRJ6B037V2ZT4U3W17VC"]) secret_key: str = Field( @@ -15,3 +18,7 @@ class S3Key(BaseModel): description="Secret of the S3 access key", examples=["2F5uNTI1qvt4oAroXV0wWct8rWclL2QvFXKqSqjS"], ) + + @field_serializer("uid") + def serialize_uid(self, uid: UUID, _info: Any) -> str: + return str(uid) diff --git a/app/schemas/security.py b/app/schemas/security.py index 3c0f596a45e25b0eaa0ea6b86f961733c0d7e3c7..a6d7bd8293e64f047eeff0369271b577612a74e5 100644 --- a/app/schemas/security.py +++ b/app/schemas/security.py @@ -1,23 +1,29 @@ from datetime import datetime +from typing import Any +from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_serializer class AuthzResponse(BaseModel): """Schema for a response from OPA""" - decision_id: str = Field( + decision_id: UUID = Field( ..., description="Decision ID for for the specific decision", examples=["8851dce0-7546-4e81-a89d-111cbec376c1"], ) result: bool = Field(..., description="Result of the Authz request") + @field_serializer("decision_id") + def serialize_decision_id(self, decision_id: UUID, _info: Any) -> str: + return str(decision_id) + class AuthzRequest(BaseModel): """Schema for a Request to OPA""" - uid: str = Field(..., description="UID of user", examples=["28c5353b8bb34984a8bd4169ba94c606"]) + uid: str = Field(..., description="lifescience id of user", examples=["28c5353b8bb34984a8bd4169ba94c606"]) operation: str = Field(..., description="Operation the user wants to perform", examples=["read"]) resource: str = Field(..., description="Resource the operation should be performed on", examples=["bucket"]) diff --git a/app/tests/api/test_bucket_permissions.py b/app/tests/api/test_bucket_permissions.py index e209798f43a5e3629207f47014e90b9078c1384a..6ea86c774cf1bc379fa3e00856cb54b4b4bd340e 100644 --- a/app/tests/api/test_bucket_permissions.py +++ b/app/tests/api/test_bucket_permissions.py @@ -1,5 +1,6 @@ import json from datetime import datetime +from uuid import uuid4 import pytest from clowmdb.models import Bucket, BucketPermission @@ -9,6 +10,7 @@ from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.schemas.bucket_permission import BucketPermissionIn as BucketPermissionSchema +from app.schemas.bucket_permission import BucketPermissionOut from app.schemas.bucket_permission import BucketPermissionParameters as BucketPermissionParametersSchema from app.tests.utils.bucket import add_permission_for_bucket from app.tests.utils.user import UserWithAuthHeader @@ -40,16 +42,16 @@ class TestBucketPermissionRoutesGet(_TestBucketPermissionRoutes): Bucket permission for a random bucket for testing. pytest fixture. """ response = await client.get( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{random_bucket_permission_schema.uid}", # noqa:E501 + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(random_bucket_permission_schema.uid)}", # noqa:E501 headers=random_user.auth_headers, ) assert response.status_code == status.HTTP_200_OK - permission = response.json() + permission = BucketPermissionOut.model_validate(response.json()) assert permission - assert permission["uid"] == random_bucket_permission_schema.uid - assert permission["bucket_name"] == random_bucket_permission_schema.bucket_name + assert permission.uid == random_bucket_permission_schema.uid + assert permission.bucket_name == random_bucket_permission_schema.bucket_name @pytest.mark.asyncio async def test_get_bucket_permission_for_unknown_user( @@ -71,7 +73,7 @@ class TestBucketPermissionRoutesGet(_TestBucketPermissionRoutes): Bucket permission for a random bucket for testing. pytest fixture. """ response = await client.get( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/ImpossibleUser", + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(uuid4())}", headers=random_user.auth_headers, ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -99,7 +101,7 @@ class TestBucketPermissionRoutesGet(_TestBucketPermissionRoutes): Random second user for testing. pytest fixture. """ response = await client.get( - f"{self.base_path}/bucket/{random_bucket.name}/user/{random_second_user.user.uid}", + f"{self.base_path}/bucket/{random_bucket.name}/user/{str(random_second_user.user.uid)}", headers=random_user.auth_headers, ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -124,15 +126,15 @@ class TestBucketPermissionRoutesGet(_TestBucketPermissionRoutes): Random second user for testing. pytest fixture. """ response = await client.get( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{random_second_user.user.uid}", + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(random_second_user.user.uid)}", headers=random_second_user.auth_headers, ) assert response.status_code == status.HTTP_200_OK - permission = response.json() + permission = BucketPermissionOut.model_validate(response.json()) assert permission - assert permission["uid"] == random_bucket_permission_schema.uid - assert permission["bucket_name"] == random_bucket_permission_schema.bucket_name + assert permission.uid == random_bucket_permission_schema.uid + assert permission.bucket_name == random_bucket_permission_schema.bucket_name @pytest.mark.asyncio async def test_get_wrong_bucket_permission_with_permission( @@ -157,8 +159,9 @@ class TestBucketPermissionRoutesGet(_TestBucketPermissionRoutes): Random third user who has no permissions for the bucket. pytest fixture. """ await add_permission_for_bucket(db, random_bucket_permission_schema.bucket_name, random_third_user.user.uid) + response = await client.get( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{random_bucket_permission_schema.uid}", # noqa:E501 + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(random_bucket_permission_schema.uid)}", # noqa:E501 headers=random_third_user.auth_headers, ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -183,16 +186,44 @@ class TestBucketPermissionRoutesGet(_TestBucketPermissionRoutes): Bucket permission for a random bucket for testing. pytest fixture. """ response = await client.get( - f"{self.base_path}/user/{random_bucket_permission_schema.uid}", + f"{self.base_path}/user/{str(random_bucket_permission_schema.uid)}", headers=random_second_user.auth_headers, ) assert response.status_code == status.HTTP_200_OK permission_list = response.json() assert isinstance(permission_list, list) assert len(permission_list) == 1 - permission = permission_list[0] - assert permission["uid"] == random_bucket_permission_schema.uid - assert permission["bucket_name"] == random_bucket_permission_schema.bucket_name + permission = BucketPermissionOut.model_validate(permission_list[0]) + assert permission.uid == random_bucket_permission_schema.uid + assert permission.bucket_name == random_bucket_permission_schema.bucket_name + + @pytest.mark.asyncio + async def test_get_all_bucket_permissions( + self, + client: AsyncClient, + random_user: UserWithAuthHeader, + random_bucket_permission_schema: BucketPermissionSchema, + ) -> None: + """ + Test for getting all bucket permission for a user. + + Parameters + ---------- + client : httpx.AsyncClient + HTTP Client to perform the request on. pytest fixture. + random_user : app.tests.utils.user.UserWithAuthHeader + Random second user for testing. pytest fixture. + random_bucket_permission_schema : app.schemas.bucket_permission.BucketPermissionOut + Bucket permission for a random bucket for testing. pytest fixture. + """ + response = await client.get(self.base_path, headers=random_user.auth_headers, params={"allow_admin": True}) + assert response.status_code == status.HTTP_200_OK + permission_list = response.json() + assert isinstance(permission_list, list) + assert len(permission_list) == 1 + permission = BucketPermissionOut.model_validate(permission_list[0]) + assert permission.uid == random_bucket_permission_schema.uid + assert permission.bucket_name == random_bucket_permission_schema.bucket_name @pytest.mark.asyncio async def test_get_bucket_permissions_for_bucket( @@ -221,9 +252,9 @@ class TestBucketPermissionRoutesGet(_TestBucketPermissionRoutes): permission_list = response.json() assert isinstance(permission_list, list) assert len(permission_list) == 1 - permission = permission_list[0] - assert permission["uid"] == random_bucket_permission_schema.uid - assert permission["bucket_name"] == random_bucket_permission_schema.bucket_name + permission = BucketPermissionOut.model_validate(permission_list[0]) + assert permission.uid == random_bucket_permission_schema.uid + assert permission.bucket_name == random_bucket_permission_schema.bucket_name @pytest.mark.asyncio async def test_get_bucket_permissions_for_foreign_bucket( @@ -268,7 +299,7 @@ class TestBucketPermissionRoutesCreate(_TestBucketPermissionRoutes): random_bucket : clowmdb.models.Bucket Random bucket for testing. pytest fixture. """ - permission = BucketPermissionSchema(bucket_name=random_bucket.name, uid="ImpossibleUser") + permission = BucketPermissionSchema(bucket_name=random_bucket.name, uid=uuid4()) response = await client.post(self.base_path, headers=random_user.auth_headers, json=permission.model_dump()) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -352,6 +383,7 @@ class TestBucketPermissionRoutesCreate(_TestBucketPermissionRoutes): random_user: UserWithAuthHeader, random_second_user: UserWithAuthHeader, random_bucket: Bucket, + db: AsyncSession, ) -> None: """ Test for creating a valid bucket permission. @@ -366,15 +398,17 @@ class TestBucketPermissionRoutesCreate(_TestBucketPermissionRoutes): Random second user for testing. pytest fixture. random_bucket : clowmdb.models.Bucket Random bucket for testing. pytest fixture. + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. pytest fixture. """ permission = BucketPermissionSchema(bucket_name=random_bucket.name, uid=random_second_user.user.uid) response = await client.post(self.base_path, headers=random_user.auth_headers, json=permission.model_dump()) + assert response.status_code == status.HTTP_201_CREATED - created_permission = response.json() - assert created_permission["uid"] == random_second_user.user.uid - assert created_permission["bucket_name"] == random_bucket.name - assert created_permission["grantee_display_name"] == random_second_user.user.display_name + created_permission = BucketPermissionOut.model_validate(response.json()) + assert created_permission.uid == random_second_user.user.uid + assert created_permission.bucket_name == random_bucket.name @pytest.mark.asyncio async def test_create_bucket_permissions_on_initial_bucket( @@ -433,7 +467,7 @@ class TestBucketPermissionRoutesDelete(_TestBucketPermissionRoutes): Bucket permission for a random bucket for testing. pytest fixture. """ response = await client.delete( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{random_bucket_permission_schema.uid}", # noqa:E501 + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(random_bucket_permission_schema.uid)}", # noqa:E501 headers=random_user.auth_headers, ) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -458,7 +492,7 @@ class TestBucketPermissionRoutesDelete(_TestBucketPermissionRoutes): Bucket permission for a random bucket for testing. pytest fixture. """ response = await client.get( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{random_bucket_permission_schema.uid}", # noqa:E501 + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(random_bucket_permission_schema.uid)}", # noqa:E501 headers=random_second_user.auth_headers, ) assert response.status_code == status.HTTP_200_OK @@ -481,7 +515,7 @@ class TestBucketPermissionRoutesDelete(_TestBucketPermissionRoutes): Bucket permission for a random bucket for testing. pytest fixture. """ response = await client.delete( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/ImpossibleUser", + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(uuid4())}", headers=random_user.auth_headers, ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -507,7 +541,7 @@ class TestBucketPermissionRoutesDelete(_TestBucketPermissionRoutes): Random second user for testing. pytest fixture. """ response = await client.delete( - f"{self.base_path}/bucket/{random_bucket.name}/user/{random_second_user.user.uid}", + f"{self.base_path}/bucket/{random_bucket.name}/user/{str(random_second_user.user.uid)}", headers=random_user.auth_headers, ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -536,7 +570,7 @@ class TestBucketPermissionRoutesDelete(_TestBucketPermissionRoutes): """ await add_permission_for_bucket(db, random_bucket_permission_schema.bucket_name, random_third_user.user.uid) response = await client.delete( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{random_bucket_permission_schema.uid}", # noqa:E501 + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(random_bucket_permission_schema.uid)}", # noqa:E501 headers=random_third_user.auth_headers, ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -568,19 +602,19 @@ class TestBucketPermissionRoutesUpdate(_TestBucketPermissionRoutes): file_prefix="pseudo/folder/", ) response = await client.put( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{random_bucket_permission_schema.uid}", # noqa:E501 + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(random_bucket_permission_schema.uid)}", # noqa:E501 headers=random_user.auth_headers, content=json.dumps(new_params.model_dump(), default=json_datetime_converter), ) assert response.status_code == status.HTTP_200_OK - updated_permission = response.json() - assert updated_permission["uid"] == random_bucket_permission_schema.uid - assert updated_permission["bucket_name"] == random_bucket_permission_schema.bucket_name + updated_permission = BucketPermissionOut.model_validate(response.json()) + assert updated_permission.uid == random_bucket_permission_schema.uid + assert updated_permission.bucket_name == random_bucket_permission_schema.bucket_name if new_params.from_timestamp is not None and new_params.to_timestamp is not None: - assert updated_permission["from_timestamp"] == new_params.from_timestamp - assert updated_permission["to_timestamp"] == new_params.to_timestamp - assert updated_permission["permission"] == new_params.permission - assert updated_permission["file_prefix"] == new_params.file_prefix + assert updated_permission.from_timestamp == new_params.from_timestamp + assert updated_permission.to_timestamp == new_params.to_timestamp + assert updated_permission.permission == new_params.permission + assert updated_permission.file_prefix == new_params.file_prefix @pytest.mark.asyncio async def test_update_unknown_bucket_permission( @@ -604,7 +638,7 @@ class TestBucketPermissionRoutesUpdate(_TestBucketPermissionRoutes): file_prefix="pseudo/folder/", ) response = await client.put( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/impossibleUser", + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(uuid4())}", headers=random_user.auth_headers, json=new_params.model_dump(), ) @@ -635,7 +669,7 @@ class TestBucketPermissionRoutesUpdate(_TestBucketPermissionRoutes): file_prefix="pseudo/folder/", ) response = await client.put( - f"{self.base_path}/bucket/{random_bucket.name}/user/{random_second_user.user.uid}", + f"{self.base_path}/bucket/{random_bucket.name}/user/{str(random_second_user.user.uid)}", headers=random_user.auth_headers, json=new_params.model_dump(), ) @@ -665,7 +699,7 @@ class TestBucketPermissionRoutesUpdate(_TestBucketPermissionRoutes): file_prefix="pseudo/folder/", ) response = await client.put( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{random_second_user.user.uid}", + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(random_second_user.user.uid)}", headers=random_second_user.auth_headers, json=new_params.model_dump(), ) @@ -695,7 +729,7 @@ class TestBucketPermissionRoutesUpdate(_TestBucketPermissionRoutes): file_prefix="pseudo/folder/", ) response = await client.put( - f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{random_bucket_permission_schema.uid}", # noqa:E501 + f"{self.base_path}/bucket/{random_bucket_permission_schema.bucket_name}/user/{str(random_bucket_permission_schema.uid)}", # noqa:E501 headers=random_third_user.auth_headers, json=new_params.model_dump(), ) diff --git a/app/tests/api/test_buckets.py b/app/tests/api/test_buckets.py index 0e9ab652b008f73b33109ac76d2bf0145be08f2d..a709921ceaca91bc897d31be22ef8ec370276e7e 100644 --- a/app/tests/api/test_buckets.py +++ b/app/tests/api/test_buckets.py @@ -5,9 +5,10 @@ from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.crud.crud_bucket import CRUDBucket -from app.schemas.bucket import BucketIn +from app.schemas.bucket import BucketIn, BucketOut from app.tests.mocks.mock_s3_resource import MockS3ServiceResource -from app.tests.utils.bucket import add_permission_for_bucket +from app.tests.utils.bucket import add_permission_for_bucket, delete_bucket +from app.tests.utils.cleanup import CleanupList from app.tests.utils.user import UserWithAuthHeader from app.tests.utils.utils import random_lower_string @@ -39,10 +40,10 @@ class TestBucketRoutesGet(_TestBucketRoutes): buckets = response.json() assert len(buckets) == 1 - bucket = buckets[0] + bucket = BucketOut.model_validate(buckets[0]) - assert bucket["name"].split(":")[-1] == random_bucket.name - assert bucket["owner"] == random_bucket.owner_id + assert bucket.name == random_bucket.name + assert bucket.owner_id == random_bucket.owner_id @pytest.mark.asyncio async def test_get_own_buckets( @@ -64,17 +65,17 @@ class TestBucketRoutesGet(_TestBucketRoutes): Random user for testing. pytest fixture. """ response = await client.get( - f"{self.base_path}", params={"user": random_bucket.owner_id}, headers=random_user.auth_headers + f"{self.base_path}", params={"owner_id": str(random_bucket.owner_id)}, headers=random_user.auth_headers ) assert response.status_code == status.HTTP_200_OK buckets = response.json() assert len(buckets) == 1 - bucket = buckets[0] + bucket = BucketOut.model_validate(buckets[0]) - assert bucket["name"].split(":")[-1] == random_bucket.name - assert bucket["owner"] == random_bucket.owner_id + assert bucket.name == random_bucket.name + assert bucket.owner_id == random_bucket.owner_id @pytest.mark.asyncio async def test_get_bucket_by_name( @@ -98,10 +99,10 @@ class TestBucketRoutesGet(_TestBucketRoutes): response = await client.get(f"{self.base_path}/{random_bucket.name}", headers=random_user.auth_headers) assert response.status_code == status.HTTP_200_OK - bucket = response.json() + bucket = BucketOut.model_validate(response.json()) - assert bucket["name"].split(":")[-1] == random_bucket.name - assert bucket["owner"] == random_bucket.owner.uid + assert bucket.name == random_bucket.name + assert bucket.owner_id == random_bucket.owner.uid @pytest.mark.asyncio async def test_get_unknown_bucket(self, client: AsyncClient, random_user: UserWithAuthHeader) -> None: @@ -145,6 +146,7 @@ class TestBucketRoutesCreate(_TestBucketRoutes): db: AsyncSession, client: AsyncClient, random_user: UserWithAuthHeader, + cleanup: CleanupList, ) -> None: """ Test for creating a bucket. @@ -157,23 +159,28 @@ class TestBucketRoutesCreate(_TestBucketRoutes): HTTP Client to perform the request on. pytest fixture. random_user : app.tests.utils.user.UserWithAuthHeader Random user for testing. pytest fixture. + cleanup : app.tests.utils.utils.CleanupList + Cleanup object where (async) functions can be registered which get executed after a (failed) test. """ bucket_info = BucketIn(name=random_lower_string(), description=random_lower_string(127)) + cleanup.add_task( + delete_bucket, + db=db, + bucket_name=bucket_info.name, + ) response = await client.post(self.base_path, headers=random_user.auth_headers, json=bucket_info.model_dump()) assert response.status_code == status.HTTP_201_CREATED - bucket = response.json() + bucket = BucketOut.model_validate(response.json()) assert bucket - assert bucket["name"].split(":")[-1] == bucket_info.name - assert bucket["owner"] == random_user.user.uid + assert bucket.name == bucket_info.name + assert bucket.owner_id == random_user.user.uid dbBucket = await CRUDBucket.get(db, bucket_info.name) assert dbBucket assert dbBucket.name == bucket_info.name assert dbBucket.owner_id == random_user.user.uid - await CRUDBucket.delete(db, dbBucket.name) - @pytest.mark.asyncio async def test_create_duplicated_bucket( self, @@ -232,6 +239,7 @@ class TestBucketRoutesDelete(_TestBucketRoutes): db: AsyncSession, random_user: UserWithAuthHeader, random_second_user: UserWithAuthHeader, + cleanup: CleanupList, ) -> None: """ Test for deleting a foreign bucket. @@ -244,14 +252,21 @@ class TestBucketRoutesDelete(_TestBucketRoutes): Random user for testing. pytest fixture. random_second_user : app.tests.utils.user.UserWithAuthHeader Random user which is not the owner of the bucket. pytest fixture. + cleanup : app.tests.utils.utils.CleanupList + Cleanup object where (async) functions can be registered which get executed after a (failed) test. """ bucket = Bucket( name=random_lower_string(), description=random_lower_string(127), - owner_id=random_second_user.user.uid, + _owner_id=random_second_user.user.uid.bytes, ) db.add(bucket) await db.commit() + cleanup.add_task( + delete_bucket, + db=db, + bucket_name=bucket.name, + ) await add_permission_for_bucket( db, bucket.name, random_user.user.uid, permission=BucketPermission.Permission.READWRITE ) @@ -262,8 +277,6 @@ class TestBucketRoutesDelete(_TestBucketRoutes): assert response.status_code == status.HTTP_403_FORBIDDEN - await db.delete(bucket) - @pytest.mark.asyncio async def test_delete_non_empty_bucket( self, diff --git a/app/tests/api/test_s3_keys.py b/app/tests/api/test_s3_keys.py index 2e060573b551ed8aa6d2a6bd00a327dba7ec77a1..1413da4aa9de94c44e6ec23ca97c4920e3df9ec5 100644 --- a/app/tests/api/test_s3_keys.py +++ b/app/tests/api/test_s3_keys.py @@ -2,6 +2,7 @@ import pytest from fastapi import status from httpx import AsyncClient +from app.schemas.s3key import S3Key from app.tests.mocks.mock_rgw_admin import MockRGWAdmin from app.tests.utils.user import UserWithAuthHeader @@ -31,7 +32,7 @@ class TestS3KeyRoutesGet(_TestS3KeyRoutes): Random foreign user for testing. pytest fixture. """ response = await client.get( - f"{self.base_path}/{random_second_user.user.uid}/keys", headers=random_user.auth_headers + f"{self.base_path}/{str(random_second_user.user.uid)}/keys", headers=random_user.auth_headers ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -52,12 +53,14 @@ class TestS3KeyRoutesGet(_TestS3KeyRoutes): random_user : app.tests.utils.user.UserWithAuthHeader Random user for testing. pytest fixture. """ - response = await client.get(f"{self.base_path}/{random_user.user.uid}/keys", headers=random_user.auth_headers) + response = await client.get( + f"{self.base_path}/{str(random_user.user.uid)}/keys", headers=random_user.auth_headers + ) keys = response.json() assert response.status_code == status.HTTP_200_OK assert isinstance(keys, list) assert len(keys) == 1 - assert keys[0]["user"] == random_user.user.uid + assert S3Key.model_validate(keys[0]).uid == random_user.user.uid @pytest.mark.asyncio async def test_get_specific_s3_key_for_user( @@ -78,15 +81,16 @@ class TestS3KeyRoutesGet(_TestS3KeyRoutes): mock_rgw_admin : app.tests.mocks.mock_rgw_admin.MockRGWAdmin Mock class for rgwadmin package. pytest fixture. """ - s3_key = mock_rgw_admin.get_user(uid=random_user.user.uid)["keys"][0] + s3_key = mock_rgw_admin.get_user(uid=str(random_user.user.uid))["keys"][0] response = await client.get( - f"{self.base_path}/{random_user.user.uid}/keys/{s3_key['access_key']}", headers=random_user.auth_headers + f"{self.base_path}/{str(random_user.user.uid)}/keys/{s3_key['access_key']}", + headers=random_user.auth_headers, ) - response_key = response.json() + response_key = S3Key.model_validate(response.json()) assert response.status_code == status.HTTP_200_OK - assert response_key["access_key"] == s3_key["access_key"] - assert response_key["secret_key"] == s3_key["secret_key"] - assert response_key["user"] == s3_key["user"] + assert response_key.access_key == s3_key["access_key"] + assert response_key.secret_key == s3_key["secret_key"] + assert str(response_key.uid) == s3_key["user"] @pytest.mark.asyncio async def test_get_specific_s3_key_from_foreign_user( @@ -110,9 +114,9 @@ class TestS3KeyRoutesGet(_TestS3KeyRoutes): mock_rgw_admin : app.tests.mocks.mock_rgw_admin.MockRGWAdmin Mock class for rgwadmin package. pytest fixture. """ - s3_key = mock_rgw_admin.get_user(uid=random_user.user.uid)["keys"][0] + s3_key = mock_rgw_admin.get_user(uid=str(random_user.user.uid))["keys"][0] response = await client.get( - f"{self.base_path}/{random_user.user.uid}/keys/{s3_key['access_key']}", + f"{self.base_path}/{str(random_user.user.uid)}/keys/{s3_key['access_key']}", headers=random_second_user.auth_headers, ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -130,7 +134,7 @@ class TestS3KeyRoutesGet(_TestS3KeyRoutes): Random user for testing. pytest fixture. """ response = await client.get( - f"{self.base_path}/{random_user.user.uid}/keys/impossible_key", headers=random_user.auth_headers + f"{self.base_path}/{str(random_user.user.uid)}/keys/impossible_key", headers=random_user.auth_headers ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -138,7 +142,10 @@ class TestS3KeyRoutesGet(_TestS3KeyRoutes): class TestS3KeyRoutesCreate(_TestS3KeyRoutes): @pytest.mark.asyncio async def test_create_s3_key_for_user( - self, client: AsyncClient, random_user: UserWithAuthHeader, mock_rgw_admin: MockRGWAdmin + self, + client: AsyncClient, + random_user: UserWithAuthHeader, + mock_rgw_admin: MockRGWAdmin, ) -> None: """ Test for getting a specific S3 key from a user. @@ -152,15 +159,15 @@ class TestS3KeyRoutesCreate(_TestS3KeyRoutes): mock_rgw_admin : app.tests.mocks.mock_rgw_admin.MockRGWAdmin Mock class for rgwadmin package. pytest fixture. """ - old_s3_key = mock_rgw_admin.get_user(uid=random_user.user.uid)["keys"][0] - response = await client.post(f"{self.base_path}/{random_user.user.uid}/keys", headers=random_user.auth_headers) - new_key = response.json() + old_s3_key = mock_rgw_admin.get_user(uid=str(random_user.user.uid))["keys"][0] + response = await client.post( + f"{self.base_path}/{str(random_user.user.uid)}/keys", headers=random_user.auth_headers + ) + new_key = S3Key.model_validate(response.json()) assert response.status_code == status.HTTP_201_CREATED - assert new_key["access_key"] != old_s3_key["access_key"] - assert new_key["user"] == random_user.user.uid - - mock_rgw_admin.remove_key(uid=random_user.user.uid, access_key=new_key["access_key"]) + assert new_key.access_key != old_s3_key["access_key"] + assert new_key.uid == random_user.user.uid @pytest.mark.asyncio async def test_create_s3_key_for_foreign_user( @@ -179,7 +186,7 @@ class TestS3KeyRoutesCreate(_TestS3KeyRoutes): Random second user for testing. pytest fixture. """ response = await client.post( - f"{self.base_path}/{random_second_user.user.uid}/keys", headers=random_user.auth_headers + f"{self.base_path}/{str(random_second_user.user.uid)}/keys", headers=random_user.auth_headers ) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -202,14 +209,15 @@ class TestS3KeyRoutesDelete(_TestS3KeyRoutes): mock_rgw_admin : app.tests.mocks.mock_rgw_admin.MockRGWAdmin Mock class for rgwadmin package. pytest fixture. """ - new_s3_key = mock_rgw_admin.create_key(uid=random_user.user.uid)[-1] - assert len(mock_rgw_admin.get_user(uid=random_user.user.uid)["keys"]) == 2 + new_s3_key = mock_rgw_admin.create_key(uid=str(random_user.user.uid))[-1] + assert len(mock_rgw_admin.get_user(uid=str(random_user.user.uid))["keys"]) == 2 response = await client.delete( - f"{self.base_path}/{random_user.user.uid}/keys/{new_s3_key['access_key']}", headers=random_user.auth_headers + f"{self.base_path}/{str(random_user.user.uid)}/keys/{new_s3_key['access_key']}", + headers=random_user.auth_headers, ) assert response.status_code == status.HTTP_204_NO_CONTENT - assert len(mock_rgw_admin.get_user(uid=random_user.user.uid)["keys"]) == 1 + assert len(mock_rgw_admin.get_user(uid=str(random_user.user.uid))["keys"]) == 1 @pytest.mark.asyncio async def test_delete_last_s3_key_for_user( @@ -227,18 +235,21 @@ class TestS3KeyRoutesDelete(_TestS3KeyRoutes): mock_rgw_admin : app.tests.mocks.mock_rgw_admin.MockRGWAdmin Mock class for rgwadmin package. pytest fixture. """ - assert len(mock_rgw_admin.get_user(uid=random_user.user.uid)["keys"]) == 1 - key_id = mock_rgw_admin.get_user(uid=random_user.user.uid)["keys"][0] + assert len(mock_rgw_admin.get_user(uid=str(random_user.user.uid))["keys"]) == 1 + key_id = mock_rgw_admin.get_user(uid=str(random_user.user.uid))["keys"][0] response = await client.delete( - f"{self.base_path}/{random_user.user.uid}/keys/{key_id}", headers=random_user.auth_headers + f"{self.base_path}/{str(random_user.user.uid)}/keys/{key_id}", headers=random_user.auth_headers ) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert len(mock_rgw_admin.get_user(uid=random_user.user.uid)["keys"]) == 1 + assert len(mock_rgw_admin.get_user(uid=str(random_user.user.uid))["keys"]) == 1 @pytest.mark.asyncio async def test_delete_unknown_s3_key_for_user( - self, client: AsyncClient, random_user: UserWithAuthHeader, mock_rgw_admin: MockRGWAdmin + self, + client: AsyncClient, + random_user: UserWithAuthHeader, + mock_rgw_admin: MockRGWAdmin, ) -> None: """ Test for deleting an unknown S3 key from a user. @@ -252,8 +263,8 @@ class TestS3KeyRoutesDelete(_TestS3KeyRoutes): mock_rgw_admin : app.tests.mocks.mock_rgw_admin.MockRGWAdmin Mock class for rgwadmin package. pytest fixture. """ - mock_rgw_admin.create_key(uid=random_user.user.uid) + mock_rgw_admin.create_key(uid=str(random_user.user.uid)) response = await client.delete( - f"{self.base_path}/{random_user.user.uid}/keys/impossible", headers=random_user.auth_headers + f"{self.base_path}/{str(random_user.user.uid)}/keys/impossible", headers=random_user.auth_headers ) assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/app/tests/api/test_security.py b/app/tests/api/test_security.py index 6b7b1fea50703cab3f179fb48e0868b63e3b9413..870c4007039ac14c2ab15c594fce7bebb6952627 100644 --- a/app/tests/api/test_security.py +++ b/app/tests/api/test_security.py @@ -54,7 +54,7 @@ class TestJWTProtectedRoutes: Random user for testing. pytest fixture. """ response = await client.get( - self.protected_route, params={"user": random_user.user.uid}, headers=random_user.auth_headers + self.protected_route, params={"owner_id": str(random_user.user.uid)}, headers=random_user.auth_headers ) assert response.status_code == status.HTTP_200_OK @@ -81,6 +81,6 @@ class TestJWTProtectedRoutes: await db.commit() response = await client.get( - self.protected_route, params={"user": random_user.user.uid}, headers=random_user.auth_headers + self.protected_route, params={"owner_id": str(random_user.user.uid)}, headers=random_user.auth_headers ) assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/app/tests/conftest.py b/app/tests/conftest.py index c3e84d9d9613a1459a092bba9d24de0d9406e532..936ddf69059e8c73d382066dfff707c0e0cee6b8 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -2,7 +2,7 @@ import asyncio import json from functools import partial from secrets import token_urlsafe -from typing import AsyncGenerator, Callable, Dict, Generator +from typing import AsyncIterator, Callable, Dict, Generator from uuid import uuid4 import httpx @@ -21,6 +21,7 @@ from app.schemas.security import AuthzResponse from app.tests.mocks.mock_rgw_admin import MockRGWAdmin from app.tests.mocks.mock_s3_resource import MockS3ServiceResource from app.tests.utils.bucket import create_random_bucket +from app.tests.utils.cleanup import CleanupList from app.tests.utils.user import UserWithAuthHeader, create_random_user, decode_mock_token, get_authorization_headers from app.tests.utils.utils import request_admin_permission @@ -54,28 +55,24 @@ def mock_s3_service() -> MockS3ServiceResource: @pytest_asyncio.fixture(scope="module") -async def client(mock_rgw_admin: MockRGWAdmin, mock_s3_service: MockS3ServiceResource) -> AsyncGenerator: +async def client( + mock_rgw_admin: MockRGWAdmin, mock_s3_service: MockS3ServiceResource +) -> AsyncIterator[httpx.AsyncClient]: """ Fixture for creating a TestClient and perform HTTP Request on it. Overrides the dependency for the RGW admin operations. """ - def get_mock_rgw() -> MockRGWAdmin: - return mock_rgw_admin - - def get_mock_s3() -> MockS3ServiceResource: - return mock_s3_service - def get_decode_token_function() -> Callable[[str], Dict[str, str]]: # Override the decode_jwt function with mock function for tests and inject random shared secret return partial(decode_mock_token, secret=jwt_secret) - async def get_mock_httpx_client() -> AsyncGenerator[httpx.AsyncClient, None]: + async def get_mock_httpx_client(allow_admin: bool = False) -> AsyncIterator[httpx.AsyncClient]: def mock_request_handler(request: httpx.Request) -> httpx.Response: response_body = {} if str(request.url).startswith(str(settings.OPA_URI)): response_body = AuthzResponse( - result=not request_admin_permission(request), decision_id=str(uuid4()) + result=not request_admin_permission(request) or allow_admin, decision_id=uuid4() ).model_dump() return httpx.Response(200, json=response_body) @@ -83,8 +80,8 @@ async def client(mock_rgw_admin: MockRGWAdmin, mock_s3_service: MockS3ServiceRes yield http_client app.dependency_overrides[get_httpx_client] = get_mock_httpx_client - app.dependency_overrides[get_rgw_admin] = get_mock_rgw - app.dependency_overrides[get_s3_resource] = get_mock_s3 + app.dependency_overrides[get_rgw_admin] = lambda: mock_rgw_admin + app.dependency_overrides[get_s3_resource] = lambda: mock_s3_service app.dependency_overrides[get_decode_jwt_function] = get_decode_token_function async with httpx.AsyncClient(app=app, base_url="http://localhost") as ac: yield ac @@ -92,7 +89,7 @@ async def client(mock_rgw_admin: MockRGWAdmin, mock_s3_service: MockS3ServiceRes @pytest_asyncio.fixture(scope="module") -async def db() -> AsyncGenerator[AsyncSession, None]: +async def db() -> AsyncIterator[AsyncSession]: """ Fixture for creating a database session to connect to. """ @@ -102,43 +99,41 @@ async def db() -> AsyncGenerator[AsyncSession, None]: yield dbSession -@pytest_asyncio.fixture(scope="module") -async def random_user(db: AsyncSession, mock_rgw_admin: MockRGWAdmin) -> AsyncGenerator[UserWithAuthHeader, None]: +@pytest_asyncio.fixture(scope="function") +async def random_user(db: AsyncSession, mock_rgw_admin: MockRGWAdmin) -> AsyncIterator[UserWithAuthHeader]: """ Create a random user and deletes him afterwards. """ user = await create_random_user(db) - mock_rgw_admin.create_key(uid=user.uid) + mock_rgw_admin.create_key(uid=str(user.uid)) yield UserWithAuthHeader(user=user, auth_headers=get_authorization_headers(uid=user.uid, secret=jwt_secret)) - mock_rgw_admin.delete_user(uid=user.uid) + mock_rgw_admin.delete_user(uid=str(user.uid)) await db.delete(user) await db.commit() -@pytest_asyncio.fixture(scope="module") -async def random_second_user( - db: AsyncSession, mock_rgw_admin: MockRGWAdmin -) -> AsyncGenerator[UserWithAuthHeader, None]: +@pytest_asyncio.fixture(scope="function") +async def random_second_user(db: AsyncSession, mock_rgw_admin: MockRGWAdmin) -> AsyncIterator[UserWithAuthHeader]: """ Create a random second user and deletes him afterwards. """ user = await create_random_user(db) - mock_rgw_admin.create_key(uid=user.uid) + mock_rgw_admin.create_key(uid=str(user.uid)) yield UserWithAuthHeader(user=user, auth_headers=get_authorization_headers(uid=user.uid, secret=jwt_secret)) - mock_rgw_admin.delete_user(uid=user.uid) + mock_rgw_admin.delete_user(uid=str(user.uid)) await db.delete(user) await db.commit() -@pytest_asyncio.fixture(scope="module") -async def random_third_user(db: AsyncSession, mock_rgw_admin: MockRGWAdmin) -> AsyncGenerator[UserWithAuthHeader, None]: +@pytest_asyncio.fixture(scope="function") +async def random_third_user(db: AsyncSession, mock_rgw_admin: MockRGWAdmin) -> AsyncIterator[UserWithAuthHeader]: """ Create a random third user and deletes him afterwards. """ user = await create_random_user(db) - mock_rgw_admin.create_key(uid=user.uid) + mock_rgw_admin.create_key(uid=str(user.uid)) yield UserWithAuthHeader(user=user, auth_headers=get_authorization_headers(uid=user.uid, secret=jwt_secret)) - mock_rgw_admin.delete_user(uid=user.uid) + mock_rgw_admin.delete_user(uid=str(user.uid)) await db.delete(user) await db.commit() @@ -146,7 +141,7 @@ async def random_third_user(db: AsyncSession, mock_rgw_admin: MockRGWAdmin) -> A @pytest_asyncio.fixture(scope="function") async def random_bucket( db: AsyncSession, random_user: UserWithAuthHeader, mock_s3_service: MockS3ServiceResource -) -> AsyncGenerator[Bucket, None]: +) -> AsyncIterator[Bucket]: """ Create a random user and deletes him afterwards. """ @@ -160,7 +155,7 @@ async def random_bucket( { "Sid": "PseudoOwnerPerm", "Effect": "Allow", - "Principal": {"AWS": [f"arn:aws:iam:::user/{random_user.user.uid}"]}, + "Principal": {"AWS": [f"arn:aws:iam:::user/{str(random_user.user.uid)}"]}, "Action": ["s3:GetObject", "s3:DeleteObject", "s3:PutObject", "s3:ListBucket"], "Resource": [f"arn:aws:s3:::{bucket.name}/*", f"arn:aws:s3:::{bucket.name}"], } @@ -184,7 +179,7 @@ async def random_bucket_permission( """ Create a bucket READ permission for the second user on a bucket. """ - permission_db = BucketPermissionDB(user_id=random_second_user.user.uid, bucket_name=random_bucket.name) + permission_db = BucketPermissionDB(_uid=random_second_user.user.uid.bytes, bucket_name=random_bucket.name) db.add(permission_db) await db.commit() await db.refresh(permission_db) @@ -209,4 +204,14 @@ async def random_bucket_permission_schema( Create a bucket READ permission for the second user on a bucket. """ - return BucketPermissionSchema.from_db_model(random_bucket_permission, random_second_user.user.uid) + return BucketPermissionSchema.from_db_model(random_bucket_permission) + + +@pytest_asyncio.fixture(scope="function") +async def cleanup(db: AsyncSession) -> AsyncIterator[CleanupList]: + """ + Yields a Cleanup object where (async) functions can be registered which get executed after a (failed) test + """ + cleanup_list = CleanupList() + yield cleanup_list + await cleanup_list.empty_queue() diff --git a/app/tests/crud/test_bucket.py b/app/tests/crud/test_bucket.py index 2250e511e4da32a0dd27ea167e38f4de1b6cd687..2af9aadc7fc707bd43fedfa52febe1ef66d5ecc6 100644 --- a/app/tests/crud/test_bucket.py +++ b/app/tests/crud/test_bucket.py @@ -8,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.crud import DuplicateError from app.crud.crud_bucket import CRUDBucket from app.schemas.bucket import BucketIn -from app.tests.utils.bucket import add_permission_for_bucket +from app.tests.utils.bucket import add_permission_for_bucket, delete_bucket +from app.tests.utils.cleanup import CleanupList from app.tests.utils.user import UserWithAuthHeader from app.tests.utils.utils import random_lower_string @@ -83,7 +84,11 @@ class TestBucketCRUDGet: @pytest.mark.asyncio async def test_get_only_foreign_bucket( - self, db: AsyncSession, random_bucket: Bucket, random_second_user: UserWithAuthHeader + self, + db: AsyncSession, + random_bucket: Bucket, + random_second_user: UserWithAuthHeader, + cleanup: CleanupList, ) -> None: """ Test for getting only foreign buckets with permissions for a user from CRUD Repository. @@ -96,14 +101,21 @@ class TestBucketCRUDGet: Random bucket for testing. pytest fixture. random_second_user : app.tests.utils.user.UserWithAuthHeader Random second user for testing. pytest fixture. + cleanup : app.tests.utils.utils.CleanupList + Cleanup object where (async) functions can be registered which get executed after a (failed) test. """ bucket = Bucket( name=random_lower_string(), description=random_lower_string(127), - owner_id=random_second_user.user.uid, + _owner_id=random_second_user.user.uid.bytes, ) db.add(bucket) await db.commit() + cleanup.add_task( + delete_bucket, + db=db, + bucket_name=bucket.name, + ) await add_permission_for_bucket( db, bucket.name, random_bucket.owner_id, permission=BucketPermission.Permission.READ ) @@ -113,14 +125,13 @@ class TestBucketCRUDGet: assert buckets[0] != random_bucket assert buckets[0].name == bucket.name - await db.delete(bucket) - @pytest.mark.asyncio async def test_get_bucket_with_read_permission_and_own( self, db: AsyncSession, random_bucket: Bucket, random_second_user: UserWithAuthHeader, + cleanup: CleanupList, ) -> None: """ Test for getting the users own bucket and a foreign bucket with READ permissions from CRUD Repository. @@ -133,14 +144,21 @@ class TestBucketCRUDGet: Random bucket for testing. pytest fixture. random_second_user : app.tests.utils.user.UserWithAuthHeader Random second user for testing. pytest fixture. + cleanup : app.tests.utils.utils.CleanupList + Cleanup object where (async) functions can be registered which get executed after a (failed) test. """ bucket = Bucket( name=random_lower_string(), description=random_lower_string(127), - owner_id=random_second_user.user.uid, + _owner_id=random_second_user.user.uid.bytes, ) db.add(bucket) await db.commit() + cleanup.add_task( + delete_bucket, + db=db, + bucket_name=bucket.name, + ) await add_permission_for_bucket( db, bucket.name, random_bucket.owner_id, permission=BucketPermission.Permission.READ ) @@ -151,8 +169,6 @@ class TestBucketCRUDGet: assert buckets[0].name == random_bucket.name or buckets[1].name == random_bucket.name assert buckets[0].name == bucket.name or buckets[1].name == bucket.name - await db.delete(bucket) - @pytest.mark.asyncio async def test_get_bucket_with_read_permission( self, db: AsyncSession, random_bucket: Bucket, random_second_user: UserWithAuthHeader @@ -308,7 +324,12 @@ class TestBucketCRUDGet: class TestBucketCRUDCreate: @pytest.mark.asyncio - async def test_create_bucket(self, db: AsyncSession, random_user: UserWithAuthHeader) -> None: + async def test_create_bucket( + self, + db: AsyncSession, + random_user: UserWithAuthHeader, + cleanup: CleanupList, + ) -> None: """ Test for creating a bucket with the CRUD Repository. @@ -318,9 +339,16 @@ class TestBucketCRUDCreate: Async database session to perform query on. pytest fixture. random_user : app.tests.utils.user.UserWithAuthHeader Random user for testing. pytest fixture. + cleanup : app.tests.utils.utils.CleanupList + Cleanup object where (async) functions can be registered which get executed after a (failed) test. """ bucket_info = BucketIn(name=random_lower_string(), description=random_lower_string(127)) bucket = await CRUDBucket.create(db, bucket_info, random_user.user.uid) + cleanup.add_task( + delete_bucket, + db=db, + bucket_name=bucket.name, + ) assert bucket.name == bucket_info.name assert bucket.owner_id == random_user.user.uid assert bucket.description == bucket_info.description @@ -333,8 +361,6 @@ class TestBucketCRUDCreate: assert bucket_db.owner_id == random_user.user.uid assert bucket_db.description == bucket_info.description - await db.delete(bucket) - @pytest.mark.asyncio async def test_create_duplicated_bucket(self, db: AsyncSession, random_bucket: Bucket) -> None: """ diff --git a/app/tests/crud/test_bucket_permission.py b/app/tests/crud/test_bucket_permission.py index a92f97f6035cc385c2709ab5ecfc137706d77469..20b94e14d2d44e39b8778d34c0aae2a38a1278d9 100644 --- a/app/tests/crud/test_bucket_permission.py +++ b/app/tests/crud/test_bucket_permission.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from uuid import uuid4 import pytest from clowmdb.models import Bucket @@ -30,10 +31,10 @@ class TestBucketPermissionCRUDGet: Bucket permission for a random bucket for testing. pytest fixture. """ bucket_permission = await CRUDBucketPermission.get( - db, bucket_name=random_bucket_permission.bucket_name, uid=random_bucket_permission.user_id + db, bucket_name=random_bucket_permission.bucket_name, uid=random_bucket_permission.uid ) assert bucket_permission - assert bucket_permission.user_id == random_bucket_permission.user_id + assert bucket_permission.uid == random_bucket_permission.uid assert bucket_permission.bucket_name == random_bucket_permission.bucket_name assert bucket_permission.permissions == random_bucket_permission.permissions @@ -54,7 +55,7 @@ class TestBucketPermissionCRUDGet: bucket_permissions = await CRUDBucketPermission.list(db, bucket_name=random_bucket_permission.bucket_name) assert len(bucket_permissions) == 1 bucket_permission = bucket_permissions[0] - assert bucket_permission.user_id == random_bucket_permission.user_id + assert bucket_permission.uid == random_bucket_permission.uid assert bucket_permission.bucket_name == random_bucket_permission.bucket_name assert bucket_permission.permissions == random_bucket_permission.permissions @@ -86,7 +87,7 @@ class TestBucketPermissionCRUDGet: db, bucket_name=random_bucket.name, permission_types=[BucketPermissionDB.Permission.READ] ) assert len(bucket_permissions) == 1 - assert bucket_permissions[0].user_id == random_second_user.user.uid + assert bucket_permissions[0].uid == random_second_user.user.uid @pytest.mark.asyncio async def test_get_read_and_write_bucket_permissions_by_bucket_name( @@ -118,8 +119,8 @@ class TestBucketPermissionCRUDGet: permission_types=[BucketPermissionDB.Permission.READ, BucketPermissionDB.Permission.WRITE], ) assert len(bucket_permissions) == 2 - assert random_second_user.user.uid in map(lambda x: x.user_id, bucket_permissions) - assert random_third_user.user.uid in map(lambda x: x.user_id, bucket_permissions) + assert random_second_user.user.uid in map(lambda x: x.uid, bucket_permissions) + assert random_third_user.user.uid in map(lambda x: x.uid, bucket_permissions) @pytest.mark.asyncio async def test_get_active_bucket_permissions_by_bucket_name1( @@ -152,7 +153,7 @@ class TestBucketPermissionCRUDGet: db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE ) assert len(bucket_permissions) == 1 - assert bucket_permissions[0].user_id == random_third_user.user.uid + assert bucket_permissions[0].uid == random_third_user.user.uid @pytest.mark.asyncio async def test_get_active_bucket_permissions_by_bucket_name2( @@ -185,7 +186,7 @@ class TestBucketPermissionCRUDGet: db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE ) assert len(bucket_permissions) == 1 - assert bucket_permissions[0].user_id == random_third_user.user.uid + assert bucket_permissions[0].uid == random_third_user.user.uid @pytest.mark.asyncio async def test_get_active_bucket_permissions_by_bucket_name3( @@ -226,7 +227,7 @@ class TestBucketPermissionCRUDGet: db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE ) assert len(bucket_permissions) == 1 - assert bucket_permissions[0].user_id == random_third_user.user.uid + assert bucket_permissions[0].uid == random_third_user.user.uid @pytest.mark.asyncio async def test_get_inactive_bucket_permissions_by_bucket_name1( @@ -259,7 +260,7 @@ class TestBucketPermissionCRUDGet: db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.INACTIVE ) assert len(bucket_permissions) == 1 - assert bucket_permissions[0].user_id == random_second_user.user.uid + assert bucket_permissions[0].uid == random_second_user.user.uid @pytest.mark.asyncio async def test_get_inactive_bucket_permissions_by_bucket_name2( @@ -292,7 +293,7 @@ class TestBucketPermissionCRUDGet: db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.INACTIVE ) assert len(bucket_permissions) == 1 - assert bucket_permissions[0].user_id == random_second_user.user.uid + assert bucket_permissions[0].uid == random_second_user.user.uid @pytest.mark.asyncio async def test_get_inactive_bucket_permissions_by_bucket_name3( @@ -333,7 +334,7 @@ class TestBucketPermissionCRUDGet: db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.INACTIVE ) assert len(bucket_permissions) == 1 - assert bucket_permissions[0].user_id == random_second_user.user.uid + assert bucket_permissions[0].uid == random_second_user.user.uid @pytest.mark.asyncio async def test_get_bucket_permissions_by_uid( @@ -349,10 +350,10 @@ class TestBucketPermissionCRUDGet: random_bucket_permission : clowmdb.models.BucketPermission Bucket permission for a random bucket for testing. pytest fixture. """ - bucket_permissions = await CRUDBucketPermission.list(db, uid=random_bucket_permission.user_id) + bucket_permissions = await CRUDBucketPermission.list(db, uid=random_bucket_permission.uid) assert len(bucket_permissions) == 1 bucket_permission = bucket_permissions[0] - assert bucket_permission.user_id == random_bucket_permission.user_id + assert bucket_permission.uid == random_bucket_permission.uid assert bucket_permission.bucket_name == random_bucket_permission.bucket_name assert bucket_permission.permissions == random_bucket_permission.permissions @@ -422,7 +423,7 @@ class TestBucketPermissionCRUDCreate: random_bucket : clowmdb.models.Bucket Random bucket for testing. pytest fixture. """ - permission = BucketPermissionSchema(bucket_name=random_bucket.name, uid="ImpossibleUser") + permission = BucketPermissionSchema(bucket_name=random_bucket.name, uid=uuid4()) with pytest.raises(KeyError): await CRUDBucketPermission.create(db, permission) @@ -485,7 +486,7 @@ class TestBucketPermissionCRUDCreate: permission = BucketPermissionSchema(bucket_name=random_bucket.name, uid=random_second_user.user.uid) created_permission = await CRUDBucketPermission.create(db, permission) - assert created_permission.user_id == random_second_user.user.uid + assert created_permission.uid == random_second_user.user.uid assert created_permission.bucket_name == random_bucket.name @@ -505,13 +506,13 @@ class TestBucketPermissionCRUDDelete: Bucket permission for a random bucket for testing. pytest fixture. """ await CRUDBucketPermission.delete( - db, bucket_name=random_bucket_permission.bucket_name, uid=random_bucket_permission.user_id + db, bucket_name=random_bucket_permission.bucket_name, uid=random_bucket_permission.uid ) stmt = select(BucketPermissionDB).where( and_( BucketPermissionDB.bucket_name == random_bucket_permission.bucket_name, - BucketPermissionDB.user_id == random_bucket_permission.user_id, + BucketPermissionDB._uid == random_bucket_permission.uid.bytes, ) ) bucket_permission_db = await db.scalar(stmt) @@ -543,7 +544,7 @@ class TestBucketPermissionCRUDUpdate: ) new_permission = await CRUDBucketPermission.update_permission(db, random_bucket_permission, new_params) - assert new_permission.user_id == random_bucket_permission.user_id + assert new_permission.uid == random_bucket_permission.uid assert new_permission.bucket_name == random_bucket_permission.bucket_name assert new_permission.from_ == new_params.from_timestamp assert new_permission.to == new_params.to_timestamp diff --git a/app/tests/crud/test_user.py b/app/tests/crud/test_user.py index e387b67a4d2728ad89590917b3afb1c4944a0e31..c9856e1fb219f1b0d0b6f86718660404d7334fe1 100644 --- a/app/tests/crud/test_user.py +++ b/app/tests/crud/test_user.py @@ -1,9 +1,10 @@ +from uuid import uuid4 + import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.crud.crud_user import CRUDUser from app.tests.utils.user import UserWithAuthHeader -from app.tests.utils.utils import random_lower_string class TestUserCRUD: @@ -37,5 +38,5 @@ class TestUserCRUD: db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. pytest fixture. """ - user = await CRUDUser.get(db, random_lower_string(length=16)) + user = await CRUDUser.get(db, uuid4()) assert user is None diff --git a/app/tests/unit/test_bucket_permission_scheme.py b/app/tests/unit/test_bucket_permission_scheme.py index 8eb79ec9168f8e802632ffea073f5d12c5f572d3..263d0afc61a8d6eb5bf64f708de42f701fcddbb6 100644 --- a/app/tests/unit/test_bucket_permission_scheme.py +++ b/app/tests/unit/test_bucket_permission_scheme.py @@ -1,4 +1,5 @@ from datetime import datetime +from uuid import uuid4 import pytest from clowmdb.models import BucketPermission @@ -14,7 +15,7 @@ class _TestPermissionPolicy: Generate a base READ bucket permission schema. """ return BucketPermissionIn( - uid=random_lower_string(), + uid=uuid4(), bucket_name=random_lower_string(), permission=BucketPermission.Permission.READ, ) @@ -30,14 +31,14 @@ class TestPermissionPolicyPermissionType(_TestPermissionPolicy): random_base_permission : app.schemas.bucket_permission.BucketPermissionOut Random base bucket permission for testing. pytest fixture. """ - uid = random_lower_string() - stmts = random_base_permission.map_to_bucket_policy_statement(user_id=uid) + uid = uuid4() + stmts = random_base_permission.map_to_bucket_policy_statement(uid=uid) assert len(stmts) == 2 object_stmt = stmts[0] assert object_stmt["Effect"] == "Allow" assert object_stmt["Sid"] == random_base_permission.to_hash(uid) - assert object_stmt["Principal"]["AWS"] == f"arn:aws:iam:::user/{random_base_permission.uid}" + assert object_stmt["Principal"]["AWS"] == f"arn:aws:iam:::user/{str(random_base_permission.uid)}" assert object_stmt["Resource"] == f"arn:aws:s3:::{random_base_permission.bucket_name}/*" with pytest.raises(KeyError): assert object_stmt["Condition"] @@ -47,7 +48,7 @@ class TestPermissionPolicyPermissionType(_TestPermissionPolicy): bucket_stmt = stmts[1] assert bucket_stmt["Sid"] == random_base_permission.to_hash(uid) assert bucket_stmt["Effect"] == "Allow" - assert bucket_stmt["Principal"]["AWS"] == f"arn:aws:iam:::user/{random_base_permission.uid}" + assert bucket_stmt["Principal"]["AWS"] == f"arn:aws:iam:::user/{str(random_base_permission.uid)}" assert bucket_stmt["Resource"] == f"arn:aws:s3:::{random_base_permission.bucket_name}" with pytest.raises(KeyError): assert bucket_stmt["Condition"] @@ -64,7 +65,7 @@ class TestPermissionPolicyPermissionType(_TestPermissionPolicy): Random base bucket permission for testing. pytest fixture. """ random_base_permission.permission = BucketPermission.Permission.WRITE - stmts = random_base_permission.map_to_bucket_policy_statement(user_id=random_lower_string()) + stmts = random_base_permission.map_to_bucket_policy_statement(uid=uuid4()) assert len(stmts) == 2 object_stmt = stmts[0] @@ -91,7 +92,7 @@ class TestPermissionPolicyPermissionType(_TestPermissionPolicy): Random base bucket permission for testing. pytest fixture. """ random_base_permission.permission = BucketPermission.Permission.READWRITE - stmts = random_base_permission.map_to_bucket_policy_statement(user_id=random_lower_string()) + stmts = random_base_permission.map_to_bucket_policy_statement(uid=uuid4()) assert len(stmts) == 2 object_stmt = stmts[0] @@ -124,7 +125,7 @@ class TestPermissionPolicyCondition(_TestPermissionPolicy): time = datetime.fromtimestamp(timestamp) # avoid rounding error random_base_permission.to_timestamp = timestamp - stmts = random_base_permission.map_to_bucket_policy_statement(user_id=random_lower_string()) + stmts = random_base_permission.map_to_bucket_policy_statement(uid=uuid4()) assert len(stmts) == 2 object_stmt = stmts[0] @@ -153,7 +154,7 @@ class TestPermissionPolicyCondition(_TestPermissionPolicy): time = datetime.fromtimestamp(timestamp) # avoid rounding error random_base_permission.from_timestamp = timestamp - stmts = random_base_permission.map_to_bucket_policy_statement(user_id=random_lower_string()) + stmts = random_base_permission.map_to_bucket_policy_statement(uid=uuid4()) assert len(stmts) == 2 object_stmt = stmts[0] @@ -179,7 +180,7 @@ class TestPermissionPolicyCondition(_TestPermissionPolicy): """ random_base_permission.file_prefix = random_lower_string(length=8) + "/" + random_lower_string(length=8) + "/" - stmts = random_base_permission.map_to_bucket_policy_statement(user_id=random_lower_string()) + stmts = random_base_permission.map_to_bucket_policy_statement(uid=uuid4()) assert len(stmts) == 2 object_stmt = stmts[0] diff --git a/app/tests/utils/bucket.py b/app/tests/utils/bucket.py index 76b5634a4f066010829961ce74aa5172d9319d10..47d32f264acd984084cab8d8d6b1dd7327b642a7 100644 --- a/app/tests/utils/bucket.py +++ b/app/tests/utils/bucket.py @@ -1,14 +1,23 @@ from datetime import datetime from typing import Optional +from uuid import UUID -import pytest from clowmdb.models import Bucket, BucketPermission, User +from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession from .utils import random_lower_string -@pytest.mark.asyncio +async def delete_bucket(db: AsyncSession, bucket_name: str) -> None: + await db.execute( + delete(Bucket).where( + Bucket.name == bucket_name, + ) + ) + await db.commit() + + async def create_random_bucket(db: AsyncSession, user: User) -> Bucket: """ Creates a random bucket in the database. @@ -28,18 +37,17 @@ async def create_random_bucket(db: AsyncSession, user: User) -> Bucket: bucket = Bucket( name=random_lower_string(), description=random_lower_string(length=127), - owner_id=user.uid, + _owner_id=user.uid.bytes, ) db.add(bucket) await db.commit() return bucket -@pytest.mark.asyncio async def add_permission_for_bucket( db: AsyncSession, bucket_name: str, - uid: str, + uid: UUID, from_: Optional[datetime] = None, to: Optional[datetime] = None, permission: BucketPermission.Permission = BucketPermission.Permission.READ, @@ -63,7 +71,7 @@ async def add_permission_for_bucket( The permission the user is granted. """ perm = BucketPermission( - user_id=uid, + _uid=uid.bytes, bucket_name=bucket_name, from_=round(from_.timestamp()) if from_ is not None else None, to=round(to.timestamp()) if to is not None else None, diff --git a/app/tests/utils/cleanup.py b/app/tests/utils/cleanup.py new file mode 100644 index 0000000000000000000000000000000000000000..1657553e8803c394664ca59753a1841042f8cee6 --- /dev/null +++ b/app/tests/utils/cleanup.py @@ -0,0 +1,66 @@ +from inspect import iscoroutinefunction +from typing import Any, Awaitable, Callable, Generic, List, ParamSpec, TypeVar + +P = ParamSpec("P") +T = TypeVar("T") + + +class Job(Generic[P, T]): + def __init__(self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> None: + self.func = func + self.args = args + self.kwargs = kwargs + + @property + def is_async(self) -> bool: + return iscoroutinefunction(self.func) + + def __call__(self) -> T: + return self.func(*self.args, **self.kwargs) + + +class AsyncJob(Job): + def __init__(self, func: Callable[P, Awaitable[T]], *args: P.args, **kwargs: P.kwargs) -> None: + super().__init__(func, *args, **kwargs) + assert iscoroutinefunction(self.func) + + async def __call__(self) -> T: + return await super().__call__() + + +class CleanupList: + """ + Helper object to hold a queue of functions that can be executed later + """ + + def __init__(self) -> None: + self.queue: List[Job] = [] + + def add_task(self, func: Callable[P, Any], *args: P.args, **kwargs: P.kwargs) -> None: + """ + Add a (async) function to the queue. + + Parameters + ---------- + func : Callable[P, Any] + Function to register. + args : P.args + Arguments to the function. + kwargs : P.kwargs + Keyword arguments to the function. + """ + if iscoroutinefunction(func): + self.queue.append(AsyncJob(func, *args, **kwargs)) + else: + self.queue.append(Job(func, *args, **kwargs)) + + async def empty_queue(self) -> None: + """ + Empty the queue by executing the registered functions. + """ + while len(self.queue) > 0: + func = self.queue.pop() + if func.is_async: + await func() + else: + func() diff --git a/app/tests/utils/user.py b/app/tests/utils/user.py index 109cc0a9e56bf546a3d23c060026f70ceb31f200..417f6872b77b404634e66dc1db07bc63903de7f7 100644 --- a/app/tests/utils/user.py +++ b/app/tests/utils/user.py @@ -1,6 +1,7 @@ from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Dict +from uuid import UUID import pytest from authlib.jose import JsonWebToken @@ -18,7 +19,7 @@ class UserWithAuthHeader: user: User -def get_authorization_headers(uid: str, secret: str = "SuperSecret") -> Dict[str, str]: +def get_authorization_headers(uid: UUID, secret: str = "SuperSecret") -> Dict[str, str]: """ Create a valid JWT and return the correct headers for subsequent requests. @@ -33,7 +34,7 @@ def get_authorization_headers(uid: str, secret: str = "SuperSecret") -> Dict[str headers : Dict[str,str] HTTP Headers to authorize each request. """ - to_encode = {"sub": uid, "exp": datetime.utcnow() + timedelta(hours=1)} + to_encode = {"sub": str(uid), "exp": datetime.now(UTC) + timedelta(hours=1)} encoded_jwt = _jwt.encode(header={"alg": "HS256"}, payload=to_encode, key=secret) headers = {"Authorization": f"Bearer {encoded_jwt.decode('utf-8')}"} @@ -84,7 +85,7 @@ async def create_random_user(db: AsyncSession) -> User: Newly created user. """ user = User( - uid=random_lower_string(), + lifescience_id=random_lower_string(), display_name=random_lower_string(), ) db.add(user) diff --git a/gunicorn_conf.py b/gunicorn_conf.py new file mode 100644 index 0000000000000000000000000000000000000000..7dd141dfc55f98de00b07daffea9a898677e9df4 --- /dev/null +++ b/gunicorn_conf.py @@ -0,0 +1,67 @@ +import json +import multiprocessing +import os + +workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1") +max_workers_str = os.getenv("MAX_WORKERS") +use_max_workers = None +if max_workers_str: + use_max_workers = int(max_workers_str) +web_concurrency_str = os.getenv("WEB_CONCURRENCY", None) + +host = os.getenv("HOST", "0.0.0.0") +port = os.getenv("PORT", "80") +bind_env = os.getenv("BIND", None) +use_loglevel = os.getenv("LOG_LEVEL", "info") +if bind_env: + use_bind = bind_env +else: + use_bind = f"{host}:{port}" + +cores = multiprocessing.cpu_count() +workers_per_core = float(workers_per_core_str) +default_web_concurrency = workers_per_core * cores +if web_concurrency_str: + web_concurrency = int(web_concurrency_str) + assert web_concurrency > 0 +else: + web_concurrency = max(int(default_web_concurrency), 2) + if use_max_workers: + web_concurrency = min(web_concurrency, use_max_workers) +accesslog_var = os.getenv("ACCESS_LOG", "-") +use_accesslog = accesslog_var or None +errorlog_var = os.getenv("ERROR_LOG", "-") +use_errorlog = errorlog_var or None +graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120") +timeout_str = os.getenv("TIMEOUT", "120") +keepalive_str = os.getenv("KEEP_ALIVE", "5") + +# Gunicorn config variables +loglevel = use_loglevel +workers = web_concurrency +bind = use_bind +errorlog = use_errorlog +worker_tmp_dir = "/dev/shm" +accesslog = use_accesslog +graceful_timeout = int(graceful_timeout_str) +timeout = int(timeout_str) +keepalive = int(keepalive_str) + + +# For debugging and testing +log_data = { + "loglevel": loglevel, + "workers": workers, + "bind": bind, + "graceful_timeout": graceful_timeout, + "timeout": timeout, + "keepalive": keepalive, + "errorlog": errorlog, + "accesslog": accesslog, + # Additional, non-gunicorn variables + "workers_per_core": workers_per_core, + "use_max_workers": use_max_workers, + "host": host, + "port": port, +} +print(json.dumps(log_data)) diff --git a/requirements-dev.txt b/requirements-dev.txt index 05c18639d37836dec2a6ad7ab360790a16f3c573..9d1bcf30b0fcbd3281e1827154613d7a313d0792 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,15 +2,16 @@ pytest>=7.4.0,<7.5.0 pytest-asyncio>=0.21.0,<0.22.0 pytest-cov>=4.1.0,<4.2.0 -coverage[toml]>=7.3.0,<7.4.0 +coverage[toml]>=7.4.0,<7.5.0 # Linters ruff>=0.1.0,<0.2.0 -black>=23.11.0,<23.12.0 -isort>=5.12.0,<5.13.0 -mypy>=1.7.0,<1.8.0 +black>=23.12.0,<24.1.0 +isort>=5.13.0,<5.14.0 +mypy>=1.8.0,<1.9.0 # stubs for mypy -boto3-stubs-lite[s3]>=1.33.0,<1.34.0 +boto3-stubs-lite[s3]>=1.34.0,<1.35.0 types-requests # Miscellaneous -pre-commit>=3.5.0,<3.6.0 +pre-commit>=3.6.0,<3.7.0 python-dotenv +uvicorn>=0.27.0,<0.28.0 diff --git a/requirements.txt b/requirements.txt index bf948033cd93090ac324ede1bb973d3524f3dc08..ddcfc6cd3b3b34b3839c96a0147d8f3dca097669 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,24 +1,22 @@ --extra-index-url https://gitlab.ub.uni-bielefeld.de/api/v4/projects/5493/packages/pypi/simple -clowmdb>=2.3.0,<2.4.0 +clowmdb>=3.0.0,<3.1.0 # Webserver packages -anyio>=3.7.0,<4.0.0 -fastapi>=0.104.0,<0.105.0 +fastapi>=0.109.0,<0.110.0 pydantic>=2.5.0,<2.6.0 pydantic-settings>=2.1.0,<2.2.0 -uvicorn>=0.24.0,<0.25.0 # Database packages PyMySQL>=1.1.0,<1.2.0 SQLAlchemy>=2.0.0,<2.1.0 aiomysql>=0.2.0,<0.3.0 # Security packages -authlib>=1.2.0,<1.3.0 +authlib>=1.3.0,<1.4.0 # Ceph and S3 packages -boto3>=1.33.0,<1.34.0 +boto3>=1.34.0,<1.35.0 rgwadmin>=2.4.0,<2.5.0 # Miscellaneous tenacity>=8.2.0,<8.3.0 -httpx>=0.25.0,<0.26.0 +httpx>=0.26.0,<0.27.0 itsdangerous # Monitoring opentelemetry-instrumentation-fastapi diff --git a/start_service.sh b/start_service.sh deleted file mode 100755 index 9fca45763981e431c287f06b937e0385f59892a5..0000000000000000000000000000000000000000 --- a/start_service.sh +++ /dev/null @@ -1,6 +0,0 @@ -#! /usr/bin/env bash - -./scripts/prestart - -# Start webserver -uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header diff --git a/start_service_gunicorn.sh b/start_service_gunicorn.sh new file mode 100755 index 0000000000000000000000000000000000000000..1b65c1164b89af0c3f9da44d8135959f614d0c9d --- /dev/null +++ b/start_service_gunicorn.sh @@ -0,0 +1,7 @@ +#! /usr/bin/env sh +set -e + +./prestart.sh + +# Start Gunicorn +exec gunicorn -k uvicorn.workers.UvicornWorker -c /app/gunicorn_conf.py app.main:app diff --git a/start_service_uvicorn.sh b/start_service_uvicorn.sh new file mode 100755 index 0000000000000000000000000000000000000000..392596854a58728ac008b79d7f5d5178d1c90c2b --- /dev/null +++ b/start_service_uvicorn.sh @@ -0,0 +1,7 @@ +#! /usr/bin/env bash +set -e + +./prestart.sh + +# Start webserver +uvicorn app.main:app --host 0.0.0.0 --port "$PORT" --no-server-header