Skip to content

Team Sign In Method Developer Guide

Ed Mozley edited this page Sep 27, 2026 · 1 revision

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.php adds the team data only for ?signin=1, which only System β†’ Analysts sends, and only when the columns are there;
  • save_analyst.php and save_team.php refuse 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's signin_conflicts count.
  • 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

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally