Bugs found by running the real test suite after merging upstream: - types/types_clone.go, types/types_view.go: extend the regeneration guard struct literals to include the new Groups field, and add a UserView.Groups() accessor. Generated files normally rebuilt via cloner / viewer; touched by hand here pending make generate. - db/db.go: the migration adding the groups column ran after 202505141324, which calls ListUsers() through the User struct that now includes Groups. Move the column-add to 202505141323 so the schema is in place before any migration loads users. Register the new ID in the FK-disabled migration list. - db/db.go: 202507021200 recreates all tables from inline SQL during the SQLite schema migration; add groups to both the CREATE TABLE users statement and the INSERT INTO users ... SELECT FROM users_old so the column survives the recreation. Also fix a copy-paste bug in the Rollback closure that referenced tx instead of db. - db/schema.sql: add the groups column to the canonical schema so squibble.Validate accepts databases produced by the new migration chain. Verified against all 7 historical sqlite dumps in hscontrol/db/testdata/sqlite. - types/users_test.go: the casby-oidc-claim case now exercises group storage; update the want to include the JSON-encoded groups column. - integration/oidc_groups_test.go: replace the aspirational draft (which referenced assertNoErr, scenario.usernames, hsic.WithTLS and other symbols that do not exist) with a focused test that follows the auth_oidc_test.go pattern. Verifies the groups column directly via sqlite3 inside the headscale container since the gRPC User message does not expose Groups.
137 lines
4.6 KiB
Go
137 lines
4.6 KiB
Go
package integration
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/juanfont/headscale/integration/hsic"
|
|
"github.com/oauth2-proxy/mockoidc"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// 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) {
|
|
IntegrationSkip(t)
|
|
|
|
// mockoidc serves logins in strict queue order, so keep NodesPerUser=1.
|
|
spec := ScenarioSpec{
|
|
NodesPerUser: 1,
|
|
Users: []string{"admin", "dev", "solo"},
|
|
OIDCUsers: []mockoidc.MockUser{
|
|
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),
|
|
},
|
|
}
|
|
|
|
scenario, err := NewScenario(spec)
|
|
require.NoError(t, err)
|
|
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",
|
|
// Make sure the OIDC scope set includes "groups" so the IdP emits the claim.
|
|
"HEADSCALE_OIDC_SCOPE": "openid,profile,email,groups",
|
|
}
|
|
|
|
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
nil,
|
|
hsic.WithTestName("oidcgroups"),
|
|
hsic.WithConfigEnv(oidcMap),
|
|
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
|
|
)
|
|
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;",
|
|
})
|
|
require.NoError(t, err, "querying users.groups from sqlite")
|
|
|
|
got := parseGroupsRows(t, out)
|
|
|
|
want := map[string][]string{
|
|
"admin": {"admins", "engineering"},
|
|
"dev": {"engineering"},
|
|
"solo": nil,
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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]
|
|
|
|
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
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// 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
|
|
}
|