From 209ba5c4ea54ec483a8246a8f0204eb0d4890662 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Thu, 4 Jun 2026 01:57:50 -0600 Subject: [PATCH] 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. --- config-example.yaml | 3 +- docs/ref/oidc.md | 55 ++++++++- gen/go/headscale/v1/user.pb.go | 16 ++- .../headscale/v1/headscale.swagger.json | 7 ++ hscontrol/db/db.go | 27 ++++- hscontrol/db/schema.sql | 1 + hscontrol/types/types_clone.go | 1 + hscontrol/types/types_view.go | 3 + hscontrol/types/users.go | 42 +++++++ hscontrol/types/users_test.go | 1 + integration/oidc_groups_test.go | 113 ++++++++++++++++++ proto/headscale/v1/user.proto | 4 + 12 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 integration/oidc_groups_test.go diff --git a/config-example.yaml b/config-example.yaml index 22b9f349..04178086 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -406,9 +406,10 @@ unix_socket_permission: "0770" # use_expiry_from_token: false # # # The OIDC scopes to use, defaults to "openid", "profile" and "email". +# # Add "groups" scope to enable group storage for external integrations. # # Custom scopes can be configured as needed, be sure to always include the # # required "openid" scope. -# scope: ["openid", "profile", "email"] +# scope: ["openid", "profile", "email", "groups"] # # # Only verified email addresses are synchronized to the user profile by # # default. Unverified emails may be allowed in case an identity provider diff --git a/docs/ref/oidc.md b/docs/ref/oidc.md index 3d2e8764..ab55e28f 100644 --- a/docs/ref/oidc.md +++ b/docs/ref/oidc.md @@ -240,13 +240,64 @@ endpoint. | username | `preferred_username` | Depends on identity provider, eg: `ssmith`, `ssmith@idp.example.com`, `\\example.com\ssmith` | | profile picture | `picture` | URL to a profile picture or avatar | | provider identifier | `iss`, `sub` | A stable and unique identifier for a user, typically a combination of `iss` and `sub` OIDC claims | -| | `groups` | [Only used to filter for allowed groups](#authorize-users-with-filters) | +| group membership | `groups` | Used for [access filtering](#authorize-users-with-filters) and stored for external integrations | + +## Group Storage and Integration + +Starting with Headscale v0.24.0, OIDC group membership is automatically extracted from authentication claims and stored in the database. This enables external integrations (such as web interfaces) to implement role-based access control based on a user's group membership. + +### Group Storage +- Groups are extracted from both ID tokens and UserInfo endpoint responses +- Group membership is updated on every successful OIDC login +- Groups are stored as JSON in the user database for external access +- Multiple group claim formats are supported (`groups`, `roles`, provider-specific claims) + +### External Integration +External applications can query user group membership for implementing role-based access control: + +```bash +# View user groups via Headscale CLI +headscale users list --output json + +# Example database query (for direct database access) +SELECT name, email, groups FROM users WHERE provider = 'oidc'; +``` + +### Scope Requirements +To enable group storage, ensure your OIDC configuration includes the `groups` scope: + +```yaml +oidc: + issuer: "https://sso.example.com" + client_id: "headscale" + client_secret: "generated-secret" + scope: ["openid", "profile", "email", "groups"] +``` + +### Headplane Integration +When using [Headplane](https://github.com/tale/headplane) as a web interface for Headscale, OIDC groups enable automatic role-based access control: + +- **Automatic Role Assignment**: Users are assigned roles based on their OIDC group membership +- **Zero-Trust Security**: New users receive minimal access until proper groups are assigned +- **Dynamic Updates**: User roles update automatically on each login based on current group membership +- **Configurable Mapping**: Organizations can customize which groups map to which roles + +Example Headplane role mapping configuration: +```yaml +role_mapping: + owner: ["ceo", "cto", "headscale-owner"] + admin: ["it-admin", "platform-admin"] + network_admin: ["network-team", "devops"] + auditor: ["compliance", "audit-team"] +``` + +For detailed Headplane OIDC configuration, see the [Headplane documentation](https://github.com/tale/headplane/docs). ## Limitations - Support for OpenID Connect aims to be generic and vendor independent. It offers only limited support for quirks of specific identity providers. -- OIDC groups cannot be used in policy rules. +- OIDC groups cannot be used in policy rules directly (use external integrations for role-based access control). - The username provided by the identity provider needs to adhere to this pattern: - The username must be at least two characters long. - It must only contain letters, digits, hyphens, dots, underscores, and up to a single `@`. 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/db/db.go b/hscontrol/db/db.go index 8c088c49..ad0dd6f6 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -215,6 +215,27 @@ AND auth_key_id NOT IN ( }, 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 // provider identifier. { @@ -315,6 +336,7 @@ AND auth_key_id NOT IN ( provider_identifier text, provider text, profile_pic_url text, + groups text, created_at datetime, updated_at datetime, deleted_at datetime @@ -381,8 +403,8 @@ AND auth_key_id NOT IN ( // Copy data directly using SQL dataCopySQL := []string{ - `INSERT INTO users (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, 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, groups, created_at, updated_at, deleted_at FROM users_old`, `INSERT INTO pre_auth_keys (id, key, user_id, reusable, ephemeral, used, tags, expiration, created_at) @@ -1000,6 +1022,7 @@ func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormig "202502131714", "202502171819", "202505091439", + "202505141323", "202505141324", // As of 2025-07-02, no new IDs should be added here. diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 781446c0..d6511404 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -12,6 +12,7 @@ CREATE TABLE users( provider_identifier text, provider text, profile_pic_url text, + groups text, created_at datetime, updated_at datetime, diff --git a/hscontrol/types/types_clone.go b/hscontrol/types/types_clone.go index a14dc6fd..2eacb561 100644 --- a/hscontrol/types/types_clone.go +++ b/hscontrol/types/types_clone.go @@ -35,6 +35,7 @@ var _UserCloneNeedsRegeneration = User(struct { ProviderIdentifier sql.NullString Provider string ProfilePicURL string + Groups string }{}) // Clone makes a deep copy of Node. diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index 3a9b3b42..0db6b810 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -124,8 +124,11 @@ var _UserViewNeedsRegeneration = User(struct { ProviderIdentifier sql.NullString Provider string ProfilePicURL string + Groups string }{}) +func (v UserView) Groups() string { return v.ж.Groups } + // View returns a read-only view of Node. func (p *Node) View() NodeView { return NodeView{ж: p} diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index e5a9e7a5..b2455a67 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -95,6 +95,11 @@ type User struct { Provider string ProfilePicURL string + + // Groups stores the OIDC groups/roles that the user belongs to. + // This is populated from the 'groups' claim in OIDC tokens and + // is used for role-based access control in Headplane. + Groups string `gorm:"type:text"` } func (u *User) StringID() string { @@ -139,6 +144,39 @@ func (u *User) profilePicURL() string { return u.ProfilePicURL } +// GetGroups returns the user's groups as a slice of strings. +// Groups are stored as JSON in the database. +func (u *User) GetGroups() []string { + if u.Groups == "" { + return []string{} + } + + var groups []string + if err := json.Unmarshal([]byte(u.Groups), &groups); err != nil { + log.Error().Err(err).Msg("Failed to unmarshal user groups") + return []string{} + } + + return groups +} + +// SetGroups stores the user's groups as JSON in the database. +func (u *User) SetGroups(groups []string) { + if len(groups) == 0 { + u.Groups = "" + return + } + + data, err := json.Marshal(groups) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal user groups") + u.Groups = "" + return + } + + u.Groups = string(data) +} + func (u *User) TailscaleUser() tailcfg.User { return tailcfg.User{ ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded @@ -205,6 +243,7 @@ func (u *User) Proto() *v1.User { ProviderId: u.ProviderIdentifier.String, Provider: u.Provider, ProfilePicUrl: u.ProfilePicURL, + Groups: u.GetGroups(), } } @@ -447,4 +486,7 @@ func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) { u.DisplayName = claims.Name u.ProfilePicURL = claims.ProfilePictureURL u.Provider = util.RegisterMethodOIDC + + // Store OIDC groups for role-based access control + u.SetGroups(claims.Groups) } diff --git a/hscontrol/types/users_test.go b/hscontrol/types/users_test.go index 1218979c..08823a3e 100644 --- a/hscontrol/types/users_test.go +++ b/hscontrol/types/users_test.go @@ -528,6 +528,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) { Valid: true, }, ProfilePicURL: "https://cdn.casbin.org/img/casbin.svg", + Groups: `["org1/department1","org1/department2"]`, }, }, } diff --git a/integration/oidc_groups_test.go b/integration/oidc_groups_test.go new file mode 100644 index 00000000..1695dac8 --- /dev/null +++ b/integration/oidc_groups_test.go @@ -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 +} 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 {