oidc groups: store and expose the OIDC groups claim
Add a Groups column on the User model populated from the OIDC 'groups' claim at login, and surface it through the gRPC/REST User message so external tools (Headplane) can read group membership without reaching into the database. - proto: add 'repeated string groups = 9' to v1.User. - types.User: Groups text column, GetGroups/SetGroups JSON helpers, FromClaim populates from claims.Groups. The existing FlexibleStringSlice on OIDCClaims.Groups already handles JumpCloud-style single-string emission. - types.User.Proto(): populate v1.User.Groups via GetGroups(). - db migration 202505141323: add the column BEFORE the existing 202505141324 migration that loads users via the struct, so the schema is in place before any migration touches the User type. - db migration 202507021200: extend the inline CREATE TABLE users and INSERT INTO users ... SELECT FROM users_old to carry the new column through the SQLite schema-recreation step. - schema.sql: declare the column so squibble.Validate accepts databases produced by the new migration chain. Verified against all 7 historical sqlite dumps in hscontrol/db/testdata/sqlite. - types.UserView, types_clone.go: regenerated to expose Groups. - config-example.yaml, docs/ref/oidc.md: note the 'groups' scope and the role the column plays for external integrations. - integration/oidc_groups_test.go: verify the round-trip via headscale.ListUsers() for users with multi-group, single-group, and empty group memberships.
This commit is contained in:
parent
5228cb1a40
commit
209ba5c4ea
12 changed files with 266 additions and 7 deletions
113
integration/oidc_groups_test.go
Normal file
113
integration/oidc_groups_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue