Skip to content

First implementation of SAML teams mapping#4770

Draft
the-glu wants to merge 2 commits into
kimai:mainfrom
ArcaniteSolutions:saml_teams_mapping
Draft

First implementation of SAML teams mapping#4770
the-glu wants to merge 2 commits into
kimai:mainfrom
ArcaniteSolutions:saml_teams_mapping

Conversation

@the-glu

@the-glu the-glu commented Apr 10, 2024

Copy link
Copy Markdown

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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • I verified that my code applies to the guidelines (composer code-check)
  • I updated the documentation (see here)
  • I agree that this code is used in Kimai (see license)

@kevinpapst

Copy link
Copy Markdown
Member

Thanks @the-glu for giving this a try 👍

Code-wise it looks good (haven't checked details, but the overall view is great), .
Maybe we should add a big try & catch - so login doesn't fail in case of issues with the teams.

Now as you mentioned: tests are missing and documentation.
Can you share how you tested this?

I'd like to try it, but I don't have a Keycloak setup, so I need either Azure AD or Google Workspace.
How does one setup Teams on IDP side?

@mastertIT

Copy link
Copy Markdown

@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.

@the-glu

the-glu commented Apr 24, 2024

Copy link
Copy Markdown
Author

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.

@mastertIT

For the syntax in the local.yaml config for testing:

kimai:
    saml:
        provider: google
        activate: true
        title: Login with SAML
(...)
        roles:
(...)
        teams:
            resetOnLogin: true
            attribute: groups
            mapping:
                - { saml: testgrsp, kimai: 7, leader: false }
(...)

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 :)

@mastertIT mastertIT left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

works in my lab

@kevinpapst

Copy link
Copy Markdown
Member

An approval is too early 😁 but great feedback 🚀

@CLAassistant

CLAassistant commented May 5, 2024

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

the-glu added a commit to ArcaniteSolutions/www.kimai.org that referenced this pull request May 10, 2024

@kevinpapst kevinpapst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! A few small things to check.

return $this->configuration->getSamlTeamsAttribute();
}

public function getTeamsMapping(): array

@kevinpapst kevinpapst May 12, 2024

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Comment thread src/Saml/SamlProvider.php
$teamMap = [];
foreach ($teamMapping as $mapping) {
$field = $mapping['kimai'];
$team = $this->teamRepository->findById($field)[0];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not like repository magic methods. Please change that to:

Suggested change
$team = $this->teamRepository->findById($field)[0];
$team = $this->teamRepository->findOneBy(['id' => $field]);
if ($team !== null) {
...

Comment thread src/Saml/SamlProvider.php
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even need the boolean? Can't you replace the break by a continue 2?

@mastertIT

Copy link
Copy Markdown

is there any movement on the changes needed to this?

can i make the changes and push them upstream?

@the-glu

the-glu commented Jan 26, 2025

Copy link
Copy Markdown
Author

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 :)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and mapping settings
  • 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.

Comment thread src/Saml/SamlProvider.php
Comment on lines +101 to +108
$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];
}

Copilot AI Dec 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}

/**
* @return array<int, array<'saml'|'kimai'|'leader', string>>

Copilot AI Dec 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* @return array<int, array<'saml'|'kimai'|'leader', string>>
* @return array<int, array<'saml'|'kimai', string>|array<'leader', bool>>

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +63
$teamRepository = $this->createMock(TeamRepository::class);

$provider = new SamlProvider($userService, $userProvider, $teamRepository, $samlConfig, $this->createMock(LoggerInterface::class));

Copilot AI Dec 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/Saml/SamlProvider.php
if (!$user->isInTeam($team)) {
$user->addTeam($team);
}

Copilot AI Dec 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Comment thread src/Saml/SamlProvider.php
$teamMap[$attribute] = ['team' => $team, 'isLeader' => $leader];
}

// Build list of teams from the SAMl list

Copilot AI Dec 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typo "SAMl" should be corrected to "SAML" for consistency and professionalism.

Suggested change
// Build list of teams from the SAMl list
// Build list of teams from the SAML list

Copilot uses AI. Check for mistakes.
Comment thread src/Saml/SamlProvider.php
Comment on lines +101 to +108
$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];
}

Copilot AI Dec 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/Saml/SamlProvider.php
Comment on lines +119 to +135
// 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) {

Copilot AI Dec 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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])) {

Copilot uses AI. Check for mistakes.
Comment thread src/Saml/SamlProvider.php
Comment on lines +126 to +130
$team = $targetTeamInfo['team'];

if ($userTeam === $team) {
$shouldBeInTeam = true;
break;

Copilot AI Dec 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/Saml/SamlProvider.php
if ($shouldBeLeader && !$user->isTeamleadOf($team)) {
$team->addTeamlead($user);
}

Copilot AI Dec 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Comment thread src/Saml/SamlProvider.php
$teamMap = [];
foreach ($teamMapping as $mapping) {
$field = $mapping['kimai'];
$team = $this->teamRepository->findById($field)[0];

Copilot AI Dec 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$team = $this->teamRepository->findById($field)[0];
$team = $this->teamRepository->find($field);
if (null === $team) {
continue;
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

5 participants