oidc groups: store and expose the OIDC groups claim
Some checks are pending
Tests / test (push) Waiting to run

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:
Ryan Malloy 2026-06-04 01:57:50 -06:00
parent 5228cb1a40
commit 209ba5c4ea
12 changed files with 266 additions and 7 deletions

View File

@ -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

View File

@ -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 `@`.

View File

@ -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" +

View File

@ -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."
}
}
}

View File

@ -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.

View File

@ -12,6 +12,7 @@ CREATE TABLE users(
provider_identifier text,
provider text,
profile_pic_url text,
groups text,
created_at datetime,
updated_at datetime,

View File

@ -35,6 +35,7 @@ var _UserCloneNeedsRegeneration = User(struct {
ProviderIdentifier sql.NullString
Provider string
ProfilePicURL string
Groups string
}{})
// Clone makes a deep copy of Node.

View File

@ -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}

View File

@ -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)
}

View File

@ -528,6 +528,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
Valid: true,
},
ProfilePicURL: "https://cdn.casbin.org/img/casbin.svg",
Groups: `["org1/department1","org1/department2"]`,
},
},
}

View 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
}

View File

@ -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 {