oidc groups: fix post-merge compile and migration issues
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.
This commit is contained in:
parent
2c8640f822
commit
32ea1c1c84
@ -215,6 +215,27 @@ AND auth_key_id NOT IN (
|
|||||||
},
|
},
|
||||||
Rollback: func(db *gorm.DB) error { return nil },
|
Rollback: func(db *gorm.DB) error { return nil },
|
||||||
},
|
},
|
||||||
|
// Add groups column to users table for OIDC role mapping.
|
||||||
|
// Must run before any migration that loads users via the User struct
|
||||||
|
// (e.g., 202505141324), since the User struct now includes Groups.
|
||||||
|
{
|
||||||
|
ID: "202505141323",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
if !tx.Migrator().HasColumn(&types.User{}, "groups") {
|
||||||
|
err := tx.Migrator().AddColumn(&types.User{}, "groups")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("adding groups column to users table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Rollback: func(db *gorm.DB) error {
|
||||||
|
if db.Migrator().HasColumn(&types.User{}, "groups") {
|
||||||
|
return db.Migrator().DropColumn(&types.User{}, "groups")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
// Fix the provider identifier for users that have a double slash in the
|
// Fix the provider identifier for users that have a double slash in the
|
||||||
// provider identifier.
|
// provider identifier.
|
||||||
{
|
{
|
||||||
@ -315,6 +336,7 @@ AND auth_key_id NOT IN (
|
|||||||
provider_identifier text,
|
provider_identifier text,
|
||||||
provider text,
|
provider text,
|
||||||
profile_pic_url text,
|
profile_pic_url text,
|
||||||
|
groups text,
|
||||||
created_at datetime,
|
created_at datetime,
|
||||||
updated_at datetime,
|
updated_at datetime,
|
||||||
deleted_at datetime
|
deleted_at datetime
|
||||||
@ -381,8 +403,8 @@ AND auth_key_id NOT IN (
|
|||||||
|
|
||||||
// Copy data directly using SQL
|
// Copy data directly using SQL
|
||||||
dataCopySQL := []string{
|
dataCopySQL := []string{
|
||||||
`INSERT INTO users (id, name, display_name, email, provider_identifier, provider, profile_pic_url, created_at, updated_at, deleted_at)
|
`INSERT INTO users (id, name, display_name, email, provider_identifier, provider, profile_pic_url, groups, created_at, updated_at, deleted_at)
|
||||||
SELECT id, name, display_name, email, provider_identifier, provider, profile_pic_url, created_at, updated_at, deleted_at
|
SELECT id, name, display_name, email, provider_identifier, provider, profile_pic_url, groups, created_at, updated_at, deleted_at
|
||||||
FROM users_old`,
|
FROM users_old`,
|
||||||
|
|
||||||
`INSERT INTO pre_auth_keys (id, key, user_id, reusable, ephemeral, used, tags, expiration, created_at)
|
`INSERT INTO pre_auth_keys (id, key, user_id, reusable, ephemeral, used, tags, expiration, created_at)
|
||||||
@ -447,28 +469,6 @@ AND auth_key_id NOT IN (
|
|||||||
},
|
},
|
||||||
Rollback: func(db *gorm.DB) error { return nil },
|
Rollback: func(db *gorm.DB) error { return nil },
|
||||||
},
|
},
|
||||||
// Add Groups column to users table for OIDC role-based access control
|
|
||||||
{
|
|
||||||
ID: "202509161200",
|
|
||||||
Migrate: func(tx *gorm.DB) error {
|
|
||||||
// Add Groups column to store OIDC group memberships as JSON
|
|
||||||
if !tx.Migrator().HasColumn(&types.User{}, "groups") {
|
|
||||||
err := tx.Migrator().AddColumn(&types.User{}, "groups")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("adding groups column to users table: %w", err)
|
|
||||||
}
|
|
||||||
log.Info().Msg("Added Groups column to users table for OIDC role mapping")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
Rollback: func(db *gorm.DB) error {
|
|
||||||
// Remove Groups column on rollback
|
|
||||||
if tx.Migrator().HasColumn(&types.User{}, "groups") {
|
|
||||||
return tx.Migrator().DropColumn(&types.User{}, "groups")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// v0.27.1
|
// v0.27.1
|
||||||
{
|
{
|
||||||
// Drop all tables that are no longer in use and has existed.
|
// Drop all tables that are no longer in use and has existed.
|
||||||
@ -1000,6 +1000,7 @@ func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormig
|
|||||||
"202502131714",
|
"202502131714",
|
||||||
"202502171819",
|
"202502171819",
|
||||||
"202505091439",
|
"202505091439",
|
||||||
|
"202505141323",
|
||||||
"202505141324",
|
"202505141324",
|
||||||
|
|
||||||
// As of 2025-07-02, no new IDs should be added here.
|
// As of 2025-07-02, no new IDs should be added here.
|
||||||
|
|||||||
@ -12,6 +12,7 @@ CREATE TABLE users(
|
|||||||
provider_identifier text,
|
provider_identifier text,
|
||||||
provider text,
|
provider text,
|
||||||
profile_pic_url text,
|
profile_pic_url text,
|
||||||
|
groups text,
|
||||||
|
|
||||||
created_at datetime,
|
created_at datetime,
|
||||||
updated_at datetime,
|
updated_at datetime,
|
||||||
|
|||||||
@ -35,6 +35,7 @@ var _UserCloneNeedsRegeneration = User(struct {
|
|||||||
ProviderIdentifier sql.NullString
|
ProviderIdentifier sql.NullString
|
||||||
Provider string
|
Provider string
|
||||||
ProfilePicURL string
|
ProfilePicURL string
|
||||||
|
Groups string
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
// Clone makes a deep copy of Node.
|
// Clone makes a deep copy of Node.
|
||||||
|
|||||||
@ -124,8 +124,11 @@ var _UserViewNeedsRegeneration = User(struct {
|
|||||||
ProviderIdentifier sql.NullString
|
ProviderIdentifier sql.NullString
|
||||||
Provider string
|
Provider string
|
||||||
ProfilePicURL string
|
ProfilePicURL string
|
||||||
|
Groups string
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
|
func (v UserView) Groups() string { return v.ж.Groups }
|
||||||
|
|
||||||
// View returns a read-only view of Node.
|
// View returns a read-only view of Node.
|
||||||
func (p *Node) View() NodeView {
|
func (p *Node) View() NodeView {
|
||||||
return NodeView{ж: p}
|
return NodeView{ж: p}
|
||||||
|
|||||||
@ -528,6 +528,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
|||||||
Valid: true,
|
Valid: true,
|
||||||
},
|
},
|
||||||
ProfilePicURL: "https://cdn.casbin.org/img/casbin.svg",
|
ProfilePicURL: "https://cdn.casbin.org/img/casbin.svg",
|
||||||
|
Groups: `["org1/department1","org1/department2"]`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,52 +2,47 @@ package integration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
|
||||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
|
||||||
"github.com/juanfont/headscale/integration/hsic"
|
"github.com/juanfont/headscale/integration/hsic"
|
||||||
"github.com/juanfont/headscale/integration/tsic"
|
|
||||||
"github.com/oauth2-proxy/mockoidc"
|
"github.com/oauth2-proxy/mockoidc"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestOIDCGroupsExtraction tests that OIDC groups are properly extracted and stored
|
// TestOIDCGroupsPersisted verifies that the `groups` claim from an OIDC
|
||||||
func TestOIDCGroupsExtraction(t *testing.T) {
|
// 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)
|
IntegrationSkip(t)
|
||||||
|
|
||||||
// Create mock users with different group memberships
|
// mockoidc serves logins in strict queue order, so keep NodesPerUser=1.
|
||||||
spec := ScenarioSpec{
|
spec := ScenarioSpec{
|
||||||
NodesPerUser: 1,
|
NodesPerUser: 1,
|
||||||
Users: []string{"admin", "user", "readonly"},
|
Users: []string{"admin", "dev", "solo"},
|
||||||
OIDCUsers: []mockoidc.MockUser{
|
OIDCUsers: []mockoidc.MockUser{
|
||||||
// Admin user with multiple groups
|
oidcMockUserWithGroups("admin", true, []string{"admins", "engineering"}),
|
||||||
{
|
oidcMockUserWithGroups("dev", true, []string{"engineering"}),
|
||||||
Subject: "admin@example.com",
|
// User with empty groups — must round-trip as no Groups stored.
|
||||||
Email: "admin@example.com",
|
oidcMockUserWithGroups("solo", true, nil),
|
||||||
PreferredUsername: "admin",
|
|
||||||
Groups: []string{"admins", "users", "engineering"},
|
|
||||||
},
|
|
||||||
// Regular user with single group
|
|
||||||
{
|
|
||||||
Subject: "user@example.com",
|
|
||||||
Email: "user@example.com",
|
|
||||||
PreferredUsername: "user",
|
|
||||||
Groups: []string{"users"},
|
|
||||||
},
|
|
||||||
// Readonly user with different groups
|
|
||||||
{
|
|
||||||
Subject: "readonly@example.com",
|
|
||||||
Email: "readonly@example.com",
|
|
||||||
PreferredUsername: "readonly",
|
|
||||||
Groups: []string{"readonly", "auditors"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
scenario, err := NewScenario(spec)
|
scenario, err := NewScenario(spec)
|
||||||
assertNoErr(t, err)
|
require.NoError(t, err)
|
||||||
defer scenario.ShutdownAssertNoPanics(t)
|
defer scenario.ShutdownAssertNoPanics(t)
|
||||||
|
|
||||||
oidcMap := map[string]string{
|
oidcMap := map[string]string{
|
||||||
@ -55,310 +50,87 @@ func TestOIDCGroupsExtraction(t *testing.T) {
|
|||||||
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
||||||
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
||||||
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
||||||
// Enable groups claim extraction
|
// Make sure the OIDC scope set includes "groups" so the IdP emits the claim.
|
||||||
"HEADSCALE_OIDC_EXTRA_PARAMS": `{"groups_claim": "groups"}`,
|
"HEADSCALE_OIDC_SCOPE": "openid,profile,email,groups",
|
||||||
}
|
}
|
||||||
|
|
||||||
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
||||||
nil,
|
nil,
|
||||||
hsic.WithTestName("oidcgroups"),
|
hsic.WithTestName("oidcgroups"),
|
||||||
hsic.WithConfigEnv(oidcMap),
|
hsic.WithConfigEnv(oidcMap),
|
||||||
hsic.WithTLS(),
|
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
|
||||||
hsic.WithHostnameAsServerURL(),
|
|
||||||
)
|
)
|
||||||
assertNoErr(t, err)
|
requireNoErrHeadscaleEnv(t, err)
|
||||||
|
|
||||||
// Perform OIDC logins for all users
|
// Drive the OIDC login flow for every client.
|
||||||
allClients, err := scenario.ListTailscaleClients()
|
_, err = scenario.ListTailscaleClients()
|
||||||
assertNoErr(t, err)
|
requireNoErrListClients(t, err)
|
||||||
|
err = scenario.WaitForTailscaleSync()
|
||||||
|
requireNoErrSync(t, err)
|
||||||
|
|
||||||
for _, client := range allClients {
|
headscale, err := scenario.Headscale()
|
||||||
user, ok := scenario.usernames[client.Hostname()]
|
require.NoError(t, err)
|
||||||
assertOK(t, ok)
|
|
||||||
|
|
||||||
_ = client.Login(scenario.loginWaitGroup, user)
|
// Query the SQLite database inside the headscale container for the groups
|
||||||
|
// column. CLI/gRPC do not expose it yet; this is the authoritative store.
|
||||||
// Wait for login to complete
|
const dbPath = "/tmp/integration_test_db.sqlite3"
|
||||||
scenario.loginWaitGroup.Wait()
|
out, err := headscale.Execute([]string{
|
||||||
time.Sleep(5 * time.Second)
|
"sqlite3", dbPath,
|
||||||
}
|
"-cmd", ".mode tabs",
|
||||||
|
"SELECT name, COALESCE(groups, '') FROM users WHERE provider = 'oidc' ORDER BY name;",
|
||||||
// Test groups were extracted correctly
|
|
||||||
t.Run("verify-groups-extracted", func(t *testing.T) {
|
|
||||||
// Get all users from Headscale
|
|
||||||
users, err := scenario.ControlServer().ListUsers(&v1.ListUsersRequest{})
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
// Verify each user has correct groups
|
|
||||||
expectedGroups := map[string][]string{
|
|
||||||
"admin": {"admins", "users", "engineering"},
|
|
||||||
"user": {"users"},
|
|
||||||
"readonly": {"readonly", "auditors"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, user := range users.GetUsers() {
|
|
||||||
// Parse groups from user
|
|
||||||
var userGroups []string
|
|
||||||
if user.GetGroups() != "" {
|
|
||||||
err := json.Unmarshal([]byte(user.GetGroups()), &userGroups)
|
|
||||||
assertNoErr(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check expected groups
|
|
||||||
expected, exists := expectedGroups[user.GetName()]
|
|
||||||
assert.True(t, exists, "Unexpected user: %s", user.GetName())
|
|
||||||
|
|
||||||
// Sort both slices for comparison
|
|
||||||
assert.ElementsMatch(t, expected, userGroups,
|
|
||||||
"User %s has incorrect groups. Expected: %v, Got: %v",
|
|
||||||
user.GetName(), expected, userGroups)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
require.NoError(t, err, "querying users.groups from sqlite")
|
||||||
|
|
||||||
// Test groups persist across logins
|
got := parseGroupsRows(t, out)
|
||||||
t.Run("verify-groups-persistence", func(t *testing.T) {
|
|
||||||
// Get a client and log it out then back in
|
|
||||||
client := allClients[0]
|
|
||||||
user := scenario.usernames[client.Hostname()]
|
|
||||||
|
|
||||||
// Logout
|
want := map[string][]string{
|
||||||
err := client.Logout()
|
"admin": {"admins", "engineering"},
|
||||||
assertNoErr(t, err)
|
"dev": {"engineering"},
|
||||||
|
"solo": nil,
|
||||||
|
}
|
||||||
|
|
||||||
// Login again
|
for name, wantGroups := range want {
|
||||||
_ = client.Login(scenario.loginWaitGroup, user)
|
gotGroups, ok := got[name]
|
||||||
scenario.loginWaitGroup.Wait()
|
assert.True(t, ok, "user %q not present in users table", name)
|
||||||
time.Sleep(3 * time.Second)
|
assert.ElementsMatch(t, wantGroups, gotGroups,
|
||||||
|
"groups mismatch for user %q (raw rows: %q)", name, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Verify groups are still there
|
// parseGroupsRows parses the tab-separated output of:
|
||||||
users, err := scenario.ControlServer().ListUsers(&v1.ListUsersRequest{})
|
//
|
||||||
assertNoErr(t, err)
|
// SELECT name, COALESCE(groups, '') FROM users ...
|
||||||
|
//
|
||||||
// Find our user
|
// Returns a map of username -> decoded groups slice. An empty groups column
|
||||||
var targetUser *v1.User
|
// (stored as "" by SetGroups when the input slice is empty) decodes to nil.
|
||||||
for _, u := range users.GetUsers() {
|
func parseGroupsRows(t *testing.T, raw string) map[string][]string {
|
||||||
if u.GetName() == user {
|
t.Helper()
|
||||||
targetUser = u
|
rows := map[string][]string{}
|
||||||
break
|
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
|
||||||
}
|
if line == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
assert.NotNil(t, targetUser, "User not found after re-login")
|
parts := strings.SplitN(line, "\t", 2)
|
||||||
|
require.Len(t, parts, 2, "unexpected sqlite row format: %q", line)
|
||||||
|
name, groupsJSON := parts[0], parts[1]
|
||||||
|
|
||||||
// Verify groups are preserved
|
if groupsJSON == "" {
|
||||||
var userGroups []string
|
rows[name] = nil
|
||||||
if targetUser.GetGroups() != "" {
|
continue
|
||||||
err := json.Unmarshal([]byte(targetUser.GetGroups()), &userGroups)
|
|
||||||
assertNoErr(t, err)
|
|
||||||
}
|
}
|
||||||
|
var gs []string
|
||||||
assert.NotEmpty(t, userGroups, "Groups should persist after re-login")
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOIDCGroupsWithoutClaim tests behavior when groups claim is not configured
|
// oidcMockUserWithGroups extends [oidcMockUser] with a Groups claim.
|
||||||
func TestOIDCGroupsWithoutClaim(t *testing.T) {
|
// mockoidc populates the id_token / userinfo from this struct verbatim.
|
||||||
IntegrationSkip(t)
|
func oidcMockUserWithGroups(username string, emailVerified bool, groups []string) mockoidc.MockUser {
|
||||||
|
u := oidcMockUser(username, emailVerified)
|
||||||
spec := ScenarioSpec{
|
u.Groups = groups
|
||||||
NodesPerUser: 1,
|
return u
|
||||||
Users: []string{"user1"},
|
|
||||||
OIDCUsers: []mockoidc.MockUser{
|
|
||||||
{
|
|
||||||
Subject: "user1@example.com",
|
|
||||||
Email: "user1@example.com",
|
|
||||||
PreferredUsername: "user1",
|
|
||||||
Groups: []string{"admins", "users"}, // Groups present but not requested
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
scenario, err := NewScenario(spec)
|
|
||||||
assertNoErr(t, err)
|
|
||||||
defer scenario.ShutdownAssertNoPanics(t)
|
|
||||||
|
|
||||||
// OIDC config WITHOUT groups claim
|
|
||||||
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",
|
|
||||||
// No groups claim configured
|
|
||||||
}
|
|
||||||
|
|
||||||
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
||||||
nil,
|
|
||||||
hsic.WithTestName("oidcnogroups"),
|
|
||||||
hsic.WithConfigEnv(oidcMap),
|
|
||||||
hsic.WithTLS(),
|
|
||||||
hsic.WithHostnameAsServerURL(),
|
|
||||||
)
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
// Login user
|
|
||||||
allClients, err := scenario.ListTailscaleClients()
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
client := allClients[0]
|
|
||||||
user := scenario.usernames[client.Hostname()]
|
|
||||||
_ = client.Login(scenario.loginWaitGroup, user)
|
|
||||||
scenario.loginWaitGroup.Wait()
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
|
|
||||||
// Verify user exists but has no groups
|
|
||||||
users, err := scenario.ControlServer().ListUsers(&v1.ListUsersRequest{})
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
assert.Len(t, users.GetUsers(), 1, "Should have exactly one user")
|
|
||||||
|
|
||||||
user1 := users.GetUsers()[0]
|
|
||||||
assert.Empty(t, user1.GetGroups(), "User should have no groups when claim not configured")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOIDCGroupsEmptyGroups tests behavior when user has no groups
|
|
||||||
func TestOIDCGroupsEmptyGroups(t *testing.T) {
|
|
||||||
IntegrationSkip(t)
|
|
||||||
|
|
||||||
spec := ScenarioSpec{
|
|
||||||
NodesPerUser: 1,
|
|
||||||
Users: []string{"ungrouped"},
|
|
||||||
OIDCUsers: []mockoidc.MockUser{
|
|
||||||
{
|
|
||||||
Subject: "ungrouped@example.com",
|
|
||||||
Email: "ungrouped@example.com",
|
|
||||||
PreferredUsername: "ungrouped",
|
|
||||||
Groups: []string{}, // User has no groups
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
scenario, err := NewScenario(spec)
|
|
||||||
assertNoErr(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",
|
|
||||||
"HEADSCALE_OIDC_EXTRA_PARAMS": `{"groups_claim": "groups"}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
||||||
nil,
|
|
||||||
hsic.WithTestName("oidcemptygroups"),
|
|
||||||
hsic.WithConfigEnv(oidcMap),
|
|
||||||
hsic.WithTLS(),
|
|
||||||
hsic.WithHostnameAsServerURL(),
|
|
||||||
)
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
// Login user with no groups
|
|
||||||
allClients, err := scenario.ListTailscaleClients()
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
client := allClients[0]
|
|
||||||
user := scenario.usernames[client.Hostname()]
|
|
||||||
_ = client.Login(scenario.loginWaitGroup, user)
|
|
||||||
scenario.loginWaitGroup.Wait()
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
|
|
||||||
// Verify user exists with empty groups
|
|
||||||
users, err := scenario.ControlServer().ListUsers(&v1.ListUsersRequest{})
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
assert.Len(t, users.GetUsers(), 1, "Should have exactly one user")
|
|
||||||
|
|
||||||
ungroupedUser := users.GetUsers()[0]
|
|
||||||
assert.Empty(t, ungroupedUser.GetGroups(), "User with no groups should have empty groups field")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOIDCGroupsUpdatesOnLogin tests that groups are updated when user logs in again
|
|
||||||
func TestOIDCGroupsUpdatesOnLogin(t *testing.T) {
|
|
||||||
IntegrationSkip(t)
|
|
||||||
|
|
||||||
// Create scenario with user having initial groups
|
|
||||||
spec := ScenarioSpec{
|
|
||||||
NodesPerUser: 1,
|
|
||||||
Users: []string{"dynamic"},
|
|
||||||
OIDCUsers: []mockoidc.MockUser{
|
|
||||||
{
|
|
||||||
Subject: "dynamic@example.com",
|
|
||||||
Email: "dynamic@example.com",
|
|
||||||
PreferredUsername: "dynamic",
|
|
||||||
Groups: []string{"initial-group"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
scenario, err := NewScenario(spec)
|
|
||||||
assertNoErr(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",
|
|
||||||
"HEADSCALE_OIDC_EXTRA_PARAMS": `{"groups_claim": "groups"}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
||||||
nil,
|
|
||||||
hsic.WithTestName("oidcdynamicgroups"),
|
|
||||||
hsic.WithConfigEnv(oidcMap),
|
|
||||||
hsic.WithTLS(),
|
|
||||||
hsic.WithHostnameAsServerURL(),
|
|
||||||
)
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
// Initial login
|
|
||||||
allClients, err := scenario.ListTailscaleClients()
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
client := allClients[0]
|
|
||||||
user := scenario.usernames[client.Hostname()]
|
|
||||||
_ = client.Login(scenario.loginWaitGroup, user)
|
|
||||||
scenario.loginWaitGroup.Wait()
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
|
|
||||||
// Verify initial groups
|
|
||||||
users, err := scenario.ControlServer().ListUsers(&v1.ListUsersRequest{})
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
userObj := users.GetUsers()[0]
|
|
||||||
var initialGroups []string
|
|
||||||
if userObj.GetGroups() != "" {
|
|
||||||
err := json.Unmarshal([]byte(userObj.GetGroups()), &initialGroups)
|
|
||||||
assertNoErr(t, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, []string{"initial-group"}, initialGroups)
|
|
||||||
|
|
||||||
// Update the mock user to have different groups
|
|
||||||
// Note: In a real test, this would involve updating the OIDC provider
|
|
||||||
// For this test, we'll simulate the scenario by modifying the mock
|
|
||||||
|
|
||||||
// Logout and login again (simulating groups change in OIDC provider)
|
|
||||||
err = client.Logout()
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
// Update mock user groups (this is test-specific, not production code)
|
|
||||||
scenario.mockOIDC.SetUserGroups("dynamic@example.com", []string{"updated-group", "admin-group"})
|
|
||||||
|
|
||||||
_ = client.Login(scenario.loginWaitGroup, user)
|
|
||||||
scenario.loginWaitGroup.Wait()
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
|
|
||||||
// Verify groups were updated
|
|
||||||
users, err = scenario.ControlServer().ListUsers(&v1.ListUsersRequest{})
|
|
||||||
assertNoErr(t, err)
|
|
||||||
|
|
||||||
updatedUserObj := users.GetUsers()[0]
|
|
||||||
var updatedGroups []string
|
|
||||||
if updatedUserObj.GetGroups() != "" {
|
|
||||||
err := json.Unmarshal([]byte(updatedUserObj.GetGroups()), &updatedGroups)
|
|
||||||
assertNoErr(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, []string{"updated-group", "admin-group"}, updatedGroups,
|
|
||||||
"Groups should be updated on subsequent login")
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user