Team Sign In Method Developer Guide - edmozley/freeitsm GitHub Wiki
Sign-in method at team level — developer guide
Built for #41 · Ships in 2.8.0 · User-facing page: Setting the method on a team
A team can carry a default sign-in method, and an analyst set to Follow team takes it. This page covers how that is built, and the one rule that anyone changing teams or analysts must keep.
🔴 The one rule
Any new code that changes an analyst's teams, a team's method or active flag, or an analyst's sign-in method must call analystSignInApplyMany() afterwards. If it doesn't, the analyst keeps signing in the old way. Nothing errors and nothing looks wrong on screen, so the mistake is easy to miss.
The reason is the design decision below.
Copy the answer in, don't work it out at sign-in
Four places enforce sign-in, and all of them read a single column, analysts.auth_provider_id:
| Where | What it does with the column |
|---|---|
api/auth/resolve_login.php |
the email-first router sends the analyst to that provider |
auth/login.php |
the password form refuses an analyst pinned to an OIDC provider, and binds against an LDAP one |
api/auth/oidc_callback.php |
strict isolation: the provider that answered must be the one pinned |
includes/ldap.php |
the same check for directory sign-in |
The obvious design is to work out the team default at sign-in time. That would mean teaching all four about teams, and a mistake in any one would be a way round strict isolation, the rule that each account has exactly one way in.
So the team's method is copied into auth_provider_id whenever something that could change it is saved. The four enforcement points were not changed at all. The cost is the rule above: every writer has to keep the copy up to date.
The data
| Column | Meaning |
|---|---|
analysts.auth_follow_team |
1 = take the method from their teams. Defaults to 0, so nobody's sign-in changes on upgrade. The screen defaults new analysts to Follow team. |
teams.auth_method |
NULL = not set, 'local' = password, 'provider' = use teams.auth_provider_id |
teams.auth_provider_id |
the provider, when auth_method = 'provider' |
A method is reduced to a single key: 0 for local password, otherwise the provider id. Two teams agree when their keys are equal.
⚠️ There are no foreign keys on the team columns. Database Verification adds columns without constraints, so deleting a provider can leave a team pointing at nothing. The lookup treats such a team as not set, and delete_sso_provider.php clears the reference explicitly. The same was true of analysts.auth_provider_id on verify-built tables, and was fixed at the same time.
The verdict
includes/analyst_signin.php reduces an analyst's active teams' methods to one of three answers:
function analystSignInVerdict(array $teams): array
{
$keys = array_values(array_unique(array_column($teams, 'key')));
if (!$keys) {
return ['status' => 'none', 'key' => null, 'teams' => $teams];
}
if (count($keys) > 1) {
return ['status' => 'conflict', 'key' => null, 'teams' => $teams];
}
return ['status' => 'agreed', 'key' => $keys[0], 'teams' => $teams];
}
Only agreed ever writes anything. none and conflict leave the analyst exactly as they were:
function analystSignInApply(PDO $conn, int $analystId): string
{
if (!analystSignInReady($conn)) {
return 'own';
}
$stmt = $conn->prepare("SELECT auth_follow_team, auth_provider_id FROM analysts WHERE id = ?");
$stmt->execute([$analystId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || (int)$row['auth_follow_team'] !== 1) {
return 'own';
}
$from = analystSignInFromTeams($conn, $analystId);
if ($from['status'] !== 'agreed') {
return $from['status']; // keep what they have - see the header
}
$current = $row['auth_provider_id'] !== null ? (int)$row['auth_provider_id'] : 0;
if ($current === $from['key']) {
return 'unchanged';
}
$conn->prepare("UPDATE analysts SET auth_provider_id = ?, last_modified_datetime = UTC_TIMESTAMP() WHERE id = ?")
->execute([$from['key'] === 0 ? null : $from['key'], $analystId]);
return 'changed';
}
Why a conflict keeps the current method rather than picking one. A priority order between teams was the alternative. It was rejected because it's invisible: someone would one day move a person between teams and change how they sign in without meaning to. A conflict is shown to the administrator instead (see The screens), and they decide.
Where it is called
| Endpoint | Who is recalculated |
|---|---|
api/tickets/save_analyst.php |
that analyst, when the form sends auth_follow_team |
api/tickets/save_analyst_teams.php |
that analyst |
api/tickets/save_team_analysts.php |
members before and after the save |
api/tickets/save_team.php |
the team's members (method and active flag) |
api/tickets/delete_team.php |
the members, read before the delete |
api/system/delete_sso_provider.php |
everyone who follows their team |
Two of those are easy to get wrong.
Members before and after. Someone taken out of a team can change method too, and after the save they are no longer a member to be found:
$before = analystSignInTeamMemberIds($conn, (int)$teamId);
// ... delete and re-insert the team's analyst_teams rows ...
$signin = analystSignInApplyMany($conn, array_merge($before, array_map('intval', $analystIds)));
Deleting a team reads the members first, for the same reason. On a verify-built install there's no cascade, so orphan analyst_teams rows can survive the delete. They're harmless, because the lookup joins teams and doesn't find them.
Sign-in itself never changes anyone's method. The JIT account creation in oidc_callback.php and ldap.php sets the provider explicitly and leaves auth_follow_team at 0. Someone created by signing in has their method from that provider, not from a team.
Every one of these endpoints returns signin: {changed, conflict}, and the screens turn that into a message: "Analysts whose sign-in method changed: 2."
Before Database Verification
New code reaches an install before its administrator runs System → Database Verification. api/tickets/get_analysts.php feeds assignee lists across the whole application, so selecting a column that doesn't exist yet would break far more than this feature. Everything checks analystSignInReady() first:
get_analysts.phpadds the team data only for?signin=1, which only System → Analysts sends, and only when the columns are there;save_analyst.phpandsave_team.phprefuse Follow team or a team method with "Run System → Database Verification…", before writing anything;- the screens hide the Follow team option when the data doesn't include
auth_follow_team.
The screens
- System → Teams: a Sign-in method field on the team, a chip showing it, and N sign-in conflicts from
get_teams.php'ssignin_conflictscount. - System → Analysts: Follow team as the first option, and a line under it saying where the answer comes from. It shows either From their team: Keycloak (Service Desk), or the disagreement and the method being kept.
- The Sign-in conflict badge is a button. It opens a modal that draws the analyst branching to each team, with one colour per method, not per team, so teams that agree visibly match. It then says what happens until the conflict is resolved and offers three ways out: Edit (choose a method for them), Teams (make the teams agree) and Change (change their teams).
⚠️ let analysts in that page is a top-level let, so it is not on window. A test harness driving the page in an iframe has to read it with contentWindow.eval('analysts').
How it was tested
On the docker/proxy-test stack (a throwaway database, never real data), through the real endpoints:
- Before Verification: lists load, and choosing a method gives the message without saving.
- Ten scenarios:
- joining a team from either side;
- a second team with a different method (conflict kept, both teams flagged);
- teams brought back into agreement;
- a team switched off;
- an analyst with their own method, never touched;
- removal from a team on the team side;
- a provider deleted while a team and an analyst pointed at it;
- a team deleted.
- Enforcement unchanged: with an OIDC method inherited from a team, the password form refused the analyst and the SSO button worked. Set back to local, the password worked.
- Both screens in headless Chrome, with a deliberately broken copy of the page as the negative control.
⚠️ Headless Chrome doesn't finish CSS transitions under virtual time. Every .btn in every modal, including the page's own Save and Cancel, measures visibility: hidden and is missing from screenshots. That's the test browser, not the page. Inject *{transition:none!important} into the frame before a screenshot.
Not tested: a real sign-in completing at an OIDC provider through a team-inherited method. The redirect was checked; the round trip was not.
See also
- Single sign-on — strict isolation, and the user-facing account of this feature
- LDAP and Active Directory — the directory side of the same column
- System — the Analysts and Teams screens
- Database Verification — developer guide — why the columns have no foreign keys