From 1e96de74b9e96435acb46d2a222c5a3646072107 Mon Sep 17 00:00:00 2001 From: Giuliano Mele Date: Wed, 7 Aug 2024 17:44:18 +0200 Subject: [PATCH 1/2] Add option to map user API groups to teams - Adds option to map groups to teams in ODC admin section. - Teams are retrieved and created during login. - Teams are assigned and unassigned based on group memberships. --- .env | 6 ++ .gitignore | 2 + config/packages/framework.yaml | 7 ++ config/services.yaml | 7 ++ src/Controller/SettingsController.php | 3 +- src/Controller/TeamController.php | 15 ++- src/Controller/TeamMemberController.php | 2 +- src/Entity/Settings.php | 32 +++++-- src/Entity/Team.php | 22 ++++- src/Form/Type/SettingsType.php | 26 ++++-- src/Migrations/Version20240807153157.php | 37 ++++++++ src/Security/KeycloakAuthenticator.php | 112 ++++++++++++++++++++++- templates/settings/settings.html.twig | 11 ++- translations/form.de.yaml | 5 + 14 files changed, 251 insertions(+), 36 deletions(-) create mode 100644 src/Migrations/Version20240807153157.php diff --git a/.env b/.env index bb37a150..97e44a1d 100644 --- a/.env +++ b/.env @@ -76,3 +76,9 @@ APP_DEMO=0 ###> LaF ### laF_version=2.0.0-dev ###< LaF ### + +### Group Mapper API ### +GROUP_API_URI=http://localhost +GROUP_API_KEY=CHANGEME +GROUP_API_ROLE=CHANGEME +GROUP_API_USER_ID=CHANGEME diff --git a/.gitignore b/.gitignore index 0d871ae7..602f92bc 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,5 @@ docker.conf ###> phpstan/phpstan ### phpstan.neon ###< phpstan/phpstan ### + +.php-version diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index b5450173..7eb9df69 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -11,6 +11,13 @@ framework: cookie_secure: auto cookie_samesite: lax + http_client: + scoped_clients: + group.client: + base_uri: '%env(GROUP_API_URI)%' + headers: + 'X-API-KEY': '%env(default::GROUP_API_KEY)%' + #esi: true #fragments: true php_errors: diff --git a/config/services.yaml b/config/services.yaml index b170a708..ed9b03e6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -15,6 +15,8 @@ parameters: KEYCLOAK_SECRET: '%env(OAUTH_KEYCLOAK_CLIENT_SECRET)%' KEYCLOAK_ID: '%env(OAUTH_KEYCLOAK_CLIENT_ID)%' superAdminRole: '%env(superAdminRole)%' + group_api_user_id: '%env(default::GROUP_API_USER_ID)%' + group_api_role: '%env(default::GROUP_API_ROLE)%' services: # default configuration for services in *this* file _defaults: @@ -57,3 +59,8 @@ services: connection: default calls: - [ setAnnotationReader, [ "@annotation_reader" ] ] + + App\Security\KeycloakAuthenticator: + arguments: + $groupApiUserId: '%group_api_user_id%' + $groupApiRole: '%group_api_role%' diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php index a8c82e0b..2bcf2727 100644 --- a/src/Controller/SettingsController.php +++ b/src/Controller/SettingsController.php @@ -43,8 +43,7 @@ public function manage( $errors = array(); if ($form->isSubmitted() && $form->isValid()) { - $newSettings = $form->getData(); - $settings->setUseKeycloakGroups($newSettings->getUseKeycloakGroups()); + $settings = $form->getData(); $errors = $validator->validate($settings); if (count($errors) == 0) { $em->persist($settings); diff --git a/src/Controller/TeamController.php b/src/Controller/TeamController.php index 53bd677a..507adeff 100644 --- a/src/Controller/TeamController.php +++ b/src/Controller/TeamController.php @@ -348,15 +348,14 @@ public function edit( $availableTeams = $currentTeamService->getTeamsWithoutCurrentHierarchy($user, $team); - $form = $this->createForm( - TeamType::class, - $team, - ['teams' => $availableTeams,] - ); + $form = $this->createForm(TeamType::class, $team, [ + 'teams' => $availableTeams, + 'disabled' => $team->isImmutable(), + ]); $form->handleRequest($request); $errors = array(); - if ($form->isSubmitted() && $form->isValid()) { + if ($form->isSubmitted() && $form->isValid() && !$team->isImmutable()) { $nTeam = $form->getData(); $errors = $validator->validate($nTeam); if (count($errors) == 0) { @@ -391,7 +390,7 @@ public function manage( { $user = $this->getUser(); $settings = $settingsRepository->findOne(); - $useKeycloakGroups = $settings ? $settings->getUseKeycloakGroups() : false; + $useKeycloakGroups = $settings && $settings->getUseKeycloakGroups(); if (!$securityService->superAdminCheck($user)) { return $this->redirectToRoute('dashboard'); @@ -440,7 +439,7 @@ public function teamDelete( $teamId = $request->get('id'); $team = $teamId ? $teamRepository->find($teamId) : $currentTeamService->getCurrentAdminTeam($user); - if ($securityService->superAdminCheck($user) === false) { + if ($securityService->superAdminCheck($user) === false || $team->isImmutable()) { return $this->redirectToRoute('dashboard'); } diff --git a/src/Controller/TeamMemberController.php b/src/Controller/TeamMemberController.php index 11495bd7..c000cf58 100644 --- a/src/Controller/TeamMemberController.php +++ b/src/Controller/TeamMemberController.php @@ -198,7 +198,7 @@ public function mitgliederAdd( $teamId = $request->get('id'); $currentTeam = null; $settings = $settingsRepository->findOne(); - $useKeycloakGroups = $settings ? $settings->getUseKeycloakGroups() : false; + $useKeycloakGroups = $settings && $settings->getUseKeycloakGroups(); $this->setBackButton($this->generateUrl('manage_teams')); if ($teamId) { diff --git a/src/Entity/Settings.php b/src/Entity/Settings.php index 2e9ed943..bd8699cd 100644 --- a/src/Entity/Settings.php +++ b/src/Entity/Settings.php @@ -3,31 +3,47 @@ namespace App\Entity; use App\Repository\SettingsRepository; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: SettingsRepository::class)] class Settings { + const NO_GROUP_MAPPING = 0; + + const KEYCLOAK_GROUP_MAPPING = 1; + + const API_GROUP_MAPPING = 2; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] private $id; - #[ORM\Column(type: 'boolean', nullable: true)] - private $useKeycloakGroups; + #[ORM\Column(options: [ + 'default' => 0, + ])] + #[Assert\Choice([ + self::NO_GROUP_MAPPING, + self::KEYCLOAK_GROUP_MAPPING, + self::API_GROUP_MAPPING, + ])] + private int $groupMapping = 0; - public function getUseKeycloakGroups(): ?bool + public function getGroupMapping(): int { - return $this->useKeycloakGroups; + return $this->groupMapping; } - public function setUseKeycloakGroups(?bool $useKeycloakGroups): self + public function setGroupMapping(int $groupMapping): static { - $this->useKeycloakGroups = $useKeycloakGroups; + $this->groupMapping = $groupMapping; return $this; } + + public function getUseKeycloakGroups(): bool + { + return $this->groupMapping === self::KEYCLOAK_GROUP_MAPPING; + } } diff --git a/src/Entity/Team.php b/src/Entity/Team.php index 3dee97e8..a8a7e7c3 100644 --- a/src/Entity/Team.php +++ b/src/Entity/Team.php @@ -13,7 +13,6 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Gedmo\Tree\Entity\Repository\NestedTreeRepository; -use phpDocumentor\Reflection\Types\Boolean; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Validator\Constraints as Assert; use Gedmo\Mapping\Annotation as Gedmo; @@ -233,6 +232,11 @@ class Team #[ORM\ManyToMany(targetEntity: AuditTomZiele::class, mappedBy: 'ignoredInTeams')] private $ignoredAuditGoals; + #[ORM\Column(options: [ + 'default' => 0, + ])] + private bool $immutable = false; + public function __construct() { $this->members = new ArrayCollection(); @@ -1393,9 +1397,11 @@ public function getRoot(): ?self return $this->root; } - public function setParent(self $parent = null): void + public function setParent(self $parent = null): static { $this->parent = $parent; + + return $this; } public function getParent(): ?self @@ -1659,4 +1665,16 @@ public function removeIgnoredProduct(Produkte $product): self return $this; } + + public function isImmutable(): bool + { + return $this->immutable; + } + + public function setImmutable(bool $immutable): static + { + $this->immutable = $immutable; + + return $this; + } } diff --git a/src/Form/Type/SettingsType.php b/src/Form/Type/SettingsType.php index 4541d969..99083c19 100644 --- a/src/Form/Type/SettingsType.php +++ b/src/Form/Type/SettingsType.php @@ -8,18 +8,10 @@ namespace App\Form\Type; -use App\Entity\AuditTom; -use App\Entity\AuditTomAbteilung; -use App\Entity\AuditTomStatus; -use App\Entity\AuditTomZiele; use App\Entity\Settings; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\Extension\Core\Type\TextareaType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -28,8 +20,22 @@ class SettingsType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options) { $builder - ->add('useKeycloakGroups', CheckboxType::class, ['label' => 'useKeycloakGroups', 'help'=> 'useKeycloakGroupsHelp', 'required' => false, 'translation_domain' => 'form']) - ->add('save', SubmitType::class, ['attr' => array('class' => 'btn'),'label' => 'save', 'translation_domain' => 'form']); + ->add('groupMapping', ChoiceType::class, [ + 'label' => 'groupMapping', + 'help'=> 'groupMappingHelp', + 'translation_domain' => 'form', + 'expanded' => true, + 'choices' => [ + 'noGroupMapping' => Settings::NO_GROUP_MAPPING, + 'useKeycloakGroups' => Settings::KEYCLOAK_GROUP_MAPPING, + 'useApiGroups' => Settings::API_GROUP_MAPPING, + ] + ]) + ->add('save', SubmitType::class, [ + 'attr' => array('class' => 'btn'), + 'label' => 'save', + 'translation_domain' => 'form' + ]); } public function configureOptions(OptionsResolver $resolver) diff --git a/src/Migrations/Version20240807153157.php b/src/Migrations/Version20240807153157.php new file mode 100644 index 00000000..14061b6b --- /dev/null +++ b/src/Migrations/Version20240807153157.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE settings ADD group_mapping INT DEFAULT 0 NOT NULL'); + $this->addSql('UPDATE settings SET group_mapping = use_keycloak_groups WHERE use_keycloak_groups IS NOT NULL'); + $this->addSql('ALTER TABLE settings DROP use_keycloak_groups'); + $this->addSql('ALTER TABLE team ADD immutable TINYINT(1) DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE settings ADD use_keycloak_groups TINYINT(1) DEFAULT NULL'); + $this->addSql('UPDATE settings SET use_keycloak_groups = group_mapping WHERE group_mapping = 1'); + $this->addSql('ALTER TABLE settings DROP group_mapping'); + $this->addSql('ALTER TABLE team DROP immutable'); + } +} diff --git a/src/Security/KeycloakAuthenticator.php b/src/Security/KeycloakAuthenticator.php index 5257004c..adea38ce 100644 --- a/src/Security/KeycloakAuthenticator.php +++ b/src/Security/KeycloakAuthenticator.php @@ -3,10 +3,13 @@ namespace App\Security; use App\Entity\Settings; +use App\Entity\Team; use App\Entity\User; use App\Repository\TeamRepository; use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; +use Exception; use KnpU\OAuth2ClientBundle\Client\ClientRegistry; use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator; use League\OAuth2\Client\Provider\ResourceOwnerInterface; @@ -24,12 +27,15 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; use Symfony\Component\Security\Http\Util\TargetPathTrait; +use Symfony\Contracts\HttpClient\HttpClientInterface; class KeycloakAuthenticator extends OAuth2Authenticator implements AuthenticationEntryPointInterface { use TargetPathTrait; public function __construct( + private readonly ?string $groupApiUserId, + private readonly ?string $groupApiRole, private readonly LoggerInterface $logger, private readonly ParameterBagInterface $parameterBag, private readonly TokenStorageInterface $tokenStorage, @@ -37,6 +43,7 @@ public function __construct( private readonly EntityManagerInterface $em, private readonly RouterInterface $router, private readonly TeamRepository $teamRepository, + private readonly HttpClientInterface $groupClient, ) { } @@ -125,10 +132,10 @@ private function getEmailForKeycloakUser(ResourceOwnerInterface $keycloakUser): try { // FIXME: ResourceOwnerInterface cannot have method getEmail() return $keycloakUser->getEmail(); - } catch (\Exception $e) { + } catch (Exception $e) { try { return $keycloakUser->toArray()['preferred_username']; - } catch (\Exception $e) { + } catch (Exception $e) { } } @@ -188,12 +195,109 @@ private function persistUser(User $user, ResourceOwnerInterface $keycloakUser): $user->setRoles(roles: $this->getRolesForKeycloakUser(keycloakUser: $keycloakUser)); $settings = $this->em->getRepository(Settings::class)->findOne(); - if ($settings && $settings->getUseKeycloakGroups()) { - $user->setTeams(teams: $this->getTeamsFromKeycloakGroups(keycloakUser: $keycloakUser)); + if ($settings) { + switch ($settings->getGroupMapping()) { + case Settings::KEYCLOAK_GROUP_MAPPING: + $user->setTeams(teams: $this->getTeamsFromKeycloakGroups(keycloakUser: $keycloakUser)); + break; + case Settings::API_GROUP_MAPPING: + $teams = $this->syncApiGroups($keycloakUser); + foreach ($teams as $team) { + $user->addTeam($team); + } + break; + } } $this->em->persist($user); $this->em->flush(); return $user; } + + private function syncApiGroups(ResourceOwnerInterface $keycloakUser): Collection { + try { + $userId = $keycloakUser->toArray()[$this->groupApiUserId]; + $response = $this->groupClient->request('GET', "/v1/users/$userId/rbac-structure"); + $responsePayload = $response->toArray(); + + $groups = array_combine( + array_column($responsePayload['groups'], 'divisionKey'), + $responsePayload['groups'] + ); + + $roleDivisions = $this->getGroupsOfMatchingRoles($responsePayload['roles']); + $roleGroups = array_filter($groups, function ($groupKey) use ($roleDivisions) { + return in_array($groupKey, $roleDivisions); + }, ARRAY_FILTER_USE_KEY); + + return $this->createTeams($roleGroups, $groups); + } catch (\Throwable $e) { + $this->logger->error("Exception \"{$e->getMessage()}\" at {$e->getFile()} line {$e->getLine()}"); + return new ArrayCollection(); + } + } + + private function getGroupsOfMatchingRoles(array $roles) { + $teamAdminRoles = array_filter($roles, function ($role) { + return $role['id'] === $this->groupApiRole; + }); + + return array_map(function ($role) { + return $role['divisionKey']; + }, $teamAdminRoles); + } + + /** + * @throws Exception + */ + private function createTeams(array $userGroups, array $groupsTree): Collection { + $teams = []; + foreach ($userGroups as $group) { + $teams[] = $this->createTeamHierarchy($group, $groupsTree); + } + return new ArrayCollection($teams); + } + + /** + * @throws Exception + */ + private function createTeamHierarchy(array $group, array $groupsTree): ?Team { + if (!array_key_exists('parentKey', $group)) { + throw new Exception('Invalid group: '.implode(',', $group)); + } + + if (!$group['parentKey']) { + return $this->getTeam($group); + } + + if (!array_key_exists($group['parentKey'], $groupsTree)) { + throw new Exception('Missing group in tree: '.implode(',', $group)); + } + + $parent = $this->createTeamHierarchy($groupsTree[$group['parentKey']], $groupsTree); + + return $this->getTeam($group, $parent); + } + + private function getTeam(array $group, Team $parent = null): Team { + $team = $this->teamRepository->findOneBy([ + 'immutable' => 1, + 'name' => $group['displayName'], + ]); + + if (!$team) { + $team = new Team(); + $team->setName($group['displayName']) + ->setImmutable(true) + ->setParent($parent) + ->setActiv(true) + ->setStrasse('') + ->setPlz('') + ->setStadt('') + ->setCeo(''); + $this->em->persist($team); + } + + return $team; + } } diff --git a/templates/settings/settings.html.twig b/templates/settings/settings.html.twig index 70426e23..a08b8cde 100644 --- a/templates/settings/settings.html.twig +++ b/templates/settings/settings.html.twig @@ -10,6 +10,15 @@ {% block body %} {{ form_start(form) }} - {{ form_row(form) }} +
+ {{ form_label(form.groupMapping) }} + {% for choice in form.groupMapping %} + {{ form_widget(choice) }} + {{ form_label(choice, '', { + 'label_attr': {'style': 'display: inline-block;'} + }) }} +
{{ (choice.vars.label~'Help')|trans({}, 'form') }}
+ {% endfor %} +
{{ form_end(form) }} {% endblock %} \ No newline at end of file diff --git a/translations/form.de.yaml b/translations/form.de.yaml index e0b7741b..77577270 100644 --- a/translations/form.de.yaml +++ b/translations/form.de.yaml @@ -16,8 +16,13 @@ send: Senden appoint: Ernennen # settings +groupMapping: Gruppenübertragung +noGroupMapping: Keine Übertragung von Gruppen +noGroupMappingHelp: Die Teamzuweisung soll nur über das ODC gesteuert werden. useKeycloakGroups: Keycloak Gruppen verwenden useKeycloakGroupsHelp: Wenn diese Option aktiviert ist, erfolgt die Teamzuweisung im Keycloak und nicht im ODC. Damit das richtig funktioniert, muss im Keycloak Client ein Mapper namens 'groups' existieren, bei dem die Option 'Full group path' deaktiviert ist. +useApiGroups: Gruppen von API übernehmen +useApiGroupsHelp: Die Teamzuweisung erfolgt über eine externe API. # status status: Status From 35ad378f115e090fd4f97828db7e20ccaaa6cb77 Mon Sep 17 00:00:00 2001 From: Giuliano Mele Date: Fri, 16 Aug 2024 13:05:14 +0200 Subject: [PATCH 2/2] Avoid name conflicts for API generated teams --- src/Entity/Team.php | 3 ++- src/Migrations/Version20240816105509.php | 33 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/Migrations/Version20240816105509.php diff --git a/src/Entity/Team.php b/src/Entity/Team.php index a8a7e7c3..1be9ae5e 100644 --- a/src/Entity/Team.php +++ b/src/Entity/Team.php @@ -20,6 +20,7 @@ #[Gedmo\Tree(type: 'nested')] #[ORM\Entity(repositoryClass: NestedTreeRepository::class)] #[UniqueEntity('slug')] +#[ORM\UniqueConstraint(name: 'UNQ_team_name_and_immutable', columns: ['name', 'immutable'])] class Team { #[ORM\Id] @@ -27,7 +28,7 @@ class Team #[ORM\Column(type: 'integer')] private $id; - #[ORM\Column(type: 'string', length: 255, unique: true)] + #[ORM\Column(type: 'string', length: 255)] #[Assert\NotBlank] private $name; diff --git a/src/Migrations/Version20240816105509.php b/src/Migrations/Version20240816105509.php new file mode 100644 index 00000000..7f59a680 --- /dev/null +++ b/src/Migrations/Version20240816105509.php @@ -0,0 +1,33 @@ +addSql('DROP INDEX UNQ_team_name ON team'); + $this->addSql('CREATE UNIQUE INDEX UNQ_team_name_and_immutable ON team (name, immutable)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNQ_team_name_and_immutable ON team'); + $this->addSql('CREATE UNIQUE INDEX UNQ_team_name ON team (name)'); + } +}