Team API docs (#5897)

* let view_team permission be handled by global ACLs
* code style and API docs
* improve permission check
This commit is contained in:
Kevin Papst
2026-04-11 17:22:59 +02:00
committed by GitHub
parent 999d820d4c
commit cbdf91f316
5 changed files with 68 additions and 91 deletions

View File

@@ -71,7 +71,7 @@ final class TeamController extends BaseApiController
* Fetch team * Fetch team
*/ */
#[IsGranted('view_team')] #[IsGranted('view_team')]
#[OA\Response(response: 200, description: 'Returns one team entity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))] #[OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_team', requirements: ['id' => '\d+'])] #[Route(methods: ['GET'], path: '/{id}', name: 'get_team', requirements: ['id' => '\d+'])]
public function getAction(Team $team): Response public function getAction(Team $team): Response
{ {
@@ -84,8 +84,8 @@ final class TeamController extends BaseApiController
/** /**
* Delete team * Delete team
*/ */
#[IsGranted('delete_team')] #[IsGranted('delete', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one team')])] #[OA\Delete(responses: [new OA\Response(response: 204, description: 'Empty')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to delete', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to delete', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])]
public function deleteAction(Team $team): Response public function deleteAction(Team $team): Response
@@ -129,7 +129,7 @@ final class TeamController extends BaseApiController
/** /**
* Update team * Update team
*/ */
#[IsGranted('edit_team')] #[IsGranted('edit', 'team')]
#[OA\Patch(description: 'Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)', responses: [new OA\Response(response: 200, description: 'Returns the updated team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])] #[OA\Patch(description: 'Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)', responses: [new OA\Response(response: 200, description: 'Returns the updated team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))] #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to update', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to update', required: true)]
@@ -169,7 +169,7 @@ final class TeamController extends BaseApiController
/** /**
* Add team member * Add team member
*/ */
#[IsGranted('edit_team')] #[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new user to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])] #[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new user to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team which will receive the new member', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The team which will receive the new member', required: true)]
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to add (User ID)', required: true)] #[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to add (User ID)', required: true)]
@@ -193,7 +193,7 @@ final class TeamController extends BaseApiController
/** /**
* Remove team member * Remove team member
*/ */
#[IsGranted('edit_team')] #[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a user from the team. The teamlead cannot be removed.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])] #[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a user from the team. The teamlead cannot be removed.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team from which the member will be removed', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The team from which the member will be removed', required: true)]
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to remove (User ID)', required: true)] #[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to remove (User ID)', required: true)]
@@ -219,10 +219,12 @@ final class TeamController extends BaseApiController
} }
/** /**
* Grant team access to customer * Grant customer access
*
* The team is granted access to the customer.
*/ */
#[IsGranted('edit_team')] #[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new customer to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])] #[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the customer', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to grant acecess to (Customer ID)', required: true)] #[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to grant acecess to (Customer ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/customers/{customerId}', name: 'post_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])] #[Route(methods: ['POST'], path: '/{id}/customers/{customerId}', name: 'post_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
@@ -242,10 +244,12 @@ final class TeamController extends BaseApiController
} }
/** /**
* Revoke customer access from team * Revoke customer access
*
* This removes access to the customer from the team.
*/ */
#[IsGranted('edit_team')] #[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a customer from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])] #[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the customer', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to remove (Customer ID)', required: true)] #[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to remove (Customer ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/customers/{customerId}', name: 'delete_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}/customers/{customerId}', name: 'delete_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
@@ -265,10 +269,12 @@ final class TeamController extends BaseApiController
} }
/** /**
* Grant team access to project * Grant project access
*
* The team is granted access to the project.
*/ */
#[IsGranted('edit_team')] #[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new project to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])] #[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the project', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to grant acecess to (Project ID)', required: true)] #[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to grant acecess to (Project ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/projects/{projectId}', name: 'post_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])] #[Route(methods: ['POST'], path: '/{id}/projects/{projectId}', name: 'post_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
@@ -288,10 +294,12 @@ final class TeamController extends BaseApiController
} }
/** /**
* Revoke project access from team * Revoke project access
*
* This removes access to the project from the team.
*/ */
#[IsGranted('edit_team')] #[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a project from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])] #[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the project', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to remove (Project ID)', required: true)] #[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to remove (Project ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/projects/{projectId}', name: 'delete_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}/projects/{projectId}', name: 'delete_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
@@ -311,10 +319,12 @@ final class TeamController extends BaseApiController
} }
/** /**
* Grant team access to activity * Grant activity access
*
* The team is granted access to the activity.
*/ */
#[IsGranted('edit_team')] #[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new activity to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])] #[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the activity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to grant acecess to (Activity ID)', required: true)] #[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to grant acecess to (Activity ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/activities/{activityId}', name: 'post_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])] #[Route(methods: ['POST'], path: '/{id}/activities/{activityId}', name: 'post_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]
@@ -334,10 +344,12 @@ final class TeamController extends BaseApiController
} }
/** /**
* Revoke activity access from team * Revoke activity access
*
* This removes access to the activity from the team.
*/ */
#[IsGranted('edit_team')] #[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a activity from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])] #[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the activity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to remove (Activity ID)', required: true)] #[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to remove (Activity ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/activities/{activityId}', name: 'delete_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}/activities/{activityId}', name: 'delete_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]

