AGENTS/plans/2_20/external-identity-linking.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Link LDAP/OIDC users to accounts via a dedicated identity entity keyed by the provider's stable ID (OIDC sub, LDAP DN) instead of matching by email — eliminating the account-takeover class of bugs (Grafana CVE-2023-3128) while keeping upgrades seamless.
Architecture: New table user__external_identity (analog of Grafana's user_auth, with its known flaws fixed: UNIQUE index on (provider, external_uid), wide external_uid column for long LDAP DNs, FK ON DELETE CASCADE instead of lazy stale-row cleanup, no OAuth-token columns mixed in). A single resolver resolveExternalUser is used by both LDAP and OIDC login flows: lookup by (provider, external_uid) first; fall back to legacy email/username matching only under a configurable mode (auto — default, self-closing migration path; always; never); otherwise create user + identity.
Tech Stack: Go, gorp (via existing SqlDb helpers), SQL migrations (MySQL/PostgreSQL/SQLite), testify.
Grafana user_auth flaw | This design |
|---|---|
auth_id varchar(190) breaks long LDAP DNs | external_uid varchar(700) (fits MySQL 3072-byte index limit with utf8mb4) |
Non-unique index on (auth_module, auth_id), "latest created wins" ambiguity | UNIQUE (provider, external_uid) |
| Stale rows referencing deleted users, cleaned lazily in login code | FOREIGN KEY (user_id) ... ON DELETE CASCADE |
| OAuth tokens mixed into the linking table | Not stored (YAGNI until a refresh-token feature exists) |
| Email-lookup toggle was a global breaking change ("user already exists" after upgrade) | auto mode: legacy External users without any identity row are matched by email once, then pinned — no flag day |
| Email lookup could adopt any account | Even in always mode only External accounts can be matched; local (password) accounts are never adopted |
| No API to view/unlink a single identity | GET /api/users/{user_id}/identities + DELETE /api/users/{user_id}/identities/{provider} (admin) |
util.Config is the existing exception.github.com/stretchr/testify/assert / require, table-driven with t.Run (project rule).db/sql/migrations/v2.18.5.sql (the loader preprocesses per dialect).db/Migration.go accordingly."ldap"; OIDC provider keys come from the oidc_providers map in config. An OIDC provider named ldap in config would collide — reject in resolver is NOT needed; instead validation is part of Task 4 resolver tests documentation ("ldap" is reserved, noted in config schema description).v2.20.0 — table user__external_identityFiles:
db/sql/migrations/v2.20.0.sqldb/Migration.go (append to commonScripts, currently ends with {Version: "2.19.11"})db/sql/migration_2_20_0_test.goInterfaces:
Consumes: nothing.
Produces: table user__external_identity with columns id, user_id, provider, external_uid, created; unique index on (provider, external_uid).
Step 1: Write the failing migration test
package sql
import (
"testing"
"github.com/semaphoreui/semaphore/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMigration_2_20_0_CreatesExternalIdentityTable(t *testing.T) {
// CreateTestStore (db/sql/SqlDb.go:124) sets util.Config, connects an
// in-memory sqlite (PRAGMA foreign_keys=ON) and runs db.Migrate(store, nil).
store := CreateTestStore()
user, err := store.CreateUserWithoutPassword(db.User{
Username: "jdoe", Name: "John Doe", Email: "[email protected]", External: true,
})
require.NoError(t, err)
// Table exists and accepts a row.
_, err = store.exec(
"insert into user__external_identity (user_id, provider, external_uid, created) values (?, 'ldap', 'cn=admin,dc=example,dc=org', ?)",
user.ID, user.Created)
assert.NoError(t, err)
// Unique (provider, external_uid) index rejects a duplicate.
_, err = store.exec(
"insert into user__external_identity (user_id, provider, external_uid, created) values (?, 'ldap', 'cn=admin,dc=example,dc=org', ?)",
user.ID, user.Created)
assert.Error(t, err)
}
Run: go test ./db/sql/ -run TestMigration_2_20_0 -v -count=1
Expected: FAIL (no such table: user__external_identity or migration version unknown)
db/sql/migrations/v2.20.0.sql:
create table `user__external_identity` (
`id` integer primary key autoincrement,
`user_id` int not null,
`provider` varchar(64) not null,
`external_uid` varchar(700) not null,
`created` datetime not null,
foreign key (`user_id`) references `user`(`id`) on delete cascade
);
create unique index `user__external_identity__provider_uid`
on `user__external_identity`(`provider`, `external_uid`);
create index `user__external_identity__user_id`
on `user__external_identity`(`user_id`);
In db/Migration.go, in commonScripts after {Version: "2.19.11"}, add:
{Version: "2.20.0"},
Run: go test ./db/sql/ -run TestMigration_2_20_0 -v -count=1
Expected: PASS
git add db/sql/migrations/v2.20.0.sql db/Migration.go db/sql/migration_2_20_0_test.go
git commit -m "feat(auth): add user__external_identity table"
Files:
db/UserExternalIdentity.godb/sql/external_identity.godb/Store.go (new ExternalIdentityManager interface, embed into Store, add UserExternalIdentityProps)db/sql/SqlDb.go:97 (gorp table mapping, after the TaskParams line)db/sql/external_identity_test.goInterfaces:
type UserExternalIdentity struct {
ID int `db:"id" json:"id"`
UserID int `db:"user_id" json:"user_id"`
Provider string `db:"provider" json:"provider"`
ExternalUID string `db:"external_uid" json:"external_uid"`
Created time.Time `db:"created" json:"created"`
}
type ExternalIdentityManager interface {
GetExternalIdentity(provider string, externalUID string) (UserExternalIdentity, error)
GetUserExternalIdentities(userID int) ([]UserExternalIdentity, error)
CreateExternalIdentity(identity UserExternalIdentity) (UserExternalIdentity, error)
DeleteExternalIdentity(userID int, provider string) error
}
db/sql/external_identity_test.go:
package sql
import (
"testing"
"github.com/semaphoreui/semaphore/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExternalIdentityCRUD(t *testing.T) {
store := CreateTestStore()
user, err := store.CreateUserWithoutPassword(db.User{
Username: "jdoe",
Name: "John Doe",
Email: "[email protected]",
External: true,
})
require.NoError(t, err)
// Not found before creation.
_, err = store.GetExternalIdentity("ldap", "cn=jdoe,dc=example,dc=org")
assert.ErrorIs(t, err, db.ErrNotFound)
created, err := store.CreateExternalIdentity(db.UserExternalIdentity{
UserID: user.ID,
Provider: "ldap",
ExternalUID: "cn=jdoe,dc=example,dc=org",
})
require.NoError(t, err)
assert.NotZero(t, created.Created)
found, err := store.GetExternalIdentity("ldap", "cn=jdoe,dc=example,dc=org")
require.NoError(t, err)
assert.Equal(t, user.ID, found.UserID)
// Unique (provider, external_uid).
_, err = store.CreateExternalIdentity(db.UserExternalIdentity{
UserID: user.ID,
Provider: "ldap",
ExternalUID: "cn=jdoe,dc=example,dc=org",
})
assert.Error(t, err)
// Same user, second provider — allowed.
_, err = store.CreateExternalIdentity(db.UserExternalIdentity{
UserID: user.ID,
Provider: "keycloak",
ExternalUID: "8b53f1e0-0000-0000-0000-000000000000",
})
require.NoError(t, err)
list, err := store.GetUserExternalIdentities(user.ID)
require.NoError(t, err)
assert.Len(t, list, 2)
require.NoError(t, store.DeleteExternalIdentity(user.ID, "keycloak"))
list, err = store.GetUserExternalIdentities(user.ID)
require.NoError(t, err)
assert.Len(t, list, 1)
// Cascade on user deletion.
require.NoError(t, store.DeleteUser(user.ID))
_, err = store.GetExternalIdentity("ldap", "cn=jdoe,dc=example,dc=org")
assert.ErrorIs(t, err, db.ErrNotFound)
}
Run: go test ./db/sql/ -run TestExternalIdentityCRUD -v -count=1
Expected: compile error (GetExternalIdentity undefined)
db/UserExternalIdentity.go:
package db
import "time"
// UserExternalIdentity links a Semaphore user to an identity at an external
// auth provider. Provider is "ldap" or a key of the oidc_providers config map.
// ExternalUID is the provider's stable user ID: the OIDC "sub" claim or the
// LDAP entry DN. Matching by this pair (instead of by email) prevents account
// takeover via reused/unverified emails.
type UserExternalIdentity struct {
ID int `db:"id" json:"id"`
UserID int `db:"user_id" json:"user_id"`
Provider string `db:"provider" json:"provider"`
ExternalUID string `db:"external_uid" json:"external_uid"`
Created time.Time `db:"created" json:"created"`
}
In db/Store.go next to SessionManager/TokenManager:
type ExternalIdentityManager interface {
GetExternalIdentity(provider string, externalUID string) (UserExternalIdentity, error)
GetUserExternalIdentities(userID int) ([]UserExternalIdentity, error)
CreateExternalIdentity(identity UserExternalIdentity) (UserExternalIdentity, error)
DeleteExternalIdentity(userID int, provider string) error
}
Embed ExternalIdentityManager into the Store interface (next to SessionManager).
Next to TokenProps (db/Store.go ~line 707):
var UserExternalIdentityProps = ObjectProps{
TableName: "user__external_identity",
Type: reflect.TypeOf(UserExternalIdentity{}),
PrimaryColumnName: "id",
}
In db/sql/SqlDb.go after line 97 (TaskParams mapping):
d.sql.AddTableWithName(db.UserExternalIdentity{}, "user__external_identity").SetKeys(true, "id")
db/sql/external_identity.go:
package sql
import (
"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/pkg/tz"
)
func (d *SqlDb) CreateExternalIdentity(identity db.UserExternalIdentity) (db.UserExternalIdentity, error) {
identity.Created = db.GetParsedTime(tz.Now())
err := d.Sql().Insert(&identity)
return identity, err
}
func (d *SqlDb) GetExternalIdentity(provider string, externalUID string) (identity db.UserExternalIdentity, err error) {
err = d.selectOne(
&identity,
"select * from user__external_identity where provider=? and external_uid=?",
provider, externalUID)
return
}
func (d *SqlDb) GetUserExternalIdentities(userID int) (identities []db.UserExternalIdentity, err error) {
_, err = d.selectAll(
&identities,
d.PrepareQuery("select * from user__external_identity where user_id=? order by created desc"),
userID)
return
}
func (d *SqlDb) DeleteExternalIdentity(userID int, provider string) error {
_, err := d.exec(
"delete from user__external_identity where user_id=? and provider=?",
userID, provider)
return err
}
(selectOne already maps sql.ErrNoRows → db.ErrNotFound, see db/sql/SqlDb.go:439.)
Run: go test ./db/sql/ -run TestExternalIdentityCRUD -v -count=1
Expected: PASS (Connect() executes PRAGMA foreign_keys = ON for sqlite — db/sql/SqlDb.go:100 — so the cascade assertion is reliable).
Run: go build ./...
Expected: OK (any other db.Store implementers, e.g. in pro_impl/, must compile; if a pro wrapper embeds db.Store it inherits the methods automatically — verify).
git add db/UserExternalIdentity.go db/Store.go db/sql/SqlDb.go db/sql/external_identity.go db/sql/external_identity_test.go
git commit -m "feat(auth): external identity entity and store methods"
external_auth_email_matchingFiles:
util/config.go (next to PasswordLoginDisable, ~line 577)config.schema.yaml — regenerate via the semaphore-config-schema skill after the code changeutil/config_test.go only if a validation helper is added (none needed — plain string field)Interfaces:
Produces: util.Config.ExternalAuthEmailMatching (string: ""/"auto" | "always" | "never"), consumed by Task 4.
Step 1: Add the field
In util/config.go, next to PasswordLoginDisable:
// ExternalAuthEmailMatching controls whether an LDAP/OIDC login may be
// linked to an existing user by email when no external identity record
// exists yet:
// "auto" (default) - only external users without any linked identity
// (one-time adoption of pre-2.20 accounts);
// "always" - any external user (needed when the same person
// logs in via several providers);
// "never" - identities are matched strictly by provider ID.
// Local (password) accounts are never matched regardless of the mode.
ExternalAuthEmailMatching string `json:"external_auth_email_matching,omitempty" env:"SEMAPHORE_EXTERNAL_AUTH_EMAIL_MATCHING" rule:"^(auto|always|never)?$" default:"auto"`
Note: check how rule:/default: tags are handled in util/config.go config loading; if default: is not applied for strings automatically, treat empty string as auto in code (Task 4 does exactly that — do NOT rely on the tag).
Run: go build ./util/
Expected: OK
Invoke the semaphore-config-schema skill (it derives config.schema.yaml from util.ConfigType). In the field description mention that provider key ldap is reserved for the LDAP integration.
git add util/config.go config.schema.yaml
git commit -m "feat(auth): external_auth_email_matching config option"
resolveExternalUserFiles:
api/login_identity.goapi/login_identity_test.goInterfaces:
ExternalIdentityManager methods (Task 2), util.Config.ExternalAuthEmailMatching (Task 3), existing store.GetUserByLoginOrEmail, store.CreateUserWithoutPassword, store.UpdateUser, store.GetUser.type externalUserProfile struct {
Provider string // "ldap" or oidc provider key
ExternalUID string // LDAP DN or OIDC sub
Username string
Name string
Email string
MatchByUsername bool // LDAP legacy behavior: also match by username
}
func resolveExternalUser(store db.Store, p externalUserProfile) (db.User, error)
api/login_identity_test.go:
package api
import (
"testing"
"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/db/sql"
"github.com/semaphoreui/semaphore/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupIdentityTest(t *testing.T, emailMatching string) db.Store {
t.Helper()
// IMPORTANT: CreateTestStore() overwrites util.Config (db/sql/SqlDb.go:124),
// so the option must be set AFTER creating the store.
store := sql.CreateTestStore()
util.Config.ExternalAuthEmailMatching = emailMatching
return store
}
func ldapProfile(uid string, email string) externalUserProfile {
return externalUserProfile{
Provider: "ldap",
ExternalUID: uid,
Username: "jdoe",
Name: "John Doe",
Email: email,
MatchByUsername: true,
}
}
func TestResolveExternalUser_CreatesUserAndIdentity(t *testing.T) {
store := setupIdentityTest(t, "auto")
user, err := resolveExternalUser(store, ldapProfile("cn=jdoe,dc=x", "[email protected]"))
require.NoError(t, err)
assert.True(t, user.External)
ids, err := store.GetUserExternalIdentities(user.ID)
require.NoError(t, err)
require.Len(t, ids, 1)
assert.Equal(t, "cn=jdoe,dc=x", ids[0].ExternalUID)
}
func TestResolveExternalUser_FindsByIdentityAfterEmailChange(t *testing.T) {
store := setupIdentityTest(t, "auto")
first, err := resolveExternalUser(store, ldapProfile("cn=jdoe,dc=x", "[email protected]"))
require.NoError(t, err)
// Email changed at the IdP: same identity, attributes synced, no new user.
second, err := resolveExternalUser(store, ldapProfile("cn=jdoe,dc=x", "[email protected]"))
require.NoError(t, err)
assert.Equal(t, first.ID, second.ID)
assert.Equal(t, "[email protected]", second.Email)
}
func TestResolveExternalUser_AutoAdoptsLegacyExternalUser(t *testing.T) {
store := setupIdentityTest(t, "auto")
// Pre-2.20 external user: no identity rows.
legacy, err := store.CreateUserWithoutPassword(db.User{
Username: "jdoe", Name: "John Doe", Email: "[email protected]", External: true,
})
require.NoError(t, err)
user, err := resolveExternalUser(store, ldapProfile("cn=jdoe,dc=x", "[email protected]"))
require.NoError(t, err)
assert.Equal(t, legacy.ID, user.ID)
ids, err := store.GetUserExternalIdentities(legacy.ID)
require.NoError(t, err)
assert.Len(t, ids, 1)
}
func TestResolveExternalUser_AutoDoesNotAdoptPinnedUser(t *testing.T) {
store := setupIdentityTest(t, "auto")
// User already pinned to a different provider identity.
pinned, err := resolveExternalUser(store, externalUserProfile{
Provider: "keycloak", ExternalUID: "sub-1",
Username: "jdoe", Name: "John Doe", Email: "[email protected]",
})
require.NoError(t, err)
// Same email arrives from another provider: must NOT reuse the account.
other, err := resolveExternalUser(store, externalUserProfile{
Provider: "okta", ExternalUID: "sub-2",
Username: "jdoe2", Name: "John Doe", Email: "[email protected]",
})
if err == nil {
assert.NotEqual(t, pinned.ID, other.ID)
} else {
// Unique email constraint may forbid the second user - also acceptable,
// but it must NOT be a silent merge.
assert.Error(t, err)
}
}
func TestResolveExternalUser_AlwaysLinksSecondProvider(t *testing.T) {
store := setupIdentityTest(t, "always")
first, err := resolveExternalUser(store, externalUserProfile{
Provider: "keycloak", ExternalUID: "sub-1",
Username: "jdoe", Name: "John Doe", Email: "[email protected]",
})
require.NoError(t, err)
second, err := resolveExternalUser(store, externalUserProfile{
Provider: "okta", ExternalUID: "sub-2",
Username: "jdoe", Name: "John Doe", Email: "[email protected]",
})
require.NoError(t, err)
assert.Equal(t, first.ID, second.ID)
ids, err := store.GetUserExternalIdentities(first.ID)
require.NoError(t, err)
assert.Len(t, ids, 2)
}
func TestResolveExternalUser_NeverSkipsEmailMatching(t *testing.T) {
store := setupIdentityTest(t, "never")
_, err := store.CreateUserWithoutPassword(db.User{
Username: "jdoe", Name: "John Doe", Email: "[email protected]", External: true,
})
require.NoError(t, err)
// Legacy user is NOT adopted; creation of a duplicate-email user must fail
// rather than silently merge.
_, err = resolveExternalUser(store, ldapProfile("cn=jdoe,dc=x", "[email protected]"))
assert.Error(t, err)
}
func TestResolveExternalUser_NeverAdoptsLocalUser(t *testing.T) {
store := setupIdentityTest(t, "always")
local, err := store.CreateUser(db.UserWithPwd{
Pwd: "verystrongpassword1",
User: db.User{
Username: "jdoe", Name: "John Doe", Email: "[email protected]",
},
})
require.NoError(t, err)
res, err := resolveExternalUser(store, ldapProfile("cn=jdoe,dc=x", "[email protected]"))
if err == nil {
assert.NotEqual(t, local.ID, res.ID)
} else {
assert.Error(t, err)
}
// The local account must remain identity-free.
ids, err := store.GetUserExternalIdentities(local.ID)
require.NoError(t, err)
assert.Empty(t, ids)
}
Run: go test ./api/ -run TestResolveExternalUser -v -count=1
Expected: compile error (resolveExternalUser undefined)
api/login_identity.go:
package api
import (
"errors"
"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/util"
)
// externalUserProfile is what an external auth flow (LDAP or OIDC) learned
// about the user. ExternalUID must be the provider's stable ID: LDAP DN or
// OIDC "sub" claim.
type externalUserProfile struct {
Provider string
ExternalUID string
Username string
Name string
Email string
MatchByUsername bool // LDAP legacy behavior: also match by username
}
// resolveExternalUser maps an external identity to a Semaphore user:
// 1. by (provider, external_uid) — the only trusted key;
// 2. by email/username — only under external_auth_email_matching mode,
// only External users (local accounts are never adopted);
// 3. otherwise a new user is created and linked.
func resolveExternalUser(store db.Store, p externalUserProfile) (db.User, error) {
if p.ExternalUID == "" {
return db.User{}, errors.New("external identity: empty external UID")
}
identity, err := store.GetExternalIdentity(p.Provider, p.ExternalUID)
switch {
case err == nil:
user, err2 := store.GetUser(identity.UserID)
if err2 != nil {
return db.User{}, err2
}
return syncExternalUserAttrs(store, user, p)
case !errors.Is(err, db.ErrNotFound):
return db.User{}, err
}
user, err := matchExternalUserByEmail(store, p)
switch {
case err == nil:
if _, err = store.CreateExternalIdentity(db.UserExternalIdentity{
UserID: user.ID,
Provider: p.Provider,
ExternalUID: p.ExternalUID,
}); err != nil {
return db.User{}, err
}
return syncExternalUserAttrs(store, user, p)
case !errors.Is(err, db.ErrNotFound):
return db.User{}, err
}
user, err = store.CreateUserWithoutPassword(db.User{
Username: p.Username,
Name: p.Name,
Email: p.Email,
External: true,
})
if err != nil {
return db.User{}, err
}
_, err = store.CreateExternalIdentity(db.UserExternalIdentity{
UserID: user.ID,
Provider: p.Provider,
ExternalUID: p.ExternalUID,
})
return user, err
}
// matchExternalUserByEmail implements the legacy email/username matching,
// gated by the external_auth_email_matching config mode.
func matchExternalUserByEmail(store db.Store, p externalUserProfile) (db.User, error) {
mode := util.Config.ExternalAuthEmailMatching
if mode == "" {
mode = "auto" // ponytail: default applied here, not via config tag
}
if mode == "never" {
return db.User{}, db.ErrNotFound
}
login := ""
if p.MatchByUsername {
login = p.Username
}
user, err := store.GetUserByLoginOrEmail(login, p.Email)
if err != nil {
return db.User{}, err
}
// Local accounts are never adopted by external providers - this is the
// takeover-protection invariant, independent of the mode.
if !user.External {
return db.User{}, db.ErrNotFound
}
if mode == "auto" {
identities, err2 := store.GetUserExternalIdentities(user.ID)
if err2 != nil {
return db.User{}, err2
}
if len(identities) > 0 {
// Already pinned to some identity - email matching no longer applies.
return db.User{}, db.ErrNotFound
}
}
return user, nil
}
// syncExternalUserAttrs updates name/email from the provider on each login,
// so an email change at the IdP is reflected instead of orphaning the account.
func syncExternalUserAttrs(store db.Store, user db.User, p externalUserProfile) (db.User, error) {
changed := false
if p.Email != "" && user.Email != p.Email {
user.Email = p.Email
changed = true
}
if p.Name != "" && user.Name != p.Name {
user.Name = p.Name
changed = true
}
if !changed {
return user, nil
}
err := store.UpdateUser(db.UserWithPwd{User: user})
return user, err
}
Run: go test ./api/ -run TestResolveExternalUser -v -count=1
Expected: PASS
git add api/login_identity.go api/login_identity_test.go
git commit -m "feat(auth): external user resolver with identity linking"
Files:
api/login.go:46-149 (tryFindLDAPUser — return DN), api/login.go:241-258 (loginByLDAP), api/login.go:343-361 (call site in login)Interfaces:
Consumes: resolveExternalUser (Task 4).
Produces: tryFindLDAPUser(username, password string) (*db.User, string, error) — third return is the entry DN.
Step 1: Change tryFindLDAPUser to return the DN
Signature: func tryFindLDAPUser(username, password string) (*db.User, string, error).
All existing return nil, err → return nil, "", err; return nil, nil (user not found) → return nil, "", nil.
The DN is already computed at api/login.go:94 (userDN := sr.Entries[0].DN); final return becomes:
return &ldapUser, userDN, nil
loginByLDAP bodyfunc loginByLDAP(store db.Store, ldapUser db.User, userDN string) (db.User, error) {
return resolveExternalUser(store, externalUserProfile{
Provider: "ldap",
ExternalUID: userDN,
Username: ldapUser.Username,
Name: ldapUser.Name,
Email: ldapUser.Email,
MatchByUsername: true,
})
}
(The old !user.External check moved into matchExternalUserByEmail — behavior preserved: LDAP login into a local account is still rejected.)
login() var ldapUser *db.User
var ldapUserDN string
if util.Config.LdapEnable {
ldapUser, ldapUserDN, err = tryFindLDAPUser(login.Auth, login.Password)
...
}
...
} else {
user, err = loginByLDAP(helpers.Store(r), *ldapUser, ldapUserDN)
}
(Keep the surrounding error handling exactly as it is today.)
Run: go test ./api/... ./db/... -count=1
Expected: PASS (including existing api/login_test.go)
git add api/login.go
git commit -m "feat(auth): link LDAP logins via external identity (DN)"
sub claim)Files:
api/login.go:571-575 (claimResult — add sub), api/login.go:652-672 (claimOidcUserInfo, claimOidcToken — fill sub), api/login.go:752-810 (oidcRedirect — use resolver)Interfaces:
Consumes: resolveExternalUser (Task 4).
Produces: nothing new.
Step 1: Capture sub
type claimResult struct {
sub string
username string
name string
email string
}
In claimOidcToken (api/login.go:663), after parsing claims:
res, err = parseClaims(claims, &provider)
res.sub = idToken.Subject
return
In claimOidcUserInfo (api/login.go:652), same pattern:
res, err = parseClaims(claims, &provider)
res.sub = userInfo.Subject
return
In the oidcRedirect userinfo fallback branch (api/login.go:765-782), where claims.email/claims.name are set directly from userInfo, also set:
claims.sub = userInfo.Subject
oidcRedirectReplace api/login.go:790-810 (from user, err := helpers.Store(r).GetUserByLoginOrEmail(...) through the !user.External check) with:
if claims.sub == "" {
log.Error(fmt.Errorf("oidc provider %s returned no sub claim", pid))
http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
return
}
user, err := resolveExternalUser(helpers.Store(r), externalUserProfile{
Provider: pid,
ExternalUID: claims.sub,
Username: claims.username,
Name: claims.name,
Email: claims.email,
// MatchByUsername stays false: OIDC matches by email only
// (username matching "creates a lot of problems" - see old comment).
})
if err != nil {
log.Error(err.Error())
http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
return
}
Run: go test ./api/... -count=1
Expected: PASS
Configure a local Keycloak/Authentik OIDC provider, log in twice, change the user's email at the IdP between logins:
1st login: user created, row in user__external_identity with provider=<pid>, external_uid=<sub>.
2nd login: same user ID, email updated, still one identity row.
Step 5: Commit
git add api/login.go
git commit -m "feat(auth): link OIDC logins via external identity (sub claim)"
Files:
api/users.go (two handlers on the users controller)api/router.go:266-270 (routes under userAPI, which already applies GetUserMiddleware; admin enforcement as in UpdateUser/DeleteUser handlers)api/users_identity_test.goInterfaces:
Consumes: GetUserExternalIdentities, DeleteExternalIdentity (Task 2).
Produces: GET /api/users/{user_id}/identities → []db.UserExternalIdentity; DELETE /api/users/{user_id}/identities/{provider} → 204.
Step 1: Write the failing test
api/users_identity_test.go (follow the request-builder pattern of api/user_options_test.go — context values store and user; check how existing users handlers read the target user from context via GetUserMiddleware and mirror that setup):
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/semaphoreui/semaphore/api/helpers"
"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/db/sql"
"github.com/semaphoreui/semaphore/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetAndDeleteUserIdentities(t *testing.T) {
util.Config = &util.ConfigType{}
store := sql.CreateTestStore()
admin, err := store.CreateUser(db.UserWithPwd{
Pwd: "verystrongpassword1",
User: db.User{Username: "root", Name: "Root", Email: "[email protected]", Admin: true},
})
require.NoError(t, err)
target, err := store.CreateUserWithoutPassword(db.User{
Username: "jdoe", Name: "John Doe", Email: "[email protected]", External: true,
})
require.NoError(t, err)
_, err = store.CreateExternalIdentity(db.UserExternalIdentity{
UserID: target.ID, Provider: "ldap", ExternalUID: "cn=jdoe,dc=x",
})
require.NoError(t, err)
// GET list
r := httptest.NewRequest(http.MethodGet, "/api/users/2/identities", nil)
r = helpers.SetContextValue(r, "store", store)
r = helpers.SetContextValue(r, "user", &admin)
r = helpers.SetContextValue(r, "_user", target) // GetUserMiddleware sets "_user" as db.User value (api/users.go:169)
w := httptest.NewRecorder()
usersController := NewUsersController(nil)
usersController.GetUserIdentities(w, r)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "cn=jdoe,dc=x")
// DELETE unlink
r = httptest.NewRequest(http.MethodDelete, "/api/users/2/identities/ldap", nil)
r = helpers.SetContextValue(r, "store", store)
r = helpers.SetContextValue(r, "user", &admin)
r = helpers.SetContextValue(r, "_user", target)
r = muxSetVars(r, map[string]string{"provider": "ldap"}) // helper: mux.SetURLVars
w = httptest.NewRecorder()
usersController.DeleteUserIdentity(w, r)
assert.Equal(t, http.StatusNoContent, w.Code)
ids, err := store.GetUserExternalIdentities(target.ID)
require.NoError(t, err)
assert.Empty(t, ids)
}
Context keys verified: GetUserMiddleware (api/users.go:143-172) puts the target as "_user" (db.User value) and enforces admin-or-self itself; the editor is "user" (*db.User). muxSetVars is mux.SetURLVars from gorilla/mux.
Run: go test ./api/ -run TestGetAndDeleteUserIdentities -v -count=1
Expected: compile error (GetUserIdentities undefined)
In api/users.go, following the style of the neighboring handlers (same middleware-provided target user, same admin guard as UpdateUser):
func (c *UsersController) GetUserIdentities(w http.ResponseWriter, r *http.Request) {
targetUser := helpers.GetFromContext(r, "_user").(db.User)
identities, err := helpers.Store(r).GetUserExternalIdentities(targetUser.ID)
if err != nil {
helpers.WriteErrorStatus(w, "Failed to get identities", http.StatusInternalServerError)
return
}
helpers.WriteJSON(w, http.StatusOK, identities)
}
func (c *UsersController) DeleteUserIdentity(w http.ResponseWriter, r *http.Request) {
targetUser := helpers.GetFromContext(r, "_user").(db.User)
provider := mux.Vars(r)["provider"]
if err := helpers.Store(r).DeleteExternalIdentity(targetUser.ID, provider); err != nil {
helpers.WriteErrorStatus(w, "Failed to delete identity", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
In api/router.go, next to the userPasswordAPI block (line 272, which already uses GetUserMiddleware):
userPasswordAPI.Path("/identities").HandlerFunc(usersController.GetUserIdentities).Methods("GET", "HEAD")
userPasswordAPI.Path("/identities/{provider}").HandlerFunc(usersController.DeleteUserIdentity).Methods("DELETE")
GetUserMiddleware enforces admin-or-self (api/users.go:160), so a user may also view/unlink their own identities — intended. If product decision changes to admin-only, add an editor.Admin check in the handlers like UpdateUser does for role changes.
Run: go test ./api/ -run TestGetAndDeleteUserIdentities -v -count=1
Expected: PASS
Run: go test ./api/... ./db/... ./util/... -count=1
Expected: PASS
git add api/users.go api/router.go api/users_identity_test.go
git commit -m "feat(auth): admin API to list and unlink external identities"
auto mode handles it lazily and self-closes.go build ./... and go test ./... -count=1 green (including pro_impl if checked out).External=true and no identity rows, log in via LDAP with matching email → adopted, identity created; repeat with external_auth_email_matching: never → login fails (no silent merge).