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..1be9ae5e 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; @@ -21,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] @@ -28,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; @@ -233,6 +233,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 +1398,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 +1666,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/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)'); + } +} 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_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