diff --git a/gen/go/headscale/v1/user.pb.go b/gen/go/headscale/v1/user.pb.go index 5f05d084..c1268e26 100644 --- a/gen/go/headscale/v1/user.pb.go +++ b/gen/go/headscale/v1/user.pb.go @@ -32,6 +32,10 @@ type User struct { ProviderId string `protobuf:"bytes,6,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` Provider string `protobuf:"bytes,7,opt,name=provider,proto3" json:"provider,omitempty"` ProfilePicUrl string `protobuf:"bytes,8,opt,name=profile_pic_url,json=profilePicUrl,proto3" json:"profile_pic_url,omitempty"` + // OIDC group memberships extracted from the identity provider's + // `groups` claim at login. Populated by hscontrol/types.User.FromClaim. + // External tools (Headplane, automation) use this for role-based access. + Groups []string `protobuf:"bytes,9,rep,name=groups,proto3" json:"groups,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -122,6 +126,13 @@ func (x *User) GetProfilePicUrl() string { return "" } +func (x *User) GetGroups() []string { + if x != nil { + return x.Groups + } + return nil +} + type CreateUserRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -518,7 +529,7 @@ var File_headscale_v1_user_proto protoreflect.FileDescriptor const file_headscale_v1_user_proto_rawDesc = "" + "\n" + - "\x17headscale/v1/user.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x83\x02\n" + + "\x17headscale/v1/user.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x9b\x02\n" + "\x04User\x12\x0e\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x129\n" + @@ -529,7 +540,8 @@ const file_headscale_v1_user_proto_rawDesc = "" + "\vprovider_id\x18\x06 \x01(\tR\n" + "providerId\x12\x1a\n" + "\bprovider\x18\a \x01(\tR\bprovider\x12&\n" + - "\x0fprofile_pic_url\x18\b \x01(\tR\rprofilePicUrl\"\x81\x01\n" + + "\x0fprofile_pic_url\x18\b \x01(\tR\rprofilePicUrl\x12\x16\n" + + "\x06groups\x18\t \x03(\tR\x06groups\"\x81\x01\n" + "\x11CreateUserRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x14\n" + diff --git a/gen/openapiv2/headscale/v1/headscale.swagger.json b/gen/openapiv2/headscale/v1/headscale.swagger.json index 545cf0b5..99480845 100644 --- a/gen/openapiv2/headscale/v1/headscale.swagger.json +++ b/gen/openapiv2/headscale/v1/headscale.swagger.json @@ -1528,6 +1528,13 @@ }, "profilePicUrl": { "type": "string" + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "description": "OIDC group memberships extracted from the identity provider's\n`groups` claim at login. Populated by hscontrol/types.User.FromClaim.\nExternal tools (Headplane, automation) use this for role-based access." } } } diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index f2ab85b1..b2455a67 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -243,6 +243,7 @@ func (u *User) Proto() *v1.User { ProviderId: u.ProviderIdentifier.String, Provider: u.Provider, ProfilePicUrl: u.ProfilePicURL, + Groups: u.GetGroups(), } } diff --git a/integration/oidc_groups_test.go b/integration/oidc_groups_test.go index af7e572f..1695dac8 100644 --- a/integration/oidc_groups_test.go +++ b/integration/oidc_groups_test.go @@ -1,11 +1,10 @@ package integration import ( - "encoding/json" "sort" - "strings" "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" @@ -13,30 +12,27 @@ import ( ) // TestOIDCGroupsPersisted verifies that the `groups` claim from an OIDC -// provider is persisted into the users.groups column after the user logs in. +// provider is persisted on the user and exposed through the gRPC API. // -// 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. +// 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 in strict queue order, so keep NodesPerUser=1. + // 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 — must round-trip as no Groups stored. + // User with empty groups — round-trips as no Groups. oidcMockUserWithGroups("solo", true, nil), }, } @@ -50,7 +46,7 @@ func TestOIDCGroupsPersisted(t *testing.T) { "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. + // Make sure the OIDC scope set includes "groups" so mockoidc emits the claim. "HEADSCALE_OIDC_SCOPE": "openid,profile,email,groups", } @@ -71,17 +67,10 @@ func TestOIDCGroupsPersisted(t *testing.T) { 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") + users, err := headscale.ListUsers() + require.NoError(t, err) - got := parseGroupsRows(t, out) + got := groupsByOIDCUserName(users) want := map[string][]string{ "admin": {"admins", "engineering"}, @@ -91,40 +80,28 @@ func TestOIDCGroupsPersisted(t *testing.T) { 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) + assert.True(t, ok, "OIDC user %q not present in ListUsers response", name) + assert.ElementsMatch(t, wantGroups, gotGroups, "groups mismatch for user %q", name) } } -// 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 == "" { +// 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 } - 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 + g := append([]string(nil), u.GetGroups()...) + sort.Strings(g) + if len(g) == 0 { + g = nil } - 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 + out[u.GetName()] = g } - return rows + return out } // oidcMockUserWithGroups extends [oidcMockUser] with a Groups claim. diff --git a/proto/headscale/v1/user.proto b/proto/headscale/v1/user.proto index bd71bcb1..a20587fe 100644 --- a/proto/headscale/v1/user.proto +++ b/proto/headscale/v1/user.proto @@ -13,6 +13,10 @@ message User { string provider_id = 6; string provider = 7; string profile_pic_url = 8; + // OIDC group memberships extracted from the identity provider's + // `groups` claim at login. Populated by hscontrol/types.User.FromClaim. + // External tools (Headplane, automation) use this for role-based access. + repeated string groups = 9; } message CreateUserRequest {