From dda3be8ead35aab090a8a19045b1998923dbc1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de> Date: Fri, 19 Apr 2024 14:08:18 +0200 Subject: [PATCH] Resolve "Add support for changing the buckets quotas" --- .gitlab-ci.yml | 4 +- Dockerfile | 2 +- Dockerfile-Gunicorn | 2 +- README.md | 10 +-- app/api/dependencies.py | 6 +- app/api/endpoints/bucket_permissions.py | 23 +++--- app/api/endpoints/buckets.py | 96 ++++++++++++++++++++---- app/core/config.py | 7 +- app/crud/__init__.py | 8 +- app/crud/crud_bucket.py | 58 ++++++++++---- app/crud/crud_bucket_permission.py | 31 ++++---- app/crud/crud_error.py | 2 + app/crud/crud_user.py | 2 +- app/schemas/bucket.py | 13 +++- app/tests/api/test_bucket_permissions.py | 38 ---------- app/tests/api/test_buckets.py | 51 +++++++------ app/tests/crud/test_bucket.py | 60 ++++++++++----- app/tests/crud/test_bucket_permission.py | 41 +++++----- app/tests/crud/test_user.py | 6 +- app/tests/mocks/mock_rgw_admin.py | 10 +++ example-config/example-config.json | 2 +- example-config/example-config.toml | 2 +- example-config/example-config.yaml | 3 +- requirements.txt | 5 +- 24 files changed, 291 insertions(+), 191 deletions(-) create mode 100644 app/crud/crud_error.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8fa9c3b..6b28106 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,7 +62,7 @@ integration-test-job: # Runs integration tests with the database MYSQL_DATABASE: "$CLOWM_DB__NAME" MYSQL_USER: "$CLOWM_DB__USER" MYSQL_PASSWORD: "$CLOWM_DB__PASSWORD" - - name: $CI_REGISTRY/cmg/clowm/clowm-database:v3.1 + - name: $CI_REGISTRY/cmg/clowm/clowm-database:v3.2 alias: upgrade-db variables: DB_HOST: "$CLOWM_DB__HOST" @@ -95,7 +95,7 @@ e2e-test-job: # Runs e2e tests on the API endpoints MYSQL_DATABASE: "$CLOWM_DB__NAME" MYSQL_USER: "$CLOWM_DB__USER" MYSQL_PASSWORD: "$CLOWM_DB__PASSWORD" - - name: $CI_REGISTRY/cmg/clowm/clowm-database:v3.1 + - name: $CI_REGISTRY/cmg/clowm/clowm-database:v3.2 alias: upgrade-db variables: DB_HOST: "$CLOWM_DB__HOST" diff --git a/Dockerfile b/Dockerfile index 60a1b2a..197951f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ EXPOSE $PORT RUN apt-get update && apt-get -y install dumb-init && apt-get clean ENTRYPOINT ["/usr/bin/dumb-init", "--"] STOPSIGNAL SIGINT -RUN pip install --disable-pip-version-check --no-cache-dir httpx[cli] "uvicorn<0.30.0" +RUN pip install --disable-pip-version-check --no-cache-dir httpx[cli] "uvicorn[standard]<0.30.0" HEALTHCHECK --interval=5s --timeout=2s CMD httpx http://localhost:$PORT/health || exit 1 diff --git a/Dockerfile-Gunicorn b/Dockerfile-Gunicorn index f5a2529..a952e86 100644 --- a/Dockerfile-Gunicorn +++ b/Dockerfile-Gunicorn @@ -4,7 +4,7 @@ EXPOSE $PORT WORKDIR /app/ ENV PYTHONPATH=/app -RUN pip install --disable-pip-version-check --no-cache-dir httpx[cli] "gunicorn<21.3.0" "uvicorn<0.30.0" +RUN pip install --disable-pip-version-check --no-cache-dir httpx[cli] "gunicorn<21.3.0" "uvicorn[standard]<0.30.0" COPY ./gunicorn_conf.py /app/gunicorn_conf.py COPY ./start_service_gunicorn.sh /app/entrypoint.sh diff --git a/README.md b/README.md index 78b3c31..f50b8e3 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,8 @@ ## Description Openstack is shipping with an integrated UI to access the Object Store provided by Ceph. Unfortunately, this UI does not -allow -fine-grained control who can access a bucket or object. You can either make it accessible for everyone or nobody, but -Ceph can do this and much more. 👎 +allow fine-grained control who can access a bucket or object. You can either make it accessible for everyone or nobody, +but Ceph can do this and much more. 👎 This is the backend for a new UI which can leverage the additional powerful functionality provided by Ceph in a user-friendly manner. 👠@@ -51,8 +50,8 @@ user-friendly manner. 👠| * `CLOWM_S3__ACCESS_KEY` | `s3.acess_key` | unset | String | `ZR7U56KMK20VW` | Access key for the S3 that owns the buckets | | * `CLOWM_S3__SECRET_KEY` | `s3.secret_key` | unset | String | `9KRUU41EGSCB3H9ODECNHW` | Secret key for the S3 that owns the buckets | | * `CLOWM_S3__USERNAME` | `s3.username` | unset | String | `clowm-bucket-manager` | ID of the user in ceph who owns all the buckets. Owner of `CLOWM_S3__ACCESS_KEY` | -| * `CLOWM_S3__ADMIN_ACCESS_KEY` | `s3.admin_acess_key` | unset | String | `ZR7U56KMK20VW` | Access key for the Ceph Object Gateway user with `user:*` privileges | -| * `CLOWM_S3__ADMIN_SECRET_KEY` | `s3.admin_secret_key` | unset | String | `9KRUU41EGSCB3H9ODECNHW` | Secret key for the Ceph Object Gateway user with `user:*` privileges. | +| * `CLOWM_S3__ADMIN_ACCESS_KEY` | `s3.admin_acess_key` | unset | String | `ZR7U56KMK20VW` | Access key for the Ceph Object Gateway user with `user=*,bucket=*` capabilities. | +| * `CLOWM_S3__ADMIN_SECRET_KEY` | `s3.admin_secret_key` | unset | String | `9KRUU41EGSCB3H9ODECNHW` | Secret key for the Ceph Object Gateway user with `user=*,bucket=*` capabilities. | ### Security @@ -68,7 +67,6 @@ user-friendly manner. 👠| `CLOWM_OTLP__GRPC_ENDPOINT` | `otlp.grpc_endpoint` | unset | String | `localhost` | OTLP compatible endpoint to send traces via gRPC, e.g. Jaeger. If unset, no traces are sent. | | `CLOWM_OTLP__SECURE` | `otlp.secure` | `false` | Boolean | `false` | Connection type | - ## License The API is licensed under the [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) license. See diff --git a/app/api/dependencies.py b/app/api/dependencies.py index e391753..ef9f9c7 100644 --- a/app/api/dependencies.py +++ b/app/api/dependencies.py @@ -145,7 +145,7 @@ async def get_current_user(token: Annotated[JWT, Depends(decode_bearer_token)], uid = UUID(token.sub) except ValueError: # pragma: no cover raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Malformed JWT") - user = await CRUDUser.get(db, uid) + user = await CRUDUser.get(uid, db=db) if user: return user raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") @@ -219,7 +219,7 @@ async def get_user_by_path_uid( User with the given uid. """ - user = await CRUDUser.get(db, uid) + user = await CRUDUser.get(uid, db=db) if user: return user raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") @@ -252,7 +252,7 @@ async def get_current_bucket( bucket : clowmdb.models.Bucket Bucket with the given name. """ - bucket = await CRUDBucket.get(db, bucket_name) + bucket = await CRUDBucket.get(bucket_name, db=db) if bucket is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bucket not found") return bucket diff --git a/app/api/endpoints/bucket_permissions.py b/app/api/endpoints/bucket_permissions.py index f54efbf..6fa135f 100644 --- a/app/api/endpoints/bucket_permissions.py +++ b/app/api/endpoints/bucket_permissions.py @@ -17,8 +17,7 @@ from app.api.dependencies import ( get_user_by_path_uid, ) from app.ceph.s3 import get_s3_bucket_policy, put_s3_bucket_policy -from app.crud import DuplicateError -from app.crud.crud_bucket_permission import CRUDBucketPermission +from app.crud import CRUDBucketPermission, DuplicateError from app.otlp import start_as_current_span_async from app.schemas.bucket_permission import BucketPermissionIn, BucketPermissionOut, BucketPermissionParameters @@ -75,7 +74,7 @@ async def list_permissions( rbac_operation = "list_all" await authorization(rbac_operation) bucket_permissions = await CRUDBucketPermission.list( - db, permission_types=permission_types, permission_status=permission_status + db=db, permission_types=permission_types, permission_status=permission_status ) return [BucketPermissionOut.from_db_model(p) for p in bucket_permissions] @@ -123,11 +122,9 @@ async def create_permission( 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) + permission_db = await CRUDBucketPermission.create(permission, db=db) except ValueError as e: current_span.record_exception(e) raise HTTPException( @@ -202,7 +199,7 @@ async def list_permissions_per_user( rbac_operation = "list_user" if user == current_user 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=db, uid=user.uid, permission_types=permission_types, permission_status=permission_status ) return [BucketPermissionOut.from_db_model(p) for p in bucket_permissions] @@ -262,7 +259,7 @@ async def list_permissions_per_bucket( rbac_operation = "list_bucket" if bucket.owner_id == current_user.uid 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=db, bucket_name=bucket.name, permission_types=permission_types, permission_status=permission_status ) return [BucketPermissionOut.from_db_model(p) for p in bucket_permissions] @@ -308,7 +305,7 @@ async def get_permission_for_bucket( 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) + bucket_permission = await CRUDBucketPermission.get(bucket.name, user.uid, db=db) if bucket_permission: return BucketPermissionOut.from_db_model(bucket_permission) raise HTTPException( @@ -360,13 +357,13 @@ async def delete_permission( 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) + bucket_permission = await CRUDBucketPermission.get(bucket.name, user.uid, db=db) if bucket_permission is None: raise HTTPException( status.HTTP_404_NOT_FOUND, detail=f"Permission for combination of bucket={bucket.name} and user={str(user.uid)} doesn't exists", ) - await CRUDBucketPermission.delete(db, bucket_name=bucket_permission.bucket_name, uid=bucket_permission.uid) + await CRUDBucketPermission.delete(db=db, bucket_name=bucket_permission.bucket_name, uid=bucket_permission.uid) bucket_permission_schema = BucketPermissionOut.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) @@ -423,14 +420,14 @@ async def update_permission( await authorization("update") if bucket.owner_id != current_user.uid: raise HTTPException(status.HTTP_403_FORBIDDEN, "Action forbidden") - bucket_permission = await CRUDBucketPermission.get(db, bucket.name, user.uid) + bucket_permission = await CRUDBucketPermission.get(bucket.name, user.uid, db=db) if bucket_permission is None: raise HTTPException( status.HTTP_404_NOT_FOUND, detail=f"Permission for combination of bucket={bucket.name} and user={user.uid} doesn't exists", ) - updated_permission = await CRUDBucketPermission.update_permission(db, bucket_permission, permission_parameters) + updated_permission = await CRUDBucketPermission.update_permission(bucket_permission, permission_parameters, db=db) updated_permission_schema = BucketPermissionOut.from_db_model(updated_permission) s3_policy = get_s3_bucket_policy(s3, bucket_name=bucket.name) policy = json.loads(s3_policy.policy) diff --git a/app/api/endpoints/buckets.py b/app/api/endpoints/buckets.py index b312ba1..299eed0 100644 --- a/app/api/endpoints/buckets.py +++ b/app/api/endpoints/buckets.py @@ -6,17 +6,24 @@ from botocore.exceptions import ClientError from clowmdb.models import Bucket from fastapi import APIRouter, Body, Depends, HTTPException, Query, status from opentelemetry import trace +from pydantic import ByteSize from pydantic.json_schema import SkipJsonSchema -from app.api.dependencies import AuthorizationDependency, CurrentBucket, CurrentUser, DBSession, S3Resource +from app.api.dependencies import ( + AuthorizationDependency, + CurrentBucket, + CurrentUser, + DBSession, + RGWAdminResource, + S3Resource, +) from app.ceph.s3 import get_s3_bucket_objects, get_s3_bucket_policy, put_s3_bucket_policy from app.core.config import settings -from app.crud import DuplicateError -from app.crud.crud_bucket import CRUDBucket -from app.crud.crud_bucket_permission import CRUDBucketPermission +from app.crud import CRUDBucket, CRUDBucketPermission, DuplicateError from app.otlp import start_as_current_span_async from app.schemas.bucket import BucketIn as BucketInSchema from app.schemas.bucket import BucketOut as BucketOutSchema +from app.schemas.bucket import BucketSizeLimits router = APIRouter(prefix="/buckets", tags=["Bucket"]) bucket_authorization = AuthorizationDependency(resource="bucket") @@ -116,9 +123,9 @@ async def list_buckets( current_span.set_attribute("bucket_type", bucket_type.name) await authorization("list_all" if current_user.uid != owner_id else "list") if owner_id is None: - buckets = await CRUDBucket.get_all(db) + buckets = await CRUDBucket.get_all(db=db) else: - buckets = await CRUDBucket.get_for_user(db, owner_id, bucket_type) + buckets = await CRUDBucket.get_for_user(owner_id, bucket_type, db=db) return buckets @@ -166,7 +173,7 @@ async def create_bucket( current_span.set_attribute("bucket_name", bucket.name) await authorization("create") try: - db_bucket = await CRUDBucket.create(db, bucket, current_user.uid) + db_bucket = await CRUDBucket.create(bucket, current_user.uid, db=db) except DuplicateError as e: current_span.record_exception(e) raise HTTPException( @@ -238,14 +245,14 @@ async def get_bucket( trace.get_current_span().set_attribute("bucket_name", bucket.name) rbac_operation = ( "read_any" - if not bucket.public and not await CRUDBucketPermission.check_permission(db, bucket.name, current_user.uid) + if not bucket.public and not await CRUDBucketPermission.check_permission(bucket.name, current_user.uid, db=db) else "read" ) await authorization(rbac_operation) return bucket -@router.patch("/{bucket_name}/public", response_model=BucketOutSchema, summary="update public status") +@router.patch("/{bucket_name}/public", response_model=BucketOutSchema, summary="Update public status") @start_as_current_span_async("api_update_bucket_public_state", tracer=tracer) async def update_bucket_public_state( bucket: CurrentBucket, @@ -256,7 +263,7 @@ async def update_bucket_public_state( public: Annotated[bool, Body(..., embed=True, description="New State")], ) -> Bucket: """ - Toggle the buckets public state. A bucket with an owner constraint can't be made public.\n + Update the buckets public state.\n Permission `bucket:update` required if the current user is the owner of the bucket, otherwise `bucket:update_any` required. \f @@ -283,11 +290,6 @@ async def update_bucket_public_state( trace.get_current_span().set_attributes({"bucket_name": bucket.name, "public": public}) rbac_operation = "update" if bucket.owner_id == current_user.uid else "update_any" await authorization(rbac_operation) - if bucket.owner_constraint is not None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"can't change the public state of a bucket with owner constraint {bucket.owner_constraint}", - ) if bucket.public == public: return bucket s3_policy = get_s3_bucket_policy(s3, bucket_name=bucket.name) @@ -302,6 +304,68 @@ async def update_bucket_public_state( return bucket +@router.patch("/{bucket_name}/limits", response_model=BucketOutSchema, summary="Update bucket limits") +@start_as_current_span_async("api_update_bucket_limits", tracer=tracer) +async def update_bucket_limits( + bucket: CurrentBucket, + authorization: Authorization, + db: DBSession, + rgw: RGWAdminResource, + limits: BucketSizeLimits, +) -> Bucket: + """ + Update the buckets size limits.\n + Permission `bucket:update_any` required. + \f + Parameters + ---------- + bucket : clowmdb.models.Bucket + Bucket with the name provided in the URL path. Dependency Injection. + authorization : Callable[[str], Awaitable[Any]] + Async function to ask the auth service for authorization. Dependency Injection. + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. Dependency Injection. + rgw : rgwadmin.RGWAdmin + RGW admin interface to manage Ceph's object store. Dependency Injection. + limits : app.schemas.bucket.BucketSizeLimits + New bucket limits. HTTP Body. + + Returns + ------- + bucket : clowmdb.models.Bucket + Bucket with the toggled public state. + """ + current_span = trace.get_current_span() + current_span.set_attribute("bucket_name", bucket.name) + if limits.size_limit is not None: # pragma: no cover + current_span.set_attribute("size_limit", ByteSize(limits.size_limit * 1024).human_readable()) + if limits.object_limit is not None: # pragma: no cover + current_span.set_attribute("object_limit", limits.object_limit) + await authorization("update_any") + with tracer.start_as_current_span( + "rgw_set_bucket_limits", + attributes={ + "bucket_name": bucket.name, + "enabled": limits.object_limit is not None or limits.size_limit is not None, + }, + ) as span: + if limits.size_limit is not None: # pragma: no cover + span.set_attribute("size_limit", ByteSize(limits.size_limit * 1024).human_readable()) + if limits.object_limit is not None: # pragma: no cover + span.set_attribute("object_limit", limits.object_limit) + rgw.set_bucket_quota( + uid=str(bucket.owner_id), + bucket=bucket.name, + max_size_kb=-1 if limits.size_limit is None else limits.size_limit, + max_objects=-1 if limits.object_limit is None else limits.object_limit, + enabled=limits.object_limit is not None or limits.size_limit is not None, + ) + await CRUDBucket.update_bucket_limits( + db=db, bucket_name=bucket.name, object_limit=limits.object_limit, size_limit=limits.size_limit + ) + return bucket + + @router.delete("/{bucket_name}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a bucket") @start_as_current_span_async("api_delete_bucket", tracer=tracer) async def delete_bucket( @@ -345,6 +409,6 @@ async def delete_bucket( with tracer.start_as_current_span("s3_delete_bucket") as span: span.set_attribute("bucket_name", bucket.name) s3.Bucket(name=bucket.name).delete() - await CRUDBucket.delete(db, bucket.name) + await CRUDBucket.delete(bucket.name, db=db) except ClientError: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Bucket not empty") diff --git a/app/core/config.py b/app/core/config.py index 6b14107..28b234b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -52,10 +52,10 @@ class S3Settings(BaseModel): ..., description="ID of the user in ceph who owns all the buckets. Owner of 'CLOWM_S3__ACCESS_KEY'" ) admin_access_key: str = Field( - ..., description="Access key for the Ceph Object Gateway with 'user:read,user:write privileges'." + ..., description="Access key for the Ceph Object Gateway user with `user=*,bucket=*` capabilities." ) admin_secret_key: SecretStr = Field( - ..., description="Secret key for the Ceph Object Gateway with 'user:read,user:write privileges'." + ..., description="Secret key for the Ceph Object Gateway user with `user=*,bucket=*` capabilities." ) @@ -91,7 +91,7 @@ class EmailSettings(BaseModel): class Settings(BaseSettings): - api_prefix: str = Field("/api/s3proxy-service", description="Path Prefix for all API endpoints.") + api_prefix: str = Field("", description="Path Prefix for all API endpoints.") public_key: SecretStr | None = Field(None, description="Public RSA Key in PEM format to verify the JWTs.") public_key_file: FilePath | None = Field( None, description="Path to Public RSA Key in PEM format to verify the JWTs." @@ -138,6 +138,7 @@ class Settings(BaseSettings): ) -> tuple[PydanticBaseSettingsSource, ...]: return ( env_settings, + file_secret_settings, dotenv_settings, YamlConfigSettingsSource(settings_cls), TomlConfigSettingsSource(settings_cls), diff --git a/app/crud/__init__.py b/app/crud/__init__.py index e9fad32..0b40609 100644 --- a/app/crud/__init__.py +++ b/app/crud/__init__.py @@ -1,2 +1,6 @@ -class DuplicateError(Exception): - pass +from .crud_bucket import CRUDBucket +from .crud_bucket_permission import CRUDBucketPermission +from .crud_error import DuplicateError +from .crud_user import CRUDUser + +__all__ = ["CRUDBucketPermission", "CRUDUser", "CRUDBucket", "DuplicateError"] diff --git a/app/crud/crud_bucket.py b/app/crud/crud_bucket.py index 729e030..033df16 100644 --- a/app/crud/crud_bucket.py +++ b/app/crud/crud_bucket.py @@ -1,14 +1,15 @@ -from enum import Enum, unique +from enum import StrEnum, unique from typing import Sequence from uuid import UUID from clowmdb.models import Bucket from clowmdb.models import BucketPermission as BucketPermissionDB from opentelemetry import trace +from pydantic import ByteSize from sqlalchemy import delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.crud import DuplicateError +from app.crud.crud_error import DuplicateError from app.schemas.bucket import BucketIn as BucketInSchema tracer = trace.get_tracer_provider().get_tracer(__name__) @@ -16,7 +17,7 @@ tracer = trace.get_tracer_provider().get_tracer(__name__) class CRUDBucket: @unique - class BucketType(str, Enum): + class BucketType(StrEnum): """ Enumeration for the type of buckets to fetch from the DB @@ -25,12 +26,12 @@ class CRUDBucket: ALL: Fetch all buckets that the user has access to """ - OWN: str = "OWN" - ALL: str = "ALL" - PERMISSION: str = "PERMISSION" + OWN = "OWN" + ALL = "ALL" + PERMISSION = "PERMISSION" @staticmethod - async def get(db: AsyncSession, bucket_name: str) -> Bucket | None: + async def get(bucket_name: str, *, db: AsyncSession) -> Bucket | None: """ Get a bucket by its name. @@ -59,7 +60,9 @@ class CRUDBucket: return (await db.scalars(stmt)).all() @staticmethod - async def get_for_user(db: AsyncSession, uid: UUID, bucket_type: BucketType = BucketType.ALL) -> Sequence[Bucket]: + async def get_for_user( + uid: UUID, bucket_type: BucketType = BucketType.ALL, *, db: AsyncSession + ) -> 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 @@ -154,7 +157,7 @@ class CRUDBucket: return (await db.scalars(stmt)).all() @staticmethod - async def create(db: AsyncSession, bucket_in: BucketInSchema, uid: UUID) -> Bucket: + async def create(bucket_in: BucketInSchema, uid: UUID, *, db: AsyncSession) -> Bucket: """ Create a bucket for a given user. @@ -177,14 +180,14 @@ class CRUDBucket: "db_create_bucket", attributes={"uid": str(uid), "bucket_name": bucket.name}, ): - if await CRUDBucket.get(db, bucket.name) is not None: + if await CRUDBucket.get(bucket.name, db=db) is not None: raise DuplicateError(f"Bucket {bucket.name} exists already") db.add(bucket) await db.commit() return bucket @staticmethod - async def update_public_state(db: AsyncSession, bucket_name: str, public: bool) -> None: + async def update_public_state(bucket_name: str, public: bool, *, db: AsyncSession) -> None: """ Update the public state of a bucket @@ -200,13 +203,42 @@ class CRUDBucket: stmt = update(Bucket).where(Bucket.name == bucket_name).values(public=public) with tracer.start_as_current_span( "db_update_bucket_public_state", - attributes={"bucket_name": bucket_name, "public": public}, + attributes={"bucket_name": bucket_name, "public": public, "sql_query": str(stmt)}, ): await db.execute(stmt) await db.commit() @staticmethod - async def delete(db: AsyncSession, bucket_name: str) -> None: + async def update_bucket_limits( + bucket_name: str, size_limit: int | None = None, object_limit: int | None = None, *, db: AsyncSession + ) -> None: + """ + Update the bucket limits. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession + Async database session to perform query on. + bucket_name : str + Name of a bucket. + size_limit : int | None, default None + New size limit for the bucket. + object_limit : int | None, default None + New object limit for the bucket. + """ + stmt = update(Bucket).where(Bucket.name == bucket_name).values(size_limit=size_limit, object_limit=object_limit) + with tracer.start_as_current_span( + "db_update_bucket_limits", attributes={"bucket_name": bucket_name, "sql_query": str(stmt)} + ) as span: + if size_limit is not None: # pragma: no cover + span.set_attribute("size_limit", ByteSize(size_limit * 1024).human_readable()) + if object_limit is not None: # pragma: no cover + span.set_attribute("object_limit", object_limit) + await db.execute(stmt) + await db.commit() + + @staticmethod + async def delete(bucket_name: str, *, db: AsyncSession) -> None: """ Delete a specific bucket. diff --git a/app/crud/crud_bucket_permission.py b/app/crud/crud_bucket_permission.py index 28e9a15..bd2c909 100644 --- a/app/crud/crud_bucket_permission.py +++ b/app/crud/crud_bucket_permission.py @@ -1,4 +1,4 @@ -from enum import Enum, unique +from enum import StrEnum, unique from typing import Sequence from uuid import UUID @@ -9,8 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from sqlalchemy.sql import Select as SQLSelect -from app.crud import DuplicateError from app.crud.crud_bucket import CRUDBucket +from app.crud.crud_error import DuplicateError from app.crud.crud_user import CRUDUser from app.otlp import start_as_current_span_async from app.schemas.bucket_permission import BucketPermissionIn as BucketPermissionSchema @@ -21,17 +21,17 @@ tracer = trace.get_tracer_provider().get_tracer(__name__) class CRUDBucketPermission: @unique - class PermissionStatus(str, Enum): + class PermissionStatus(StrEnum): """ Status of a bucket permission. Can be either `ACTIVE` or `INACTIVE`. A permission can only get `INACTIVE` if the permission itself has a time limit and the current time is not in the timespan. """ - ACTIVE: str = "ACTIVE" - INACTIVE: str = "INACTIVE" + ACTIVE = "ACTIVE" + INACTIVE = "INACTIVE" @staticmethod - async def get(db: AsyncSession, bucket_name: str, uid: UUID) -> BucketPermissionDB | None: + async def get(bucket_name: str, uid: UUID, *, db: AsyncSession) -> BucketPermissionDB | None: stmt = select(BucketPermissionDB).where( BucketPermissionDB.uid_bytes == uid.bytes, BucketPermissionDB.bucket_name == bucket_name ) @@ -44,11 +44,12 @@ class CRUDBucketPermission: @staticmethod @start_as_current_span_async("db_list_bucket_permissions", tracer=tracer) async def list( - db: AsyncSession, bucket_name: str | None = None, uid: UUID | None = None, permission_types: list[BucketPermissionDB.Permission] | None = None, permission_status: PermissionStatus | None = None, + *, + db: AsyncSession, ) -> Sequence[BucketPermissionDB]: """ Get the permissions for the given bucket. @@ -92,7 +93,7 @@ class CRUDBucketPermission: return (await db.scalars(stmt)).all() @staticmethod - async def check_permission(db: AsyncSession, bucket_name: str, uid: UUID) -> bool: + async def check_permission(bucket_name: str, uid: UUID, *, db: AsyncSession) -> bool: """ Check if the provided user has any permission to the provided bucket. @@ -114,12 +115,12 @@ class CRUDBucketPermission: "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) + buckets = await CRUDBucket.get_for_user(uid, bucket_type=CRUDBucket.BucketType.ALL, db=db) return bucket_name in map(lambda x: x.name, buckets) @staticmethod @start_as_current_span_async("db_create_bucket_permission", tracer=tracer) - async def create(db: AsyncSession, permission: BucketPermissionSchema) -> BucketPermissionDB: + async def create(permission: BucketPermissionSchema, *, db: AsyncSession) -> BucketPermissionDB: """ Create a permission in the database and raise Exceptions if there are problems. @@ -136,17 +137,17 @@ class CRUDBucketPermission: """ 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) + user = await CRUDUser.get(uid=permission.uid, db=db) if user is None: raise KeyError( 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) + bucket = await CRUDBucket.get(permission.bucket_name, db=db) if bucket is None or bucket.owner_id == user.uid: 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) + previous_permission = await CRUDBucketPermission.get(bucket_name=permission.bucket_name, uid=user.uid, db=db) if previous_permission is not None: raise DuplicateError( f"bucket permission for combination {permission.bucket_name} {str(permission.uid)} already exists." @@ -166,7 +167,7 @@ class CRUDBucketPermission: return permission_db @staticmethod - async def delete(db: AsyncSession, bucket_name: str, uid: UUID) -> None: + async def delete(bucket_name: str, uid: UUID, *, db: AsyncSession) -> None: """ Delete a permission in the database. @@ -191,7 +192,7 @@ class CRUDBucketPermission: @staticmethod async def update_permission( - db: AsyncSession, permission: BucketPermissionDB, new_params: BucketPermissionParametersSchema + permission: BucketPermissionDB, new_params: BucketPermissionParametersSchema, *, db: AsyncSession ) -> BucketPermissionDB: """ Update a permission in the database. diff --git a/app/crud/crud_error.py b/app/crud/crud_error.py new file mode 100644 index 0000000..e9fad32 --- /dev/null +++ b/app/crud/crud_error.py @@ -0,0 +1,2 @@ +class DuplicateError(Exception): + pass diff --git a/app/crud/crud_user.py b/app/crud/crud_user.py index 158c386..f2e8da6 100644 --- a/app/crud/crud_user.py +++ b/app/crud/crud_user.py @@ -10,7 +10,7 @@ tracer = trace.get_tracer_provider().get_tracer(__name__) class CRUDUser: @staticmethod - async def get(db: AsyncSession, uid: UUID) -> User | None: + async def get(uid: UUID, *, db: AsyncSession) -> User | None: """ Get a user by its UID. diff --git a/app/schemas/bucket.py b/app/schemas/bucket.py index 568dc20..b73d6a0 100644 --- a/app/schemas/bucket.py +++ b/app/schemas/bucket.py @@ -1,6 +1,5 @@ import re -from clowmdb.models import Bucket from pydantic import BaseModel, ConfigDict, Field, field_validator from app.schemas import UUID @@ -44,7 +43,16 @@ class BucketIn(_BaseBucket): """ -class BucketOut(_BaseBucket): +class BucketSizeLimits(BaseModel): + size_limit: int | None = Field( + None, gt=0, lt=2**32, description="Size limit of the bucket in KiB", examples=[10240] + ) + object_limit: int | None = Field( + None, gt=0, lt=2**32, description="Number of objects limit of the bucket", examples=[10000] + ) + + +class BucketOut(_BaseBucket, BucketSizeLimits): """ Schema for answering a request with a bucket. """ @@ -55,6 +63,5 @@ class BucketOut(_BaseBucket): description="Time when the bucket was created as UNIX timestamp", ) owner_id: UUID = Field(..., description="UID of the owner", examples=["1d3387f3-95c0-4813-8767-2cad87faeebf"]) - owner_constraint: Bucket.Constraint | None = Field(None, description="Constraint for the owner of the bucket") public: bool = Field(..., description="Flag if the bucket is anonymously readable") model_config = ConfigDict(from_attributes=True) diff --git a/app/tests/api/test_bucket_permissions.py b/app/tests/api/test_bucket_permissions.py index 001dcd7..b61909a 100644 --- a/app/tests/api/test_bucket_permissions.py +++ b/app/tests/api/test_bucket_permissions.py @@ -6,7 +6,6 @@ from clowmdb.models import Bucket, BucketPermission from fastapi import status from httpx import AsyncClient from pydantic import TypeAdapter -from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.schemas.bucket_permission import BucketPermissionIn as BucketPermissionSchema @@ -423,43 +422,6 @@ class TestBucketPermissionRoutesCreate(_TestBucketPermissionRoutes): 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( - self, - client: AsyncClient, - db: AsyncSession, - random_user: UserWithAuthHeader, - random_second_user: UserWithAuthHeader, - random_bucket: Bucket, - ) -> None: - """ - Test for creating a bucket permission on an initial READ Bucket. - - Parameters - ---------- - client : httpx.AsyncClient - HTTP Client to perform the request on. - db : sqlalchemy.ext.asyncio.AsyncSession. - Async database session to perform query on. - random_user : app.tests.utils.user.UserWithAuthHeader - Random user for testing. - random_second_user : app.tests.utils.user.UserWithAuthHeader - Random second user for testing. - random_bucket : clowmdb.models.Bucket - Random bucket for testing. - """ - update_stmt = ( - update(Bucket).where(Bucket.name == random_bucket.name).values(owner_constraint=Bucket.Constraint.READ) - ) - await db.execute(update_stmt) - await db.commit() - 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, content=permission.model_dump_json() - ) - assert response.status_code == status.HTTP_403_FORBIDDEN - class TestBucketPermissionRoutesDelete(_TestBucketPermissionRoutes): @pytest.mark.asyncio diff --git a/app/tests/api/test_buckets.py b/app/tests/api/test_buckets.py index b771e86..d740c8a 100644 --- a/app/tests/api/test_buckets.py +++ b/app/tests/api/test_buckets.py @@ -3,12 +3,11 @@ from clowmdb.models import Bucket, BucketPermission from fastapi import status from httpx import AsyncClient from pydantic import TypeAdapter -from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.api.endpoints.buckets import ANONYMOUS_ACCESS_SID -from app.crud.crud_bucket import CRUDBucket -from app.schemas.bucket import BucketIn, BucketOut +from app.crud import CRUDBucket +from app.schemas.bucket import BucketIn, BucketOut, BucketSizeLimits from app.tests.mocks.mock_s3_resource import MockS3ServiceResource from app.tests.utils.bucket import add_permission_for_bucket, delete_bucket, make_bucket_public from app.tests.utils.cleanup import CleanupList @@ -216,7 +215,7 @@ class TestBucketRoutesCreate(_TestBucketRoutes): assert bucket.name == bucket_info.name assert bucket.owner_id == random_user.user.uid - db_bucket = await CRUDBucket.get(db, bucket_info.name) + db_bucket = await CRUDBucket.get(bucket_info.name, db=db) assert db_bucket assert db_bucket.name == bucket_info.name assert db_bucket.owner_id == random_user.user.uid @@ -284,13 +283,11 @@ class TestBucketRoutesUpdate(_TestBucketRoutes): assert ANONYMOUS_ACCESS_SID in mock_s3_service.Bucket(bucket.name).Policy().policy @pytest.mark.asyncio - async def test_make_bucket_private( + async def test_update_bucket_limits( self, client: AsyncClient, random_bucket: Bucket, random_user: UserWithAuthHeader, - mock_s3_service: MockS3ServiceResource, - db: AsyncSession, ) -> None: """ Test for getting a foreign public bucket. @@ -303,26 +300,25 @@ class TestBucketRoutesUpdate(_TestBucketRoutes): Random bucket for testing. random_user : app.tests.utils.user.UserWithAuthHeader Random user who is owner of the bucket. - mock_s3_service : app.tests.mocks.mock_s3_resource.MockS3ServiceResource - Mock S3 Service to manipulate objects. - db : sqlalchemy.ext.asyncio.AsyncSession. - Async database session to perform query on. """ - await make_bucket_public(db=db, s3=mock_s3_service, bucket_name=random_bucket.name) response = await client.patch( - f"{self.base_path}/{random_bucket.name}/public", headers=random_user.auth_headers, json={"public": False} + f"{self.base_path}/{random_bucket.name}/limits", + headers=random_user.auth_headers, + content=BucketSizeLimits(size_limit=10240, object_limit=1000).model_dump_json(), ) assert response.status_code == status.HTTP_200_OK bucket = BucketOut.model_validate_json(response.content) assert bucket.name == random_bucket.name - assert not bucket.public + assert bucket.size_limit is not None + assert bucket.size_limit == 10240 - assert ANONYMOUS_ACCESS_SID not in mock_s3_service.Bucket(bucket.name).Policy().policy + assert bucket.object_limit is not None + assert bucket.object_limit == 1000 @pytest.mark.asyncio - async def test_make_private_bucket_private( + async def test_make_bucket_private( self, client: AsyncClient, random_bucket: Bucket, @@ -346,6 +342,7 @@ class TestBucketRoutesUpdate(_TestBucketRoutes): db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. """ + await make_bucket_public(db=db, s3=mock_s3_service, bucket_name=random_bucket.name) response = await client.patch( f"{self.base_path}/{random_bucket.name}/public", headers=random_user.auth_headers, json={"public": False} ) @@ -359,11 +356,12 @@ class TestBucketRoutesUpdate(_TestBucketRoutes): assert ANONYMOUS_ACCESS_SID not in mock_s3_service.Bucket(bucket.name).Policy().policy @pytest.mark.asyncio - async def test_make_bucket_public_with_owner_constraint( + async def test_make_private_bucket_private( self, client: AsyncClient, random_bucket: Bucket, random_user: UserWithAuthHeader, + mock_s3_service: MockS3ServiceResource, db: AsyncSession, ) -> None: """ @@ -377,19 +375,22 @@ class TestBucketRoutesUpdate(_TestBucketRoutes): Random bucket for testing. random_user : app.tests.utils.user.UserWithAuthHeader Random user who is owner of the bucket. + mock_s3_service : app.tests.mocks.mock_s3_resource.MockS3ServiceResource + Mock S3 Service to manipulate objects. db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. """ - update_stmt = ( - update(Bucket).where(Bucket.name == random_bucket.name).values(owner_constraint=Bucket.Constraint.READ) - ) - await db.execute(update_stmt) - await db.commit() - response = await client.patch( - f"{self.base_path}/{random_bucket.name}/public", headers=random_user.auth_headers, json={"public": True} + f"{self.base_path}/{random_bucket.name}/public", headers=random_user.auth_headers, json={"public": False} ) - assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code == status.HTTP_200_OK + + bucket = BucketOut.model_validate_json(response.content) + + assert bucket.name == random_bucket.name + assert not bucket.public + + assert ANONYMOUS_ACCESS_SID not in mock_s3_service.Bucket(bucket.name).Policy().policy class TestBucketRoutesDelete(_TestBucketRoutes): diff --git a/app/tests/crud/test_bucket.py b/app/tests/crud/test_bucket.py index 91e3436..7585b8c 100644 --- a/app/tests/crud/test_bucket.py +++ b/app/tests/crud/test_bucket.py @@ -5,8 +5,7 @@ from clowmdb.models import Bucket, BucketPermission from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.crud import DuplicateError -from app.crud.crud_bucket import CRUDBucket +from app.crud import CRUDBucket, DuplicateError from app.schemas.bucket import BucketIn from app.tests.utils.bucket import add_permission_for_bucket, delete_bucket from app.tests.utils.cleanup import CleanupList @@ -27,7 +26,7 @@ class TestBucketCRUDGet: random_bucket : clowmdb.models.Bucket Random bucket for testing. """ - buckets = await CRUDBucket.get_all(db) + buckets = await CRUDBucket.get_all(db=db) assert len(buckets) == 1 bucket = buckets[0] assert bucket.name == random_bucket.name @@ -46,7 +45,7 @@ class TestBucketCRUDGet: random_bucket : clowmdb.models.Bucket Random bucket for testing. """ - bucket = await CRUDBucket.get(db, random_bucket.name) + bucket = await CRUDBucket.get(random_bucket.name, db=db) assert bucket assert bucket.name == random_bucket.name assert bucket.public == random_bucket.public @@ -62,7 +61,7 @@ class TestBucketCRUDGet: db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. """ - bucket = await CRUDBucket.get(db, "unknown Bucket") + bucket = await CRUDBucket.get("unknown Bucket", db=db) assert bucket is None @pytest.mark.asyncio @@ -77,7 +76,7 @@ class TestBucketCRUDGet: random_bucket : clowmdb.models.Bucket Random bucket for testing. """ - buckets = await CRUDBucket.get_for_user(db, random_bucket.owner_id, CRUDBucket.BucketType.OWN) + buckets = await CRUDBucket.get_for_user(random_bucket.owner_id, CRUDBucket.BucketType.OWN, db=db) assert len(buckets) == 1 assert buckets[0].name == random_bucket.name @@ -120,7 +119,7 @@ class TestBucketCRUDGet: db, bucket.name, random_bucket.owner_id, permission=BucketPermission.Permission.READ ) - buckets = await CRUDBucket.get_for_user(db, random_bucket.owner_id, CRUDBucket.BucketType.PERMISSION) + buckets = await CRUDBucket.get_for_user(random_bucket.owner_id, CRUDBucket.BucketType.PERMISSION, db=db) assert len(buckets) == 1 assert buckets[0] != random_bucket assert buckets[0].name == bucket.name @@ -163,7 +162,7 @@ class TestBucketCRUDGet: db, bucket.name, random_bucket.owner_id, permission=BucketPermission.Permission.READ ) - buckets = await CRUDBucket.get_for_user(db, random_bucket.owner_id) + buckets = await CRUDBucket.get_for_user(random_bucket.owner_id, db=db) assert len(buckets) == 2 assert buckets[0].name == random_bucket.name or buckets[1].name == random_bucket.name @@ -189,7 +188,7 @@ class TestBucketCRUDGet: db, random_bucket.name, random_second_user.user.uid, permission=BucketPermission.Permission.READ ) - buckets = await CRUDBucket.get_for_user(db, random_second_user.user.uid) + buckets = await CRUDBucket.get_for_user(random_second_user.user.uid, db=db) assert len(buckets) > 0 assert buckets[0].name == random_bucket.name @@ -214,7 +213,7 @@ class TestBucketCRUDGet: db, random_bucket.name, random_second_user.user.uid, permission=BucketPermission.Permission.READWRITE ) - buckets = await CRUDBucket.get_for_user(db, random_second_user.user.uid) + buckets = await CRUDBucket.get_for_user(random_second_user.user.uid, db=db) assert len(buckets) > 0 assert buckets[0].name == random_bucket.name @@ -239,7 +238,7 @@ class TestBucketCRUDGet: db, random_bucket.name, random_second_user.user.uid, permission=BucketPermission.Permission.WRITE ) - buckets = await CRUDBucket.get_for_user(db, random_second_user.user.uid) + buckets = await CRUDBucket.get_for_user(random_second_user.user.uid, db=db) assert len(buckets) == 1 assert buckets[0] == random_bucket @@ -268,7 +267,7 @@ class TestBucketCRUDGet: to=datetime.now() + timedelta(days=10), ) - buckets = await CRUDBucket.get_for_user(db, random_second_user.user.uid) + buckets = await CRUDBucket.get_for_user(random_second_user.user.uid, db=db) assert len(buckets) > 0 assert buckets[0].name == random_bucket.name @@ -293,7 +292,7 @@ class TestBucketCRUDGet: db, random_bucket.name, random_second_user.user.uid, from_=datetime.now() + timedelta(days=10) ) - buckets = await CRUDBucket.get_for_user(db, random_second_user.user.uid) + buckets = await CRUDBucket.get_for_user(random_second_user.user.uid, db=db) assert len(buckets) == 0 @@ -317,7 +316,7 @@ class TestBucketCRUDGet: db, random_bucket.name, random_second_user.user.uid, to=datetime.now() - timedelta(days=10) ) - buckets = await CRUDBucket.get_for_user(db, random_second_user.user.uid) + buckets = await CRUDBucket.get_for_user(random_second_user.user.uid, db=db) assert len(buckets) == 0 @@ -343,7 +342,7 @@ class TestBucketCRUDCreate: 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) + bucket = await CRUDBucket.create(bucket_info, random_user.user.uid, db=db) cleanup.add_task( delete_bucket, db=db, @@ -375,14 +374,14 @@ class TestBucketCRUDCreate: """ bucket_info = BucketIn(name=random_bucket.name, description=random_lower_string(127)) with pytest.raises(DuplicateError): - await CRUDBucket.create(db, bucket_info, random_bucket.owner_id) + await CRUDBucket.create(bucket_info, random_bucket.owner_id, db=db) class TestBucketCRUDUpdate: @pytest.mark.asyncio async def test_update_public_state(self, db: AsyncSession, random_bucket: Bucket) -> None: """ - Test for deleting a bucket with the CRUD Repository. + Test for updating the bucket public state with the CRUD Repository. Parameters ---------- @@ -401,6 +400,31 @@ class TestBucketCRUDUpdate: assert bucket_db == random_bucket assert old_public_state != bucket_db.public + @pytest.mark.asyncio + async def test_update_bucket_limits(self, db: AsyncSession, random_bucket: Bucket) -> None: + """ + Test for updating the bucket limits with the CRUD Repository. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. + random_bucket : clowmdb.models.Bucket + Random bucket for testing. + """ + await CRUDBucket.update_bucket_limits(db=db, bucket_name=random_bucket.name, size_limit=100, object_limit=120) + + stmt = select(Bucket).where(Bucket.name == random_bucket.name) + bucket_db = await db.scalar(stmt) + + assert bucket_db is not None + assert bucket_db == random_bucket + assert bucket_db.size_limit is not None + assert bucket_db.size_limit == 100 + + assert bucket_db.object_limit is not None + assert bucket_db.object_limit == 120 + class TestBucketCRUDDelete: @pytest.mark.asyncio @@ -415,7 +439,7 @@ class TestBucketCRUDDelete: random_bucket : clowmdb.models.Bucket Random bucket for testing. """ - await CRUDBucket.delete(db, random_bucket.name) + await CRUDBucket.delete(random_bucket.name, db=db) stmt = select(Bucket).where(Bucket.name == random_bucket.name) bucket_db = await db.scalar(stmt) diff --git a/app/tests/crud/test_bucket_permission.py b/app/tests/crud/test_bucket_permission.py index 36224dd..72ac9a9 100644 --- a/app/tests/crud/test_bucket_permission.py +++ b/app/tests/crud/test_bucket_permission.py @@ -7,8 +7,7 @@ from clowmdb.models import BucketPermission as BucketPermissionDB from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from app.crud import DuplicateError -from app.crud.crud_bucket_permission import CRUDBucketPermission +from app.crud import CRUDBucketPermission, DuplicateError from app.schemas.bucket_permission import BucketPermissionIn as BucketPermissionSchema from app.schemas.bucket_permission import BucketPermissionParameters as BucketPermissionParametersSchema from app.tests.utils.bucket import add_permission_for_bucket @@ -31,7 +30,7 @@ class TestBucketPermissionCRUDGet: Bucket permission for a random bucket for testing. """ bucket_permission = await CRUDBucketPermission.get( - db, bucket_name=random_bucket_permission.bucket_name, uid=random_bucket_permission.uid + bucket_name=random_bucket_permission.bucket_name, uid=random_bucket_permission.uid, db=db ) assert bucket_permission assert bucket_permission.uid == random_bucket_permission.uid @@ -52,7 +51,7 @@ class TestBucketPermissionCRUDGet: random_bucket_permission : clowmdb.models.BucketPermission Bucket permission for a random bucket for testing. """ - bucket_permissions = await CRUDBucketPermission.list(db, bucket_name=random_bucket_permission.bucket_name) + bucket_permissions = await CRUDBucketPermission.list(db=db, bucket_name=random_bucket_permission.bucket_name) assert len(bucket_permissions) == 1 bucket_permission = bucket_permissions[0] assert bucket_permission.uid == random_bucket_permission.uid @@ -84,7 +83,7 @@ class TestBucketPermissionCRUDGet: db, random_bucket.name, random_third_user.user.uid, permission=BucketPermissionDB.Permission.WRITE ) bucket_permissions = await CRUDBucketPermission.list( - db, bucket_name=random_bucket.name, permission_types=[BucketPermissionDB.Permission.READ] + db=db, bucket_name=random_bucket.name, permission_types=[BucketPermissionDB.Permission.READ] ) assert len(bucket_permissions) == 1 assert bucket_permissions[0].uid == random_second_user.user.uid @@ -114,7 +113,7 @@ class TestBucketPermissionCRUDGet: db, random_bucket.name, random_third_user.user.uid, permission=BucketPermissionDB.Permission.WRITE ) bucket_permissions = await CRUDBucketPermission.list( - db, + db=db, bucket_name=random_bucket.name, permission_types=[BucketPermissionDB.Permission.READ, BucketPermissionDB.Permission.WRITE], ) @@ -150,7 +149,7 @@ class TestBucketPermissionCRUDGet: db, random_bucket.name, random_third_user.user.uid, from_=datetime.now() - timedelta(weeks=1) ) bucket_permissions = await CRUDBucketPermission.list( - db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE + db=db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE ) assert len(bucket_permissions) == 1 assert bucket_permissions[0].uid == random_third_user.user.uid @@ -183,7 +182,7 @@ class TestBucketPermissionCRUDGet: db, random_bucket.name, random_third_user.user.uid, to=datetime.now() + timedelta(weeks=1) ) bucket_permissions = await CRUDBucketPermission.list( - db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE + db=db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE ) assert len(bucket_permissions) == 1 assert bucket_permissions[0].uid == random_third_user.user.uid @@ -224,7 +223,7 @@ class TestBucketPermissionCRUDGet: from_=datetime.now() - timedelta(weeks=1), ) bucket_permissions = await CRUDBucketPermission.list( - db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE + db=db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE ) assert len(bucket_permissions) == 1 assert bucket_permissions[0].uid == random_third_user.user.uid @@ -257,7 +256,7 @@ class TestBucketPermissionCRUDGet: db, random_bucket.name, random_third_user.user.uid, from_=datetime.now() - timedelta(weeks=1) ) bucket_permissions = await CRUDBucketPermission.list( - db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.INACTIVE + db=db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.INACTIVE ) assert len(bucket_permissions) == 1 assert bucket_permissions[0].uid == random_second_user.user.uid @@ -290,7 +289,7 @@ class TestBucketPermissionCRUDGet: db, random_bucket.name, random_third_user.user.uid, to=datetime.now() + timedelta(weeks=1) ) bucket_permissions = await CRUDBucketPermission.list( - db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.INACTIVE + db=db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.INACTIVE ) assert len(bucket_permissions) == 1 assert bucket_permissions[0].uid == random_second_user.user.uid @@ -331,7 +330,7 @@ class TestBucketPermissionCRUDGet: from_=datetime.now() - timedelta(weeks=1), ) bucket_permissions = await CRUDBucketPermission.list( - db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.INACTIVE + db=db, bucket_name=random_bucket.name, permission_status=CRUDBucketPermission.PermissionStatus.INACTIVE ) assert len(bucket_permissions) == 1 assert bucket_permissions[0].uid == random_second_user.user.uid @@ -350,7 +349,7 @@ class TestBucketPermissionCRUDGet: random_bucket_permission : clowmdb.models.BucketPermission Bucket permission for a random bucket for testing. """ - bucket_permissions = await CRUDBucketPermission.list(db, uid=random_bucket_permission.uid) + bucket_permissions = await CRUDBucketPermission.list(db=db, uid=random_bucket_permission.uid) assert len(bucket_permissions) == 1 bucket_permission = bucket_permissions[0] assert bucket_permission.uid == random_bucket_permission.uid @@ -378,7 +377,7 @@ class TestBucketPermissionCRUDGet: db, random_bucket.name, random_second_user.user.uid, permission=BucketPermissionDB.Permission.WRITE ) bucket_permissions = await CRUDBucketPermission.list( - db, uid=random_second_user.user.uid, permission_types=[BucketPermissionDB.Permission.READ] + db=db, uid=random_second_user.user.uid, permission_types=[BucketPermissionDB.Permission.READ] ) assert len(bucket_permissions) == 0 @@ -405,7 +404,7 @@ class TestBucketPermissionCRUDGet: ) bucket_permissions = await CRUDBucketPermission.list( - db, uid=random_second_user.user.uid, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE + db=db, uid=random_second_user.user.uid, permission_status=CRUDBucketPermission.PermissionStatus.ACTIVE ) assert len(bucket_permissions) == 0 @@ -425,7 +424,7 @@ class TestBucketPermissionCRUDCreate: """ permission = BucketPermissionSchema(bucket_name=random_bucket.name, uid=uuid4()) with pytest.raises(KeyError): - await CRUDBucketPermission.create(db, permission) + await CRUDBucketPermission.create(permission, db=db) @pytest.mark.asyncio async def test_create_bucket_permissions_for_owner( @@ -445,7 +444,7 @@ class TestBucketPermissionCRUDCreate: """ permission = BucketPermissionSchema(bucket_name=random_bucket.name, uid=random_user.user.uid) with pytest.raises(ValueError): - await CRUDBucketPermission.create(db, permission) + await CRUDBucketPermission.create(permission, db=db) @pytest.mark.asyncio async def test_create_duplicate_bucket_permissions( @@ -465,7 +464,7 @@ class TestBucketPermissionCRUDCreate: bucket_name=random_bucket_permission_schema.bucket_name, uid=random_bucket_permission_schema.uid ) with pytest.raises(DuplicateError): - await CRUDBucketPermission.create(db, permission) + await CRUDBucketPermission.create(permission, db=db) @pytest.mark.asyncio async def test_create_valid_bucket_permissions( @@ -484,7 +483,7 @@ class TestBucketPermissionCRUDCreate: Random bucket for testing. """ permission = BucketPermissionSchema(bucket_name=random_bucket.name, uid=random_second_user.user.uid) - created_permission = await CRUDBucketPermission.create(db, permission) + created_permission = await CRUDBucketPermission.create(permission, db=db) assert created_permission.uid == random_second_user.user.uid assert created_permission.bucket_name == random_bucket.name @@ -506,7 +505,7 @@ class TestBucketPermissionCRUDDelete: Bucket permission for a random bucket for testing. """ await CRUDBucketPermission.delete( - db, bucket_name=random_bucket_permission.bucket_name, uid=random_bucket_permission.uid + db=db, bucket_name=random_bucket_permission.bucket_name, uid=random_bucket_permission.uid ) stmt = select(BucketPermissionDB).where( @@ -542,7 +541,7 @@ class TestBucketPermissionCRUDUpdate: permission=BucketPermissionDB.Permission.READWRITE, file_prefix="pseudo/folder/", ) - new_permission = await CRUDBucketPermission.update_permission(db, random_bucket_permission, new_params) + new_permission = await CRUDBucketPermission.update_permission(random_bucket_permission, new_params, db=db) assert new_permission.uid == random_bucket_permission.uid assert new_permission.bucket_name == random_bucket_permission.bucket_name diff --git a/app/tests/crud/test_user.py b/app/tests/crud/test_user.py index 261aa06..6f9612f 100644 --- a/app/tests/crud/test_user.py +++ b/app/tests/crud/test_user.py @@ -3,7 +3,7 @@ from uuid import uuid4 import pytest from sqlalchemy.ext.asyncio import AsyncSession -from app.crud.crud_user import CRUDUser +from app.crud import CRUDUser from app.tests.utils.user import UserWithAuthHeader @@ -20,7 +20,7 @@ class TestUserCRUD: random_user : clowmdb.models.User Random user for testing. """ - user = await CRUDUser.get(db, random_user.user.uid) + user = await CRUDUser.get(random_user.user.uid, db=db) assert user assert random_user.user.uid == user.uid assert random_user.user.display_name == user.display_name @@ -38,5 +38,5 @@ class TestUserCRUD: db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. """ - user = await CRUDUser.get(db, uuid4()) + user = await CRUDUser.get(uuid4(), db=db) assert user is None diff --git a/app/tests/mocks/mock_rgw_admin.py b/app/tests/mocks/mock_rgw_admin.py index fc2ba94..e4fe480 100644 --- a/app/tests/mocks/mock_rgw_admin.py +++ b/app/tests/mocks/mock_rgw_admin.py @@ -25,6 +25,16 @@ class MockRGWAdmin: def __init__(self) -> None: self._keys = {} + def set_bucket_quota( + self, + uid: str, + bucket: str, + max_size_kb: int | None = None, + max_objects: int | None = None, + enabled: bool = True, + ) -> None: + pass + def create_user(self, uid: str, max_buckets: int, display_name: str) -> None: self.create_key(uid) diff --git a/example-config/example-config.json b/example-config/example-config.json index 171b9a3..0e0c3a9 100644 --- a/example-config/example-config.json +++ b/example-config/example-config.json @@ -23,5 +23,5 @@ }, "api_prefix": "/api/s3proxy-service", "public_key_file": "/path/to/key.pub", - "ui_uri": "http://localhost:9999" + "ui_uri": "http://localhost" } diff --git a/example-config/example-config.toml b/example-config/example-config.toml index 894951b..fca60aa 100644 --- a/example-config/example-config.toml +++ b/example-config/example-config.toml @@ -1,6 +1,6 @@ api_prefix = "/api/s3proxy-service" public_key_file= "/path/to/key.pub" -ui_uri = "http://localhost:9999" +ui_uri = "http://localhost" [db] port = 3306 host = "localhost" diff --git a/example-config/example-config.yaml b/example-config/example-config.yaml index 2280b5b..e18b5fd 100644 --- a/example-config/example-config.yaml +++ b/example-config/example-config.yaml @@ -9,7 +9,6 @@ s3: uri: "https://s3-staging.bi.denbi.de" access_key: "xxx" secret_key: "xxx" - resource_bucket: "clowm-resources" username: "clowm-bucket-manager" admin_access_key: "xxx" admin_secret_key: "xxx" @@ -19,4 +18,4 @@ otlp: grpc_endpoint: "localhost:4317" api_prefix: "/api/s3proxy-service" public_key_file: "/path/to/key.pub" -ui_uri: "http://localhost:9999" +ui_uri: "http://localhost" diff --git a/requirements.txt b/requirements.txt index 86c5c65..f82e677 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,10 @@ --extra-index-url https://gitlab.ub.uni-bielefeld.de/api/v4/projects/5493/packages/pypi/simple -clowmdb>=3.1.0,<3.2.0 +clowmdb>=3.2.0,<3.3.0 # Webserver packages fastapi>=0.109.0,<0.111.0 -pydantic<2.8.0 +pydantic[email]<2.8.0 pydantic-settings[yaml, toml]>=2.2.0,<2.3.0 -email-validator>=2.1.0,<2.2.0 # Database packages PyMySQL>=1.1.0,<1.2.0 SQLAlchemy>=2.0.0,<2.1.0 -- GitLab