View File

@@ -38,15 +38,9 @@ final class TeamController extends AbstractController
{ {
} }
/**
* @param TeamRepository $repository
* @param Request $request
* @param int $page
* @return Response
*/
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_team', methods: ['GET'])] #[Route(path: '/', defaults: ['page' => 1], name: 'admin_team', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_team_paginated', methods: ['GET'])] #[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_team_paginated', methods: ['GET'])]
public function listTeams(TeamRepository $repository, Request $request, $page): Response public function listTeams(int $page, TeamRepository $repository, Request $request): Response
{ {
$query = new TeamQuery(); $query = new TeamQuery();
$query->setPage($page); $query->setPage($page);
@@ -81,10 +75,6 @@ final class TeamController extends AbstractController
]); ]);
} }
/**
* @param Request $request
* @return Response
*/
#[Route(path: '/create', name: 'admin_team_create', methods: ['GET', 'POST'])] #[Route(path: '/create', name: 'admin_team_create', methods: ['GET', 'POST'])]
#[IsGranted('create_team')] #[IsGranted('create_team')]
public function createTeam(Request $request): Response public function createTeam(Request $request): Response

View File

@@ -24,7 +24,6 @@ final class TeamVoter extends Voter
* support rules based on the given $subject (here: Team) * support rules based on the given $subject (here: Team)
*/ */
private const ALLOWED_ATTRIBUTES = [ private const ALLOWED_ATTRIBUTES = [
'view',
'edit', 'edit',
'delete', 'delete',
]; ];
@@ -52,17 +51,13 @@ final class TeamVoter extends Voter
{ {
$user = $token->getUser(); $user = $token->getUser();
if (!$user instanceof User) { if (!$user instanceof User || !($subject instanceof Team)) {
return false; return false;
} }
switch ($attribute) { // changing existing teams should be limited to admins and teamleads
case 'edit': if (!$user->isAdmin() && !$user->isSuperAdmin() && !$user->isTeamleadOf($subject)) {
case 'delete': return false;
// changing existing teams should be limited to admins and teamleads
if (!$user->isAdmin() && !$user->isSuperAdmin() && !$user->isTeamleadOf($subject)) {
return false;
}
} }
return $this->permissionManager->hasRolePermission($user, $attribute . '_team'); return $this->permissionManager->hasRolePermission($user, $attribute . '_team');

View File

@@ -21,7 +21,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class TeamVoterTest extends AbstractVoterTestCase class TeamVoterTest extends AbstractVoterTestCase
{ {
#[DataProvider('getTestData')] #[DataProvider('getTestData')]
public function testVote(User $user, $subject, $attribute, $result): void public function testVote(User $user, mixed $subject, string $attribute, int $result): void
{ {
$token = new UsernamePasswordToken($user, 'bar', $user->getRoles()); $token = new UsernamePasswordToken($user, 'bar', $user->getRoles());
$sut = $this->getVoter(TeamVoter::class); $sut = $this->getVoter(TeamVoter::class);
@@ -29,7 +29,7 @@ class TeamVoterTest extends AbstractVoterTestCase
self::assertEquals($result, $sut->vote($token, $subject, [$attribute])); self::assertEquals($result, $sut->vote($token, $subject, [$attribute]));
} }
public static function getTestData() public static function getTestData(): iterable
{ {
$user0 = self::getUser(0, null); $user0 = self::getUser(0, null);
$user1 = self::getUser(1, User::ROLE_USER); $user1 = self::getUser(1, User::ROLE_USER);
@@ -39,42 +39,42 @@ class TeamVoterTest extends AbstractVoterTestCase
$team = new Team('foo'); $team = new Team('foo');
$result = VoterInterface::ACCESS_ABSTAIN; $abstain = VoterInterface::ACCESS_ABSTAIN;
$allTeamPerms = ['view_team', 'create_team', 'edit_team', 'delete_team']; $allTeamPerms = ['view_team', 'create_team', 'edit_team', 'delete_team'];
foreach ($allTeamPerms as $fullPerm) { foreach ($allTeamPerms as $fullPerm) {
yield [$user0, [], $fullPerm, $result]; yield [$user0, [], $fullPerm, $abstain];
yield [$user0, new \stdClass(), $fullPerm, $result]; yield [$user0, new \stdClass(), $fullPerm, $abstain];
yield [$user0, $team, $fullPerm, $result]; yield [$user0, $team, $fullPerm, $abstain];
yield [$user1, $team, $fullPerm, $result]; yield [$user1, $team, $fullPerm, $abstain];
yield [$user2, $team, $fullPerm, $result]; yield [$user2, $team, $fullPerm, $abstain];
yield [$user3, $team, $fullPerm, $result]; yield [$user3, $team, $fullPerm, $abstain];
yield [$user4, $team, $fullPerm, $result]; yield [$user4, $team, $fullPerm, $abstain];
} }
$result = VoterInterface::ACCESS_DENIED; $denied = VoterInterface::ACCESS_DENIED;
yield [$user0, $team, 'view', $result]; yield [$user0, $team, 'view', $abstain];
yield [$user0, $team, 'edit', $result]; yield [$user0, $team, 'edit', $denied];
yield [$user0, $team, 'delete', $result]; yield [$user0, $team, 'delete', $denied];
yield [$user1, $team, 'view', $result]; yield [$user1, $team, 'view', $abstain];
yield [$user1, $team, 'edit', $result]; yield [$user1, $team, 'edit', $denied];
yield [$user1, $team, 'delete', $result]; yield [$user1, $team, 'delete', $denied];
yield [$user2, $team, 'view', $result]; yield [$user2, $team, 'view', $abstain];
yield [$user2, $team, 'edit', $result]; yield [$user2, $team, 'edit', $denied];
yield [$user2, $team, 'delete', $result]; yield [$user2, $team, 'delete', $denied];
$result = VoterInterface::ACCESS_GRANTED; $granted = VoterInterface::ACCESS_GRANTED;
yield [$user3, $team, 'view', $result]; yield [$user3, $team, 'view', $abstain];
yield [$user3, $team, 'edit', $result]; yield [$user3, $team, 'edit', $granted];
yield [$user3, $team, 'delete', $result]; yield [$user3, $team, 'delete', $granted];
yield [$user4, $team, 'view', $result]; yield [$user4, $team, 'view', $abstain];
yield [$user4, $team, 'edit', $result]; yield [$user4, $team, 'edit', $granted];
yield [$user4, $team, 'delete', $result]; yield [$user4, $team, 'delete', $granted];
} }
} }

View File

@@ -2381,26 +2381,6 @@ parameters:
count: 1 count: 1
path: Voter/RolePermissionVoterTest.php path: Voter/RolePermissionVoterTest.php
-
message: "#^Method App\\\\Tests\\\\Voter\\\\TeamVoterTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Voter/TeamVoterTest.php
-
message: "#^Method App\\\\Tests\\\\Voter\\\\TeamVoterTest\\:\\:testVote\\(\\) has parameter \\$attribute with no type specified\\.$#"
count: 1
path: Voter/TeamVoterTest.php
-
message: "#^Method App\\\\Tests\\\\Voter\\\\TeamVoterTest\\:\\:testVote\\(\\) has parameter \\$result with no type specified\\.$#"
count: 1
path: Voter/TeamVoterTest.php
-
message: "#^Method App\\\\Tests\\\\Voter\\\\TeamVoterTest\\:\\:testVote\\(\\) has parameter \\$subject with no type specified\\.$#"
count: 1
path: Voter/TeamVoterTest.php
- -
message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:assertVote\\(\\) has parameter \\$attribute with no type specified\\.$#" message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:assertVote\\(\\) has parameter \\$attribute with no type specified\\.$#"
count: 1 count: 1