mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-26 11:40:27 -04:00
fix: enforce organize-group-data permission on food/tag/category mutations (#7651)
This commit is contained in:
@@ -51,6 +51,7 @@ class RecipeCategoryController(BaseCrudController):
|
|||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
def create_one(self, category: CategoryIn):
|
def create_one(self, category: CategoryIn):
|
||||||
"""Creates a Category in the database"""
|
"""Creates a Category in the database"""
|
||||||
|
self.checks.can_organize()
|
||||||
save_data = mapper.cast(category, CategorySave, group_id=self.group_id)
|
save_data = mapper.cast(category, CategorySave, group_id=self.group_id)
|
||||||
new_category = self.mixins.create_one(save_data)
|
new_category = self.mixins.create_one(save_data)
|
||||||
if new_category:
|
if new_category:
|
||||||
@@ -83,6 +84,7 @@ class RecipeCategoryController(BaseCrudController):
|
|||||||
@router.put("/{item_id}", response_model=CategorySummary)
|
@router.put("/{item_id}", response_model=CategorySummary)
|
||||||
def update_one(self, item_id: UUID4, update_data: CategoryIn):
|
def update_one(self, item_id: UUID4, update_data: CategoryIn):
|
||||||
"""Updates an existing Tag in the database"""
|
"""Updates an existing Tag in the database"""
|
||||||
|
self.checks.can_organize()
|
||||||
save_data = mapper.cast(update_data, CategorySave, group_id=self.group_id)
|
save_data = mapper.cast(update_data, CategorySave, group_id=self.group_id)
|
||||||
category = self.mixins.update_one(save_data, item_id)
|
category = self.mixins.update_one(save_data, item_id)
|
||||||
|
|
||||||
@@ -108,6 +110,7 @@ class RecipeCategoryController(BaseCrudController):
|
|||||||
category does not impact a recipe. The category will be removed
|
category does not impact a recipe. The category will be removed
|
||||||
from any recipes that contain it
|
from any recipes that contain it
|
||||||
"""
|
"""
|
||||||
|
self.checks.can_organize()
|
||||||
if category := self.mixins.delete_one(item_id):
|
if category := self.mixins.delete_one(item_id):
|
||||||
self.publish_event(
|
self.publish_event(
|
||||||
event_type=EventTypes.category_deleted,
|
event_type=EventTypes.category_deleted,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class TagController(BaseCrudController):
|
|||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
def create_one(self, tag: TagIn):
|
def create_one(self, tag: TagIn):
|
||||||
"""Creates a Tag in the database"""
|
"""Creates a Tag in the database"""
|
||||||
|
self.checks.can_organize()
|
||||||
save_data = mapper.cast(tag, TagSave, group_id=self.group_id)
|
save_data = mapper.cast(tag, TagSave, group_id=self.group_id)
|
||||||
new_tag = self.mixins.create_one(save_data)
|
new_tag = self.mixins.create_one(save_data)
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ class TagController(BaseCrudController):
|
|||||||
@router.put("/{item_id}", response_model=RecipeTagResponse)
|
@router.put("/{item_id}", response_model=RecipeTagResponse)
|
||||||
def update_one(self, item_id: UUID4, new_tag: TagIn):
|
def update_one(self, item_id: UUID4, new_tag: TagIn):
|
||||||
"""Updates an existing Tag in the database"""
|
"""Updates an existing Tag in the database"""
|
||||||
|
self.checks.can_organize()
|
||||||
save_data = mapper.cast(new_tag, TagSave, group_id=self.group_id)
|
save_data = mapper.cast(new_tag, TagSave, group_id=self.group_id)
|
||||||
tag = self.repo.update(item_id, save_data)
|
tag = self.repo.update(item_id, save_data)
|
||||||
|
|
||||||
@@ -97,6 +99,7 @@ class TagController(BaseCrudController):
|
|||||||
tag does not impact a recipe. The tag will be removed
|
tag does not impact a recipe. The tag will be removed
|
||||||
from any recipes that contain it
|
from any recipes that contain it
|
||||||
"""
|
"""
|
||||||
|
self.checks.can_organize()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tag = self.repo.delete(item_id)
|
tag = self.repo.delete(item_id)
|
||||||
|
|||||||
@@ -48,11 +48,13 @@ class IngredientFoodsController(BaseUserController):
|
|||||||
|
|
||||||
@router.post("", response_model=IngredientFood, status_code=201)
|
@router.post("", response_model=IngredientFood, status_code=201)
|
||||||
def create_one(self, data: CreateIngredientFood):
|
def create_one(self, data: CreateIngredientFood):
|
||||||
|
self.checks.can_organize()
|
||||||
save_data = mapper.cast(data, SaveIngredientFood, group_id=self.group_id)
|
save_data = mapper.cast(data, SaveIngredientFood, group_id=self.group_id)
|
||||||
return self.mixins.create_one(save_data)
|
return self.mixins.create_one(save_data)
|
||||||
|
|
||||||
@router.put("/merge", response_model=SuccessResponse)
|
@router.put("/merge", response_model=SuccessResponse)
|
||||||
def merge_one(self, data: MergeFood):
|
def merge_one(self, data: MergeFood):
|
||||||
|
self.checks.can_organize()
|
||||||
try:
|
try:
|
||||||
self.repo.merge(data.from_food, data.to_food)
|
self.repo.merge(data.from_food, data.to_food)
|
||||||
return SuccessResponse.respond("Successfully merged foods")
|
return SuccessResponse.respond("Successfully merged foods")
|
||||||
@@ -66,9 +68,11 @@ class IngredientFoodsController(BaseUserController):
|
|||||||
|
|
||||||
@router.put("/{item_id}", response_model=IngredientFood)
|
@router.put("/{item_id}", response_model=IngredientFood)
|
||||||
def update_one(self, item_id: UUID4, data: CreateIngredientFood):
|
def update_one(self, item_id: UUID4, data: CreateIngredientFood):
|
||||||
|
self.checks.can_organize()
|
||||||
data = mapper.cast(data, SaveIngredientFood, group_id=self.group_id)
|
data = mapper.cast(data, SaveIngredientFood, group_id=self.group_id)
|
||||||
return self.mixins.update_one(data, item_id)
|
return self.mixins.update_one(data, item_id)
|
||||||
|
|
||||||
@router.delete("/{item_id}", response_model=IngredientFood)
|
@router.delete("/{item_id}", response_model=IngredientFood)
|
||||||
def delete_one(self, item_id: UUID4):
|
def delete_one(self, item_id: UUID4):
|
||||||
|
self.checks.can_organize()
|
||||||
return self.mixins.delete_one(item_id)
|
return self.mixins.delete_one(item_id)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Tests that the "User can organize group data" permission gates the
|
||||||
|
mutation endpoints for foods, tags, and categories (GHSA / issue #6883).
|
||||||
|
|
||||||
|
Read endpoints remain open; only POST/PUT/DELETE require the permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.utils import api_routes
|
||||||
|
from tests.utils.factories import random_string
|
||||||
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
# (id, collection route, item-id route builder)
|
||||||
|
RESOURCES = [
|
||||||
|
("foods", api_routes.foods, api_routes.foods_item_id),
|
||||||
|
("tags", api_routes.organizers_tags, api_routes.organizers_tags_item_id),
|
||||||
|
("categories", api_routes.organizers_categories, api_routes.organizers_categories_item_id),
|
||||||
|
]
|
||||||
|
RESOURCE_IDS = [resource[0] for resource in RESOURCES]
|
||||||
|
|
||||||
|
|
||||||
|
def set_can_organize(user: TestUser, value: bool) -> None:
|
||||||
|
db_user = user.repos.users.get_one(user.user_id)
|
||||||
|
assert db_user
|
||||||
|
db_user.can_organize = value
|
||||||
|
user.repos.users.update(db_user.id, db_user)
|
||||||
|
|
||||||
|
|
||||||
|
def create_item(api_client: TestClient, user: TestUser, collection: str) -> str:
|
||||||
|
"""Creates an item as a user with the organize permission and returns its id."""
|
||||||
|
set_can_organize(user, True)
|
||||||
|
response = api_client.post(collection, json={"name": random_string(10)}, headers=user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
return response.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name, collection, item", RESOURCES, ids=RESOURCE_IDS)
|
||||||
|
def test_create_requires_organize_permission(
|
||||||
|
api_client: TestClient, unique_user_fn_scoped: TestUser, name: str, collection: str, item
|
||||||
|
):
|
||||||
|
user = unique_user_fn_scoped
|
||||||
|
payload = {"name": random_string(10)}
|
||||||
|
|
||||||
|
set_can_organize(user, False)
|
||||||
|
response = api_client.post(collection, json=payload, headers=user.token)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
set_can_organize(user, True)
|
||||||
|
response = api_client.post(collection, json=payload, headers=user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name, collection, item", RESOURCES, ids=RESOURCE_IDS)
|
||||||
|
def test_update_requires_organize_permission(
|
||||||
|
api_client: TestClient, unique_user_fn_scoped: TestUser, name: str, collection: str, item
|
||||||
|
):
|
||||||
|
user = unique_user_fn_scoped
|
||||||
|
item_id = create_item(api_client, user, collection)
|
||||||
|
payload = {"id": item_id, "name": random_string(10)}
|
||||||
|
|
||||||
|
set_can_organize(user, False)
|
||||||
|
response = api_client.put(item(item_id), json=payload, headers=user.token)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
set_can_organize(user, True)
|
||||||
|
response = api_client.put(item(item_id), json=payload, headers=user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name, collection, item", RESOURCES, ids=RESOURCE_IDS)
|
||||||
|
def test_delete_requires_organize_permission(
|
||||||
|
api_client: TestClient, unique_user_fn_scoped: TestUser, name: str, collection: str, item
|
||||||
|
):
|
||||||
|
user = unique_user_fn_scoped
|
||||||
|
item_id = create_item(api_client, user, collection)
|
||||||
|
|
||||||
|
set_can_organize(user, False)
|
||||||
|
response = api_client.delete(item(item_id), headers=user.token)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
set_can_organize(user, True)
|
||||||
|
response = api_client.delete(item(item_id), headers=user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_food_merge_requires_organize_permission(api_client: TestClient, unique_user_fn_scoped: TestUser):
|
||||||
|
user = unique_user_fn_scoped
|
||||||
|
from_food = create_item(api_client, user, api_routes.foods)
|
||||||
|
to_food = create_item(api_client, user, api_routes.foods)
|
||||||
|
payload = {"fromFood": from_food, "toFood": to_food}
|
||||||
|
|
||||||
|
set_can_organize(user, False)
|
||||||
|
response = api_client.put(api_routes.foods_merge, json=payload, headers=user.token)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
set_can_organize(user, True)
|
||||||
|
response = api_client.put(api_routes.foods_merge, json=payload, headers=user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name, collection, item", RESOURCES, ids=RESOURCE_IDS)
|
||||||
|
def test_read_endpoints_do_not_require_organize_permission(
|
||||||
|
api_client: TestClient, unique_user_fn_scoped: TestUser, name: str, collection: str, item
|
||||||
|
):
|
||||||
|
user = unique_user_fn_scoped
|
||||||
|
item_id = create_item(api_client, user, collection)
|
||||||
|
|
||||||
|
set_can_organize(user, False)
|
||||||
|
|
||||||
|
response = api_client.get(collection, headers=user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = api_client.get(item(item_id), headers=user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
Reference in New Issue
Block a user