2026-05-21 17:55:31 -06:00
|
|
|
package integration
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-05-21 21:14:08 -06:00
|
|
|
"sort"
|
|
|
|
|
"strings"
|
2026-05-21 17:55:31 -06:00
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/juanfont/headscale/integration/hsic"
|
|
|
|
|
"github.com/oauth2-proxy/mockoidc"
|
|
|
|
|
"github.com/stretchr/testify/assert"
|
2026-05-21 21:14:08 -06:00
|
|
|
"github.com/stretchr/testify/require"
|
2026-05-21 17:55:31 -06:00
|
|
|
)
|
|
|
|
|
|
2026-05-21 21:14:08 -06:00
|
|
|
// TestOIDCGroupsPersisted verifies that the `groups` claim from an OIDC
|
|
|
|
|
// provider is persisted into the users.groups column after the user logs in.
|
|
|
|
|
//
|
|
|
|
|
// The implementation under test:
|
|
|
|
|
// - User.Groups column (TEXT, JSON-encoded []string) added by migration
|
|
|
|
|
// 202505141323 in hscontrol/db/db.go.
|
|
|
|
|
// - User.SetGroups / User.GetGroups in hscontrol/types/users.go.
|
|
|
|
|
// - FromClaim() calls SetGroups(claims.Groups) so login populates the column.
|
|
|
|
|
// - OIDCClaims.Groups is FlexibleStringSlice so providers like JumpCloud
|
|
|
|
|
// that return a single string instead of a one-element array also work.
|
|
|
|
|
//
|
|
|
|
|
// Verification is done by reading the SQLite database inside the headscale
|
|
|
|
|
// container directly, because the gRPC User message does not currently
|
|
|
|
|
// expose Groups. Adding groups to the gRPC API is a separate, larger change.
|
|
|
|
|
func TestOIDCGroupsPersisted(t *testing.T) {
|
2026-05-21 17:55:31 -06:00
|
|
|
IntegrationSkip(t)
|
|
|
|
|
|
2026-05-21 21:14:08 -06:00
|
|
|
// mockoidc serves logins in strict queue order, so keep NodesPerUser=1.
|
2026-05-21 17:55:31 -06:00
|
|
|
spec := ScenarioSpec{
|
|
|
|
|
NodesPerUser: 1,
|
2026-05-21 21:14:08 -06:00
|
|
|
Users: []string{"admin", "dev", "solo"},
|
2026-05-21 17:55:31 -06:00
|
|
|
OIDCUsers: []mockoidc.MockUser{
|
2026-05-21 21:14:08 -06:00
|
|
|
oidcMockUserWithGroups("admin", true, []string{"admins", "engineering"}),
|
|
|
|
|
oidcMockUserWithGroups("dev", true, []string{"engineering"}),
|
|
|
|
|
// User with empty groups — must round-trip as no Groups stored.
|
|
|
|
|
oidcMockUserWithGroups("solo", true, nil),
|
2026-05-21 17:55:31 -06:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scenario, err := NewScenario(spec)
|
2026-05-21 21:14:08 -06:00
|
|
|
require.NoError(t, err)
|
2026-05-21 17:55:31 -06:00
|
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
|
|
|
|
|
|
oidcMap := map[string]string{
|
|
|
|
|
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
|
|
|
|
|
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
|
|
|
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
|
|
|
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
2026-05-21 21:14:08 -06:00
|
|
|
// Make sure the OIDC scope set includes "groups" so the IdP emits the claim.
|
|
|
|
|
"HEADSCALE_OIDC_SCOPE": "openid,profile,email,groups",
|
2026-05-21 17:55:31 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
|
|
|
nil,
|
|
|
|
|
hsic.WithTestName("oidcgroups"),
|
|
|
|
|
hsic.WithConfigEnv(oidcMap),
|
2026-05-21 21:14:08 -06:00
|
|
|
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
|
2026-05-21 17:55:31 -06:00
|
|
|
)
|
2026-05-21 21:14:08 -06:00
|
|
|
requireNoErrHeadscaleEnv(t, err)
|
|
|
|
|
|
|
|
|
|
// Drive the OIDC login flow for every client.
|
|
|
|
|
_, err = scenario.ListTailscaleClients()
|
|
|
|
|
requireNoErrListClients(t, err)
|
|
|
|
|
err = scenario.WaitForTailscaleSync()
|
|
|
|
|
requireNoErrSync(t, err)
|
|
|
|
|
|
|
|
|
|
headscale, err := scenario.Headscale()
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
// Query the SQLite database inside the headscale container for the groups
|
|
|
|
|
// column. CLI/gRPC do not expose it yet; this is the authoritative store.
|
|
|
|
|
const dbPath = "/tmp/integration_test_db.sqlite3"
|
|
|
|
|
out, err := headscale.Execute([]string{
|
|
|
|
|
"sqlite3", dbPath,
|
|
|
|
|
"-cmd", ".mode tabs",
|
|
|
|
|
"SELECT name, COALESCE(groups, '') FROM users WHERE provider = 'oidc' ORDER BY name;",
|
2026-05-21 17:55:31 -06:00
|
|
|
})
|
2026-05-21 21:14:08 -06:00
|
|
|
require.NoError(t, err, "querying users.groups from sqlite")
|
2026-05-21 17:55:31 -06:00
|
|
|
|
2026-05-21 21:14:08 -06:00
|
|
|
got := parseGroupsRows(t, out)
|
2026-05-21 17:55:31 -06:00
|
|
|
|
2026-05-21 21:14:08 -06:00
|
|
|
want := map[string][]string{
|
|
|
|
|
"admin": {"admins", "engineering"},
|
|
|
|
|
"dev": {"engineering"},
|
|
|
|
|
"solo": nil,
|
2026-05-21 17:55:31 -06:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 21:14:08 -06:00
|
|
|
for name, wantGroups := range want {
|
|
|
|
|
gotGroups, ok := got[name]
|
|
|
|
|
assert.True(t, ok, "user %q not present in users table", name)
|
|
|
|
|
assert.ElementsMatch(t, wantGroups, gotGroups,
|
|
|
|
|
"groups mismatch for user %q (raw rows: %q)", name, out)
|
2026-05-21 17:55:31 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 21:14:08 -06:00
|
|
|
// parseGroupsRows parses the tab-separated output of:
|
|
|
|
|
//
|
|
|
|
|
// SELECT name, COALESCE(groups, '') FROM users ...
|
|
|
|
|
//
|
|
|
|
|
// Returns a map of username -> decoded groups slice. An empty groups column
|
|
|
|
|
// (stored as "" by SetGroups when the input slice is empty) decodes to nil.
|
|
|
|
|
func parseGroupsRows(t *testing.T, raw string) map[string][]string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
rows := map[string][]string{}
|
|
|
|
|
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
|
|
|
|
|
if line == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
parts := strings.SplitN(line, "\t", 2)
|
|
|
|
|
require.Len(t, parts, 2, "unexpected sqlite row format: %q", line)
|
|
|
|
|
name, groupsJSON := parts[0], parts[1]
|
2026-05-21 17:55:31 -06:00
|
|
|
|
2026-05-21 21:14:08 -06:00
|
|
|
if groupsJSON == "" {
|
|
|
|
|
rows[name] = nil
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
var gs []string
|
|
|
|
|
require.NoError(t, json.Unmarshal([]byte(groupsJSON), &gs),
|
|
|
|
|
"groups column for %q is not valid JSON: %q", name, groupsJSON)
|
|
|
|
|
sort.Strings(gs)
|
|
|
|
|
rows[name] = gs
|
2026-05-21 17:55:31 -06:00
|
|
|
}
|
2026-05-21 21:14:08 -06:00
|
|
|
return rows
|
2026-05-21 17:55:31 -06:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 21:14:08 -06:00
|
|
|
// oidcMockUserWithGroups extends [oidcMockUser] with a Groups claim.
|
|
|
|
|
// mockoidc populates the id_token / userinfo from this struct verbatim.
|
|
|
|
|
func oidcMockUserWithGroups(username string, emailVerified bool, groups []string) mockoidc.MockUser {
|
|
|
|
|
u := oidcMockUser(username, emailVerified)
|
|
|
|
|
u.Groups = groups
|
|
|
|
|
return u
|
|
|
|
|
}
|