headscale/integration/oidc_groups_test.go
Ryan Malloy 9ca200b6bc
Some checks failed
Build / build-nix (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Has been cancelled
Check Generated Files / check-generated (push) Has been cancelled
Build (main) / container (push) Has been cancelled
Build (main) / binaries (amd64, darwin) (push) Has been cancelled
Build (main) / binaries (amd64, linux) (push) Has been cancelled
Build (main) / binaries (arm64, darwin) (push) Has been cancelled
Build (main) / binaries (arm64, linux) (push) Has been cancelled
NixOS Module Tests / nix-module-check (push) Has been cancelled
Tests / test (push) Has been cancelled
update-flake-lock / lockfile (push) Has been cancelled
GitHub Actions Version Updater / build (push) Has been cancelled
oidc groups: expose Groups through the gRPC API
The Groups column is already persisted on users.User (migration
202505141323) and populated from claims.Groups in FromClaim. This
makes the value visible through the gRPC/REST surface so external
tools (notably Headplane, which is the motivation for storing the
claim in the first place) can read group membership without
poking at the database.

- proto/headscale/v1/user.proto: add `repeated string groups = 9;`
  with a doc comment describing where the value comes from.
- gen/go/headscale/v1/user.pb.go,
  gen/openapiv2/headscale/v1/headscale.swagger.json: regenerated
  via `buf generate --template ../buf.gen.yaml -o .. ../proto`.
- hscontrol/types/users.go: populate v1.User.Groups in Proto() by
  decoding the JSON-encoded users.groups column via GetGroups().
- integration/oidc_groups_test.go: drop the sqlite3-via-Execute hack
  and verify groups through headscale.ListUsers() like every other
  user-state integration test.
2026-05-21 21:29:54 -06:00

114 lines
3.8 KiB
Go

package integration
import (
"sort"
"testing"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"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 on the user and exposed through the gRPC API.
//
// Implementation under test:
// - users.groups TEXT column added by migration 202505141323.
// - hscontrol/types.User.SetGroups / GetGroups round-trip via JSON.
// - 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 an array also work.
// - v1.User.Groups (proto field 9) is populated by User.Proto() so
// external tools (Headplane) can read group membership over gRPC.
func TestOIDCGroupsPersisted(t *testing.T) {
IntegrationSkip(t)
// mockoidc serves logins from a strict queue, 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 — round-trips as no Groups.
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 mockoidc 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)
users, err := headscale.ListUsers()
require.NoError(t, err)
got := groupsByOIDCUserName(users)
want := map[string][]string{
"admin": {"admins", "engineering"},
"dev": {"engineering"},
"solo": nil,
}
for name, wantGroups := range want {
gotGroups, ok := got[name]
assert.True(t, ok, "OIDC user %q not present in ListUsers response", name)
assert.ElementsMatch(t, wantGroups, gotGroups, "groups mismatch for user %q", name)
}
}
// groupsByOIDCUserName picks out OIDC users from a ListUsers response and
// returns a map of username -> sorted groups. CLI-created users (no provider)
// are filtered out so the assertions don't need to know about them.
func groupsByOIDCUserName(users []*v1.User) map[string][]string {
out := map[string][]string{}
for _, u := range users {
if u.GetProvider() != "oidc" {
continue
}
g := append([]string(nil), u.GetGroups()...)
sort.Strings(g)
if len(g) == 0 {
g = nil
}
out[u.GetName()] = g
}
return out
}
// 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
}