First implementation of SAML teams mapping#4770
Conversation
|
Thanks @the-glu for giving this a try 👍 Code-wise it looks good (haven't checked details, but the overall view is great), . Now as you mentioned: tests are missing and documentation. I'd like to try it, but I don't have a Keycloak setup, so I need either Azure AD or Google Workspace. |
|
@the-glu what's the syntax in the local.yaml config for this? from the code it looks like another line in the mapping section? would like to test this. |
|
Hello, Great, I will add tests and documentation as soon as possible :) I tested with our internal SSO but it should works with any SSO. If you like I can make you a Keycloak available somewhere with some basic configuration. For the syntax in the local.yaml config for testing: Saml attribute is the value in the attribute list (that you specify on the line before), kimai is the team id on the kimai side (edit a team and check the URL for the ID), leader is if the user should be leader or not of the team :) |
|
An approval is too early 😁 but great feedback 🚀 |
kevinpapst
left a comment
There was a problem hiding this comment.
Thanks! A few small things to check.
| return $this->configuration->getSamlTeamsAttribute(); | ||
| } | ||
|
|
||
| public function getTeamsMapping(): array |
There was a problem hiding this comment.
| public function getTeamsMapping(): array | |
| /** | |
| * @return array<int, array{'saml': string, 'kimai': int, 'leader': string}> | |
| */ | |
| public function getTeamsMapping(): array |
Please check if that is correct.
Then add the same for the Interface and then you can remove the PHPStan ignores as well.
The PHPStan baseline can only be reduced, but no new ones should be added.
| $teamMap = []; | ||
| foreach ($teamMapping as $mapping) { | ||
| $field = $mapping['kimai']; | ||
| $team = $this->teamRepository->findById($field)[0]; |
There was a problem hiding this comment.
I do not like repository magic methods. Please change that to:
| $team = $this->teamRepository->findById($field)[0]; | |
| $team = $this->teamRepository->findOneBy(['id' => $field]); | |
| if ($team !== null) { | |
| ... |
| if ($this->configuration->isTeamsResetOnLogin()) { | ||
| // For all teams of the user, we check that the user should be present | ||
| foreach ($user->getTeams() as $userTeam) { | ||
| $shouldBeInTeam = false; |
There was a problem hiding this comment.
Do we even need the boolean? Can't you replace the break by a continue 2?
|
is there any movement on the changes needed to this? can i make the changes and push them upstream? |
Yes sorry, I don't have much time those day to work on this :/ I gave you access to the upstream repository so you may do the fixes if you have time. Thanks :) |
There was a problem hiding this comment.
Pull request overview
This PR implements SAML teams mapping functionality, allowing Kimai teams to be automatically assigned and synchronized from SAML authentication attributes. This feature mirrors the existing SAML roles mapping behavior and includes support for team leadership assignment.
Key changes:
- Added configuration support for SAML teams mapping with
resetOnLogin,attribute, andmappingsettings - Implemented team hydration logic in SamlProvider to map SAML attributes to Kimai teams and set leadership status
- Extended configuration interfaces and implementations to expose teams mapping methods
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| src/DependencyInjection/Configuration.php | Adds SAML teams configuration node with mapping structure similar to roles |
| src/Configuration/SystemConfiguration.php | Implements getter methods for teams attribute, mapping, and resetOnLogin flag |
| src/Configuration/SamlConfigurationInterface.php | Defines interface methods for teams configuration access |
| src/Configuration/SamlConfiguration.php | Implements interface methods by delegating to SystemConfiguration |
| src/Saml/SamlProvider.php | Core implementation that fetches teams from repository and syncs user team memberships and leadership based on SAML attributes |
| tests/Saml/SamlProviderTest.php | Updates test setup to inject TeamRepository mock dependency |
| tests/DependencyInjection/ConfigurationTest.php | Adds expected default configuration for teams mapping section |
| phpstan.neon | Updates PHPStan baseline to accommodate new teams mapping methods |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $teamMap = []; | ||
| foreach ($teamMapping as $mapping) { | ||
| $field = $mapping['kimai']; | ||
| $team = $this->teamRepository->findById($field)[0]; | ||
| $attribute = $mapping['saml']; | ||
| $leader = $mapping['leader']; | ||
| $teamMap[$attribute] = ['team' => $team, 'isLeader' => $leader]; | ||
| } |
There was a problem hiding this comment.
There is no error handling for when a team ID in the mapping configuration doesn't exist in the database. If find() returns null, the subsequent code will fail when trying to access properties or methods on the null team object. Add validation to check if the team exists and either skip the mapping entry with appropriate logging, or throw a meaningful exception to help administrators identify misconfigured team IDs.
| } | ||
|
|
||
| /** | ||
| * @return array<int, array<'saml'|'kimai'|'leader', string>> |
There was a problem hiding this comment.
The PHPDoc type annotation incorrectly specifies 'leader' as string when it should be bool. Looking at the configuration in Configuration.php line 855, the 'leader' field is defined as a booleanNode. The type annotation should be @return array<int, array<'saml'|'kimai', string>|array<'leader', bool>> or similar to accurately reflect the mixed types in the mapping array.
| * @return array<int, array<'saml'|'kimai'|'leader', string>> | |
| * @return array<int, array<'saml'|'kimai', string>|array<'leader', bool>> |
| $teamRepository = $this->createMock(TeamRepository::class); | ||
|
|
||
| $provider = new SamlProvider($userService, $userProvider, $teamRepository, $samlConfig, $this->createMock(LoggerInterface::class)); |
There was a problem hiding this comment.
The new teams mapping feature lacks test coverage. The test file is updated to inject TeamRepository as a mock, but there are no actual test cases that verify the teams mapping functionality. Given that SamlProviderTest already has test coverage for user hydration and roles mapping functionality (as seen in testFindUserHydratesUser), tests should be added to verify: 1) teams are correctly mapped from SAML attributes, 2) team leadership is properly set, 3) resetOnLogin behavior works correctly, and 4) error handling for invalid team IDs.
| if (!$user->isInTeam($team)) { | ||
| $user->addTeam($team); | ||
| } | ||
|
|
There was a problem hiding this comment.
The code calls $team->addTeamlead($user) which, according to Team.php:203-217, will add the user to the team if they're not already a member. However, this happens after checking !$user->isInTeam($team) and calling $user->addTeam($team) at line 147. While this redundancy is safe, it creates team membership through two different code paths which could lead to confusion and potential issues with TeamMember object lifecycle. Consider refactoring to ensure team membership is established only once, or add a comment explaining why both approaches are used.
| // Note: Team::addTeamlead() can also add the user to the team if needed, | |
| // but membership is already ensured via User::addTeam() above. In this | |
| // context, addTeamlead() is expected to primarily update the leader flag. | |
| // This avoids relying on two different code paths to create membership. |
| $teamMap[$attribute] = ['team' => $team, 'isLeader' => $leader]; | ||
| } | ||
|
|
||
| // Build list of teams from the SAMl list |
There was a problem hiding this comment.
The typo "SAMl" should be corrected to "SAML" for consistency and professionalism.
| // Build list of teams from the SAMl list | |
| // Build list of teams from the SAML list |
| $teamMap = []; | ||
| foreach ($teamMapping as $mapping) { | ||
| $field = $mapping['kimai']; | ||
| $team = $this->teamRepository->findById($field)[0]; | ||
| $attribute = $mapping['saml']; | ||
| $leader = $mapping['leader']; | ||
| $teamMap[$attribute] = ['team' => $team, 'isLeader' => $leader]; | ||
| } |
There was a problem hiding this comment.
There's potential for a performance issue when processing team mappings. For each team mapping entry, a separate database query is made with find() to fetch the team. If there are many team mappings, this could result in N+1 queries. Consider collecting all team IDs first, then using findByIds() (which already exists in TeamRepository) to fetch all teams in a single query, similar to how it's done in ExportCreateCommand.php line 124.
| // If we need to reset teams on login, remove team that user should not have | ||
| if ($this->configuration->isTeamsResetOnLogin()) { | ||
| // For all teams of the user, we check that the user should be present | ||
| foreach ($user->getTeams() as $userTeam) { | ||
| $shouldBeInTeam = false; | ||
|
|
||
| foreach($teams as $targetTeamInfo) { | ||
| $team = $targetTeamInfo['team']; | ||
|
|
||
| if ($userTeam === $team) { | ||
| $shouldBeInTeam = true; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| // If the team was not found in the list of computed teams from SAML's list, remove the user | ||
| if (!$shouldBeInTeam) { |
There was a problem hiding this comment.
The nested loop structure for checking team membership (lines 122-138) could be optimized. Currently, for each user team, the code iterates through all SAML teams to check if it should be kept. This results in O(n*m) complexity. Consider building a Set or using array keys for O(1) lookup when checking if a team should be kept. For example, create an array of team IDs from $teams first, then use in_array() or isset() for faster lookups.
| // If we need to reset teams on login, remove team that user should not have | |
| if ($this->configuration->isTeamsResetOnLogin()) { | |
| // For all teams of the user, we check that the user should be present | |
| foreach ($user->getTeams() as $userTeam) { | |
| $shouldBeInTeam = false; | |
| foreach($teams as $targetTeamInfo) { | |
| $team = $targetTeamInfo['team']; | |
| if ($userTeam === $team) { | |
| $shouldBeInTeam = true; | |
| break; | |
| } | |
| } | |
| // If the team was not found in the list of computed teams from SAML's list, remove the user | |
| if (!$shouldBeInTeam) { | |
| // Build a lookup map of target teams for faster membership checks | |
| $targetTeamsMap = []; | |
| foreach ($teams as $targetTeamInfo) { | |
| $team = $targetTeamInfo['team']; | |
| $targetTeamsMap[\spl_object_hash($team)] = true; | |
| } | |
| // If we need to reset teams on login, remove team that user should not have | |
| if ($this->configuration->isTeamsResetOnLogin()) { | |
| // For all teams of the user, we check that the user should be present | |
| foreach ($user->getTeams() as $userTeam) { | |
| $userTeamHash = \spl_object_hash($userTeam); | |
| // If the team was not found in the list of computed teams from SAML's list, remove the user | |
| if (!isset($targetTeamsMap[$userTeamHash])) { |
| $team = $targetTeamInfo['team']; | ||
|
|
||
| if ($userTeam === $team) { | ||
| $shouldBeInTeam = true; | ||
| break; |
There was a problem hiding this comment.
Potential null pointer issue: if a team object is null (due to invalid team ID in mapping), the code will fail when attempting to compare $userTeam === $team at line 128. This is a continuation of the issue with missing null checks when fetching teams from the repository. Ensure proper validation is in place before using team objects in comparisons.
| if ($shouldBeLeader && !$user->isTeamleadOf($team)) { | ||
| $team->addTeamlead($user); | ||
| } | ||
|
|
There was a problem hiding this comment.
The leadership demotion logic at lines 155-157 could create an inconsistent state. If $shouldBeLeader is false, the code will demote the user but won't check if they should remain in the team at all. According to the resetOnLogin logic (lines 120-138), team membership is only removed for teams NOT in the SAML list. However, if a user appears in the SAML teams list but with leader=false when they were previously a leader, they will be correctly demoted. The logic appears correct, but consider adding a comment to clarify that demotion only applies to users who remain in the team.
| // Demotion here only applies to teams that the user is supposed to remain a member of. | |
| // Team membership has already been synchronized above based on the SAML list, and within | |
| // this loop we ensure the user is in $team before adjusting the leader flag. |
| $teamMap = []; | ||
| foreach ($teamMapping as $mapping) { | ||
| $field = $mapping['kimai']; | ||
| $team = $this->teamRepository->findById($field)[0]; |
There was a problem hiding this comment.
The method findById does not exist in the TeamRepository. The TeamRepository extends EntityRepository which provides a find($id) method that returns a single Team or null, not an array. Accessing [0] on a potentially null value will cause an error. Consider using $this->teamRepository->find($field) instead, and add proper null checking to handle cases where the team ID doesn't exist in the database.
| $team = $this->teamRepository->findById($field)[0]; | |
| $team = $this->teamRepository->find($field); | |
| if (null === $team) { | |
| continue; | |
| } |
Description
Hello,
This is a basic implementation of #3846: It add possibility to map kimai's team from a SAML attribute.
It's also allow setting the leadership 'attribute' of a user in a team.
This PR currently lacks tests and documentations, I wanted first to be sure the implementation direction is ok :)
Feedback is welcome, my PHP is a bit rusty ^^'
===> PR for documentation: kimai/www.kimai.org#423
Types of changes
Checklist
composer code-check)