From 348bcf93a77b39c2003af71e5649dac6f2cad75f Mon Sep 17 00:00:00 2001 From: johnpccd Date: Sun, 25 May 2025 00:39:08 +0200 Subject: [PATCH] admin key can read all images + fix env var for cloud function --- .../terraform/.terraform.tfstate.lock.info | 1 + deployment/terraform/cloud-function.tf | 2 + deployment/terraform/terraform.tfstate | 85 +++++--- deployment/terraform/terraform.tfstate.backup | 89 ++++---- src/api/v1/images.py | 86 +++++--- .../firestore_image_repository.py | 75 +++++++ test_admin_images.py | 201 ++++++++++++++++++ 7 files changed, 429 insertions(+), 110 deletions(-) create mode 100644 deployment/terraform/.terraform.tfstate.lock.info create mode 100644 test_admin_images.py diff --git a/deployment/terraform/.terraform.tfstate.lock.info b/deployment/terraform/.terraform.tfstate.lock.info new file mode 100644 index 0000000..6060cd6 --- /dev/null +++ b/deployment/terraform/.terraform.tfstate.lock.info @@ -0,0 +1 @@ +{"ID":"9b771a70-f4b7-dbb4-f919-38d27ca55e23","Operation":"OperationTypeApply","Info":"","Who":"DESKTOP\\habal@Desktop","Version":"1.10.1","Created":"2025-05-24T22:38:22.6064375Z","Path":"terraform.tfstate"} \ No newline at end of file diff --git a/deployment/terraform/cloud-function.tf b/deployment/terraform/cloud-function.tf index ad7d1f4..600fb68 100644 --- a/deployment/terraform/cloud-function.tf +++ b/deployment/terraform/cloud-function.tf @@ -66,6 +66,8 @@ resource "google_cloudfunctions2_function" "image_processor" { # Logging LOG_LEVEL = "INFO" + + PROJECT_ID = var.project_id } service_account_email = local.cloud_function_service_account diff --git a/deployment/terraform/terraform.tfstate b/deployment/terraform/terraform.tfstate index 3a277b6..2cd0c62 100644 --- a/deployment/terraform/terraform.tfstate +++ b/deployment/terraform/terraform.tfstate @@ -1,7 +1,7 @@ { "version": 4, "terraform_version": "1.10.1", - "serial": 410, + "serial": 415, "lineage": "a183cd95-f987-8698-c6dd-84e933c394a5", "outputs": { "cloud_function_name": { @@ -98,16 +98,16 @@ "attributes": { "exclude_symlink_directories": null, "excludes": null, - "id": "7a7a706b5bba3a12744f2dd109eb18de7112f351", - "output_base64sha256": "0wAfDV7tH41jEspQ3LBLvIEnrQ6XU2aEtK2GWsMdyCA=", - "output_base64sha512": "glsNAiHzSTOy9mGDckkSyDJhBVFDtLh8Xr6+hSxtCT8nok9qNGO+61iTRLU42OPxaPS/BrbDAXJeT86F3riefA==", + "id": "bfc4b3b9de401cd15676a09a067a8e4095b0bf4e", + "output_base64sha256": "cXx9sC1kIbTDG7BlKAtf3FUasHLZ/wZPzVoyBvt8p9Q=", + "output_base64sha512": "dcntRZ4Hz4dfBBj7YVsTzx+SEAqCXZCD8TAAh8cr5xa3uT2Lsmtf8zpxpyQEeMlsCNaF8dUohGQ7BD9LJYigPw==", "output_file_mode": null, - "output_md5": "a5d3a7fe131c972bf8d0edf309545042", + "output_md5": "46efa1aee5386e2f244b597289c7c4ba", "output_path": "./function-source.zip", - "output_sha": "7a7a706b5bba3a12744f2dd109eb18de7112f351", - "output_sha256": "d3001f0d5eed1f8d6312ca50dcb04bbc8127ad0e97536684b4ad865ac31dc820", - "output_sha512": "825b0d0221f34933b2f66183724912c83261055143b4b87c5ebebe852c6d093f27a24f6a3463beeb589344b538d8e3f168f4bf06b6c301725e4fce85deb89e7c", - "output_size": 4487, + "output_sha": "bfc4b3b9de401cd15676a09a067a8e4095b0bf4e", + "output_sha256": "717c7db02d6421b4c31bb065280b5fdc551ab072d9ff064fcd5a3206fb7ca7d4", + "output_sha512": "75c9ed459e07cf875f0418fb615b13cf1f92100a825d9083f1300087c72be716b7b93d8bb26b5ff33a71a7240478c96c08d685f1d52884643b043f4b2588a03f", + "output_size": 6734, "source": [], "source_content": null, "source_content_filename": null, @@ -182,7 +182,7 @@ "goog-terraform-provisioned": "true" }, "generation": 1, - "labels": null, + "labels": {}, "namespace": "gen-lang-client-0424120530", "resource_version": "AAY16UbSm4k", "self_link": "/apis/serving.knative.dev/v1/namespaces/761163285547/services/sereact", @@ -256,8 +256,8 @@ "container_concurrency": 80, "containers": [ { - "args": null, - "command": null, + "args": [], + "command": [], "env": [ { "name": "FIRESTORE_DATABASE_NAME", @@ -332,7 +332,7 @@ "cpu": "1", "memory": "1Gi" }, - "requests": null + "requests": {} } ], "startup_probe": [ @@ -354,7 +354,7 @@ "working_dir": "" } ], - "node_selector": null, + "node_selector": {}, "service_account_name": "761163285547-compute@developer.gserviceaccount.com", "serving_state": "", "timeout_seconds": 300, @@ -596,6 +596,13 @@ } ] }, + { + "mode": "managed", + "type": "google_compute_address", + "name": "vector_db_static_ip", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [] + }, { "mode": "managed", "type": "google_compute_firewall", @@ -822,7 +829,7 @@ }, { "type": "get_attr", - "value": "disk_encryption_key_rsa" + "value": "disk_encryption_key_raw" } ], [ @@ -839,7 +846,7 @@ }, { "type": "get_attr", - "value": "disk_encryption_key_raw" + "value": "disk_encryption_key_rsa" } ] ], @@ -868,8 +875,8 @@ "database_edition": "STANDARD", "delete_protection_state": "DELETE_PROTECTION_DISABLED", "deletion_policy": "ABANDON", - "earliest_version_time": "2025-05-24T21:29:52.924798Z", - "etag": "IPHgo4eUvY0DMKrW4vCEvY0D", + "earliest_version_time": "2025-05-24T21:38:29.341046Z", + "etag": "IOGow/2VvY0DMKrW4vCEvY0D", "id": "projects/gen-lang-client-0424120530/databases/sereact-imagedb", "key_prefix": "", "location_id": "us-central1", @@ -989,18 +996,18 @@ { "mode": "managed", "type": "google_project_iam_member", - "name": "function_vision", + "name": "function_vertex_ai", "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", "instances": [ { "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16LCINIE=", - "id": "gen-lang-client-0424120530/roles/ml.developer/serviceAccount:761163285547-compute@developer.gserviceaccount.com", + "etag": "BwY16WAsDU4=", + "id": "gen-lang-client-0424120530/roles/aiplatform.user/serviceAccount:761163285547-compute@developer.gserviceaccount.com", "member": "serviceAccount:761163285547-compute@developer.gserviceaccount.com", "project": "gen-lang-client-0424120530", - "role": "roles/ml.developer" + "role": "roles/aiplatform.user" }, "sensitive_attributes": [], "private": "bnVsbA==", @@ -1016,6 +1023,20 @@ "name": "services", "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", "instances": [ + { + "index_key": "aiplatform.googleapis.com", + "schema_version": 0, + "attributes": { + "disable_dependent_services": null, + "disable_on_destroy": false, + "id": "gen-lang-client-0424120530/aiplatform.googleapis.com", + "project": "gen-lang-client-0424120530", + "service": "aiplatform.googleapis.com", + "timeouts": null + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInJlYWQiOjYwMDAwMDAwMDAwMCwidXBkYXRlIjoxMjAwMDAwMDAwMDAwfX0=" + }, { "index_key": "cloudbuild.googleapis.com", "schema_version": 0, @@ -1493,21 +1514,21 @@ "content_encoding": "", "content_language": "", "content_type": "application/zip", - "crc32c": "Y4Q5hw==", + "crc32c": "kTROsA==", "customer_encryption": [], - "detect_md5hash": "pdOn/hMclyv40O3zCVRQQg==", + "detect_md5hash": "Ru+hruU4bi8kS1lyicfEug==", "event_based_hold": false, - "generation": 1748125796837241, - "id": "gen-lang-client-0424120530-cloud-function-source-function-source-a5d3a7fe131c972bf8d0edf309545042.zip", + "generation": 1748126317190781, + "id": "gen-lang-client-0424120530-cloud-function-source-function-source-46efa1aee5386e2f244b597289c7c4ba.zip", "kms_key_name": "", - "md5hash": "pdOn/hMclyv40O3zCVRQQg==", - "md5hexhash": "a5d3a7fe131c972bf8d0edf309545042", - "media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-a5d3a7fe131c972bf8d0edf309545042.zip?generation=1748125796837241\u0026alt=media", + "md5hash": "Ru+hruU4bi8kS1lyicfEug==", + "md5hexhash": "46efa1aee5386e2f244b597289c7c4ba", + "media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-46efa1aee5386e2f244b597289c7c4ba.zip?generation=1748126317190781\u0026alt=media", "metadata": null, - "name": "function-source-a5d3a7fe131c972bf8d0edf309545042.zip", - "output_name": "function-source-a5d3a7fe131c972bf8d0edf309545042.zip", + "name": "function-source-46efa1aee5386e2f244b597289c7c4ba.zip", + "output_name": "function-source-46efa1aee5386e2f244b597289c7c4ba.zip", "retention": [], - "self_link": "https://www.googleapis.com/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-a5d3a7fe131c972bf8d0edf309545042.zip", + "self_link": "https://www.googleapis.com/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-46efa1aee5386e2f244b597289c7c4ba.zip", "source": "./function-source.zip", "storage_class": "STANDARD", "temporary_hold": false, diff --git a/deployment/terraform/terraform.tfstate.backup b/deployment/terraform/terraform.tfstate.backup index e202c57..3a277b6 100644 --- a/deployment/terraform/terraform.tfstate.backup +++ b/deployment/terraform/terraform.tfstate.backup @@ -1,7 +1,7 @@ { "version": 4, "terraform_version": "1.10.1", - "serial": 404, + "serial": 410, "lineage": "a183cd95-f987-8698-c6dd-84e933c394a5", "outputs": { "cloud_function_name": { @@ -98,16 +98,16 @@ "attributes": { "exclude_symlink_directories": null, "excludes": null, - "id": "045029ac803155784c12f8d587fee56b85b1fbe9", - "output_base64sha256": "b/FgNMMT30JSXfrLRXNkWeNc6i22YAmT3YwQRTw1+A4=", - "output_base64sha512": "7GDDTkHwwQVAlwSxe7yzgtGccMNIRCQ7t72ZRk7bcfDI1tzpruhJ5G/0AbrUMXWQO6LffnWtwumQ7XdFHAIzBA==", + "id": "7a7a706b5bba3a12744f2dd109eb18de7112f351", + "output_base64sha256": "0wAfDV7tH41jEspQ3LBLvIEnrQ6XU2aEtK2GWsMdyCA=", + "output_base64sha512": "glsNAiHzSTOy9mGDckkSyDJhBVFDtLh8Xr6+hSxtCT8nok9qNGO+61iTRLU42OPxaPS/BrbDAXJeT86F3riefA==", "output_file_mode": null, - "output_md5": "34d81725abbd4f423de71ecd4215d116", + "output_md5": "a5d3a7fe131c972bf8d0edf309545042", "output_path": "./function-source.zip", - "output_sha": "045029ac803155784c12f8d587fee56b85b1fbe9", - "output_sha256": "6ff16034c313df42525dfacb45736459e35cea2db6600993dd8c10453c35f80e", - "output_sha512": "ec60c34e41f0c105409704b17bbcb382d19c70c34844243bb7bd99464edb71f0c8d6dce9aee849e46ff401bad43175903ba2df7e75adc2e990ed77451c023304", - "output_size": 5014, + "output_sha": "7a7a706b5bba3a12744f2dd109eb18de7112f351", + "output_sha256": "d3001f0d5eed1f8d6312ca50dcb04bbc8127ad0e97536684b4ad865ac31dc820", + "output_sha512": "825b0d0221f34933b2f66183724912c83261055143b4b87c5ebebe852c6d093f27a24f6a3463beeb589344b538d8e3f168f4bf06b6c301725e4fce85deb89e7c", + "output_size": 4487, "source": [], "source_content": null, "source_content_filename": null, @@ -170,11 +170,9 @@ "run.googleapis.com/ingress": "all" }, "effective_annotations": { - "run.googleapis.com/client-name": "gcloud", - "run.googleapis.com/client-version": "431.0.0", "run.googleapis.com/ingress": "all", "run.googleapis.com/ingress-status": "all", - "run.googleapis.com/operation-id": "e4d7484f-39e4-4dde-8105-28d285eb927b", + "run.googleapis.com/operation-id": "7869f742-fe94-42d0-8d82-a1462681980d", "run.googleapis.com/urls": "[\"https://sereact-761163285547.us-central1.run.app\",\"https://sereact-p64zpdtkta-uc.a.run.app\"]", "serving.knative.dev/creator": "johnpccd3@gmail.com", "serving.knative.dev/lastModifier": "johnpccd3@gmail.com" @@ -183,15 +181,15 @@ "cloud.googleapis.com/location": "us-central1", "goog-terraform-provisioned": "true" }, - "generation": 2, - "labels": {}, + "generation": 1, + "labels": null, "namespace": "gen-lang-client-0424120530", - "resource_version": "AAY16Gy+yWQ", + "resource_version": "AAY16UbSm4k", "self_link": "/apis/serving.knative.dev/v1/namespaces/761163285547/services/sereact", "terraform_labels": { "goog-terraform-provisioned": "true" }, - "uid": "c67276c9-0c25-4a6c-8f39-4ea942599769" + "uid": "d5532269-ab10-4b77-b90f-698306bf0919" } ], "name": "sereact", @@ -218,14 +216,14 @@ "type": "RoutesReady" } ], - "latest_created_revision_name": "sereact-00002-cew", - "latest_ready_revision_name": "sereact-00002-cew", - "observed_generation": 2, + "latest_created_revision_name": "sereact-00001-9rv", + "latest_ready_revision_name": "sereact-00001-9rv", + "observed_generation": 1, "traffic": [ { "latest_revision": true, "percent": 100, - "revision_name": "sereact-00002-cew", + "revision_name": "sereact-00001-9rv", "tag": "", "url": "" } @@ -246,7 +244,7 @@ "labels": { "run.googleapis.com/startupProbeType": "Default" }, - "name": "sereact-00002-cew", + "name": "", "namespace": "", "resource_version": "", "self_link": "", @@ -258,8 +256,8 @@ "container_concurrency": 80, "containers": [ { - "args": [], - "command": [], + "args": null, + "command": null, "env": [ { "name": "FIRESTORE_DATABASE_NAME", @@ -334,7 +332,7 @@ "cpu": "1", "memory": "1Gi" }, - "requests": {} + "requests": null } ], "startup_probe": [ @@ -356,7 +354,7 @@ "working_dir": "" } ], - "node_selector": {}, + "node_selector": null, "service_account_name": "761163285547-compute@developer.gserviceaccount.com", "serving_state": "", "timeout_seconds": 300, @@ -437,7 +435,7 @@ "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16Etxb+g=", + "etag": "BwY16UdHJ00=", "id": "v1/projects/gen-lang-client-0424120530/locations/us-central1/services/sereact/roles/run.invoker/allUsers", "location": "us-central1", "member": "allUsers", @@ -471,7 +469,7 @@ "automatic_update_policy": [ {} ], - "build": "projects/761163285547/locations/us-central1/builds/1b8e28d1-ee4d-4d2f-acf2-47e2b03aa421", + "build": "projects/761163285547/locations/us-central1/builds/b2b7e513-e00e-462a-8ac8-94abdfb4a0b9", "docker_repository": "projects/gen-lang-client-0424120530/locations/us-central1/repositories/gcf-artifacts", "entry_point": "process_image_embedding", "environment_variables": {}, @@ -485,7 +483,7 @@ { "bucket": "gen-lang-client-0424120530-cloud-function-source", "generation": 1748123369545880, - "object": "function-source-34d81725abbd4f423de71ecd4215d116.zip" + "object": "function-source-a5d3a7fe131c972bf8d0edf309545042.zip" } ] } @@ -554,7 +552,7 @@ "goog-terraform-provisioned": "true" }, "timeouts": null, - "update_time": "2025-05-24T22:08:16.899711009Z", + "update_time": "2025-05-24T22:31:52.525335119Z", "url": "https://us-central1-gen-lang-client-0424120530.cloudfunctions.net/process-image-embedding" }, "sensitive_attributes": [ @@ -598,13 +596,6 @@ } ] }, - { - "mode": "managed", - "type": "google_compute_address", - "name": "vector_db_static_ip", - "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", - "instances": [] - }, { "mode": "managed", "type": "google_compute_firewall", @@ -877,8 +868,8 @@ "database_edition": "STANDARD", "delete_protection_state": "DELETE_PROTECTION_DISABLED", "deletion_policy": "ABANDON", - "earliest_version_time": "2025-05-24T21:26:23.088753Z", - "etag": "IOqynKOTvY0DMKrW4vCEvY0D", + "earliest_version_time": "2025-05-24T21:29:52.924798Z", + "etag": "IPHgo4eUvY0DMKrW4vCEvY0D", "id": "projects/gen-lang-client-0424120530/databases/sereact-imagedb", "key_prefix": "", "location_id": "us-central1", @@ -1502,21 +1493,21 @@ "content_encoding": "", "content_language": "", "content_type": "application/zip", - "crc32c": "YXAlNA==", + "crc32c": "Y4Q5hw==", "customer_encryption": [], - "detect_md5hash": "NNgXJau9T0I95x7NQhXRFg==", + "detect_md5hash": "pdOn/hMclyv40O3zCVRQQg==", "event_based_hold": false, - "generation": 1748124439573408, - "id": "gen-lang-client-0424120530-cloud-function-source-function-source-34d81725abbd4f423de71ecd4215d116.zip", + "generation": 1748125796837241, + "id": "gen-lang-client-0424120530-cloud-function-source-function-source-a5d3a7fe131c972bf8d0edf309545042.zip", "kms_key_name": "", - "md5hash": "NNgXJau9T0I95x7NQhXRFg==", - "md5hexhash": "34d81725abbd4f423de71ecd4215d116", - "media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-34d81725abbd4f423de71ecd4215d116.zip?generation=1748124439573408\u0026alt=media", - "metadata": {}, - "name": "function-source-34d81725abbd4f423de71ecd4215d116.zip", - "output_name": "function-source-34d81725abbd4f423de71ecd4215d116.zip", + "md5hash": "pdOn/hMclyv40O3zCVRQQg==", + "md5hexhash": "a5d3a7fe131c972bf8d0edf309545042", + "media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-a5d3a7fe131c972bf8d0edf309545042.zip?generation=1748125796837241\u0026alt=media", + "metadata": null, + "name": "function-source-a5d3a7fe131c972bf8d0edf309545042.zip", + "output_name": "function-source-a5d3a7fe131c972bf8d0edf309545042.zip", "retention": [], - "self_link": "https://www.googleapis.com/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-34d81725abbd4f423de71ecd4215d116.zip", + "self_link": "https://www.googleapis.com/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-a5d3a7fe131c972bf8d0edf309545042.zip", "source": "./function-source.zip", "storage_class": "STANDARD", "temporary_hold": false, diff --git a/src/api/v1/images.py b/src/api/v1/images.py index 5cfa5eb..04761df 100644 --- a/src/api/v1/images.py +++ b/src/api/v1/images.py @@ -136,10 +136,22 @@ async def list_images( current_user: UserModel = Depends(get_current_user) ): """ - List images for the current user's team + List images for the current user's team, or all images if user is admin. + + Regular users can only see images from their own team. + Admin users can see all images across all teams. + + Args: + skip: Number of records to skip for pagination + limit: Maximum number of records to return (1-100) + collection_id: Optional filter by collection ID + tags: Optional comma-separated list of tags to filter by + + Returns: + List of images with pagination metadata """ log_request( - {"path": request.url.path, "method": request.method, "skip": skip, "limit": limit}, + {"path": request.url.path, "method": request.method, "skip": skip, "limit": limit, "is_admin": current_user.is_admin}, user_id=str(current_user.id), team_id=str(current_user.team_id) ) @@ -149,21 +161,37 @@ async def list_images( if tags: tag_filter = [tag.strip() for tag in tags.split(',') if tag.strip()] - # Get images - images = await image_repository.get_by_team( - current_user.team_id, - skip=skip, - limit=limit, - collection_id=ObjectId(collection_id) if collection_id else None, - tags=tag_filter - ) - - # Get total count - total = await image_repository.count_by_team( - current_user.team_id, - collection_id=ObjectId(collection_id) if collection_id else None, - tags=tag_filter - ) + # Check if user is admin - if so, get all images across all teams + if current_user.is_admin: + # Admin users can see all images across all teams + images = await image_repository.get_all_with_pagination( + skip=skip, + limit=limit, + collection_id=ObjectId(collection_id) if collection_id else None, + tags=tag_filter + ) + + # Get total count for admin + total = await image_repository.count_all( + collection_id=ObjectId(collection_id) if collection_id else None, + tags=tag_filter + ) + else: + # Regular users only see images from their team + images = await image_repository.get_by_team( + current_user.team_id, + skip=skip, + limit=limit, + collection_id=ObjectId(collection_id) if collection_id else None, + tags=tag_filter + ) + + # Get total count for regular user + total = await image_repository.count_by_team( + current_user.team_id, + collection_id=ObjectId(collection_id) if collection_id else None, + tags=tag_filter + ) # Convert to response response_images = [] @@ -211,7 +239,7 @@ async def get_image( Get image metadata by ID """ log_request( - {"path": request.url.path, "method": request.method, "image_id": image_id}, + {"path": request.url.path, "method": request.method, "image_id": image_id, "is_admin": current_user.is_admin}, user_id=str(current_user.id), team_id=str(current_user.team_id) ) @@ -226,8 +254,8 @@ async def get_image( if not image: raise HTTPException(status_code=404, detail="Image not found") - # Check team access - if image.team_id != current_user.team_id: + # Check team access (admins can access any image) + if not current_user.is_admin and image.team_id != current_user.team_id: raise HTTPException(status_code=403, detail="Not authorized to access this image") # Update last accessed @@ -278,7 +306,7 @@ async def download_image( Download image file """ log_request( - {"path": request.url.path, "method": request.method, "image_id": image_id}, + {"path": request.url.path, "method": request.method, "image_id": image_id, "is_admin": current_user.is_admin}, user_id=str(current_user.id), team_id=str(current_user.team_id) ) @@ -293,8 +321,8 @@ async def download_image( if not image: raise HTTPException(status_code=404, detail="Image not found") - # Check team access - if image.team_id != current_user.team_id: + # Check team access (admins can access any image) + if not current_user.is_admin and image.team_id != current_user.team_id: raise HTTPException(status_code=403, detail="Not authorized to access this image") # Get file from storage @@ -323,7 +351,7 @@ async def update_image( Update image metadata """ log_request( - {"path": request.url.path, "method": request.method, "image_id": image_id}, + {"path": request.url.path, "method": request.method, "image_id": image_id, "is_admin": current_user.is_admin}, user_id=str(current_user.id), team_id=str(current_user.team_id) ) @@ -338,8 +366,8 @@ async def update_image( if not image: raise HTTPException(status_code=404, detail="Image not found") - # Check team access - if image.team_id != current_user.team_id: + # Check team access (admins can update any image) + if not current_user.is_admin and image.team_id != current_user.team_id: raise HTTPException(status_code=403, detail="Not authorized to update this image") # Update image @@ -398,7 +426,7 @@ async def delete_image( Delete an image """ log_request( - {"path": request.url.path, "method": request.method, "image_id": image_id}, + {"path": request.url.path, "method": request.method, "image_id": image_id, "is_admin": current_user.is_admin}, user_id=str(current_user.id), team_id=str(current_user.team_id) ) @@ -413,8 +441,8 @@ async def delete_image( if not image: raise HTTPException(status_code=404, detail="Image not found") - # Check team access - if image.team_id != current_user.team_id: + # Check team access (admins can delete any image) + if not current_user.is_admin and image.team_id != current_user.team_id: raise HTTPException(status_code=403, detail="Not authorized to delete this image") # Delete from storage diff --git a/src/db/repositories/firestore_image_repository.py b/src/db/repositories/firestore_image_repository.py index 81e2b35..1d0e01c 100644 --- a/src/db/repositories/firestore_image_repository.py +++ b/src/db/repositories/firestore_image_repository.py @@ -173,5 +173,80 @@ class FirestoreImageRepository(FirestoreRepository[ImageModel]): logger.error(f"Error getting images by tag: {e}") raise + async def get_all_with_pagination( + self, + skip: int = 0, + limit: int = 50, + collection_id: Optional[ObjectId] = None, + tags: Optional[List[str]] = None + ) -> List[ImageModel]: + """ + Get all images across all teams with pagination and filtering (admin only) + + Args: + skip: Number of records to skip + limit: Maximum number of records to return + collection_id: Optional collection ID filter + tags: Optional list of tags to filter by + + Returns: + List of images + """ + try: + # Get all images + images = await self.get_all() + + # Filter by collection if specified + if collection_id: + images = [image for image in images if image.collection_id == collection_id] + + # Filter by tags if specified + if tags: + images = [ + image for image in images + if any(tag in image.tags for tag in tags) + ] + + # Apply pagination + return images[skip:skip + limit] + except Exception as e: + logger.error(f"Error getting all images with pagination: {e}") + raise + + async def count_all( + self, + collection_id: Optional[ObjectId] = None, + tags: Optional[List[str]] = None + ) -> int: + """ + Count all images across all teams with filtering (admin only) + + Args: + collection_id: Optional collection ID filter + tags: Optional list of tags to filter by + + Returns: + Count of images + """ + try: + # Get all images + images = await self.get_all() + + # Filter by collection if specified + if collection_id: + images = [image for image in images if image.collection_id == collection_id] + + # Filter by tags if specified + if tags: + images = [ + image for image in images + if any(tag in image.tags for tag in tags) + ] + + return len(images) + except Exception as e: + logger.error(f"Error counting all images: {e}") + raise + # Create a singleton repository firestore_image_repository = FirestoreImageRepository() \ No newline at end of file diff --git a/test_admin_images.py b/test_admin_images.py new file mode 100644 index 0000000..fa9414c --- /dev/null +++ b/test_admin_images.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Test script to verify admin image access functionality. +This script tests that: +1. Regular users can only see images from their own team +2. Admin users can see all images across all teams +""" + +import asyncio +import sys +import os +from datetime import datetime +from bson import ObjectId + +# Add the src directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from src.models.image import ImageModel +from src.models.user import UserModel +from src.db.repositories.image_repository import image_repository +from src.db.repositories.user_repository import user_repository +from src.db.providers.firestore_provider import firestore_db + + +async def setup_test_data(): + """Set up test data for the admin functionality test""" + print("Setting up test data...") + + # Create two teams + team1_id = ObjectId() + team2_id = ObjectId() + + # Create users + regular_user = UserModel( + email="regular@test.com", + name="Regular User", + team_id=team1_id, + is_admin=False + ) + + admin_user = UserModel( + email="admin@test.com", + name="Admin User", + team_id=team1_id, + is_admin=True + ) + + # Create test images for team 1 + image1_team1 = ImageModel( + filename="team1-image1.jpg", + original_filename="team1_image1.jpg", + file_size=1024, + content_type="image/jpeg", + storage_path="images/team1-image1.jpg", + team_id=team1_id, + uploader_id=regular_user.id, + description="Team 1 Image 1", + tags=["team1", "test"] + ) + + image2_team1 = ImageModel( + filename="team1-image2.jpg", + original_filename="team1_image2.jpg", + file_size=2048, + content_type="image/jpeg", + storage_path="images/team1-image2.jpg", + team_id=team1_id, + uploader_id=admin_user.id, + description="Team 1 Image 2", + tags=["team1", "admin"] + ) + + # Create test images for team 2 + image1_team2 = ImageModel( + filename="team2-image1.jpg", + original_filename="team2_image1.jpg", + file_size=1536, + content_type="image/jpeg", + storage_path="images/team2-image1.jpg", + team_id=team2_id, + uploader_id=ObjectId(), # Different user from team 2 + description="Team 2 Image 1", + tags=["team2", "test"] + ) + + return { + 'regular_user': regular_user, + 'admin_user': admin_user, + 'team1_id': team1_id, + 'team2_id': team2_id, + 'images': [image1_team1, image2_team1, image1_team2] + } + + +async def test_regular_user_access(regular_user, team1_id): + """Test that regular users only see their team's images""" + print("\n=== Testing Regular User Access ===") + + # Simulate getting images for regular user (team-filtered) + team1_images = await image_repository.get_by_team(team1_id, skip=0, limit=50) + team1_count = await image_repository.count_by_team(team1_id) + + print(f"Regular user sees {len(team1_images)} images from their team") + print(f"Total count for team: {team1_count}") + + for image in team1_images: + print(f" - {image.filename} (Team: {image.team_id})") + + # Verify all images belong to the user's team + for image in team1_images: + assert image.team_id == team1_id, f"Regular user should not see image from different team: {image.filename}" + + print("โœ… Regular user access test passed - only sees team images") + return len(team1_images) + + +async def test_admin_user_access(admin_user): + """Test that admin users see all images across all teams""" + print("\n=== Testing Admin User Access ===") + + # Simulate getting all images for admin user + all_images = await image_repository.get_all_with_pagination(skip=0, limit=50) + all_count = await image_repository.count_all() + + print(f"Admin user sees {len(all_images)} images across all teams") + print(f"Total count across all teams: {all_count}") + + teams_seen = set() + for image in all_images: + teams_seen.add(str(image.team_id)) + print(f" - {image.filename} (Team: {image.team_id})") + + print(f"Admin sees images from {len(teams_seen)} different teams") + + # Verify admin sees more images than regular user would + assert len(all_images) >= 2, "Admin should see images from multiple teams" + assert len(teams_seen) >= 2, "Admin should see images from at least 2 teams" + + print("โœ… Admin user access test passed - sees all images across teams") + return len(all_images) + + +async def main(): + """Main test function""" + print("๐Ÿงช Testing Admin Image Access Functionality") + print("=" * 50) + + try: + # Connect to database + firestore_db.connect() + print("โœ… Connected to Firestore") + + # Set up test data + test_data = await setup_test_data() + + # Create test images in database + created_images = [] + for image in test_data['images']: + created_image = await image_repository.create(image) + created_images.append(created_image) + print(f"Created test image: {created_image.filename}") + + # Test regular user access + regular_count = await test_regular_user_access( + test_data['regular_user'], + test_data['team1_id'] + ) + + # Test admin user access + admin_count = await test_admin_user_access(test_data['admin_user']) + + # Verify admin sees more images than regular user + print(f"\n=== Summary ===") + print(f"Regular user images: {regular_count}") + print(f"Admin user images: {admin_count}") + + if admin_count > regular_count: + print("โœ… SUCCESS: Admin sees more images than regular user") + else: + print("โŒ FAILURE: Admin should see more images than regular user") + + # Clean up test data + print(f"\n=== Cleanup ===") + for image in created_images: + await image_repository.delete(image.id) + print(f"Deleted test image: {image.filename}") + + print("โœ… Test completed successfully!") + + except Exception as e: + print(f"โŒ Test failed with error: {e}") + import traceback + traceback.print_exc() + finally: + # Disconnect from database + firestore_db.disconnect() + print("โœ… Disconnected from Firestore") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file