oidc groups: store and expose the OIDC groups claim
Some checks are pending
Tests / test (push) Waiting to run
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:
parent
5228cb1a40
commit
209ba5c4ea
@ -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
|
||||
|
||||
@ -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 `@`.
|
||||
|
||||
@ -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" +
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -12,6 +12,7 @@ CREATE TABLE users(
|
||||
provider_identifier text,
|
||||
provider text,
|
||||
profile_pic_url text,
|
||||
groups text,
|
||||
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
|
||||
@ -35,6 +35,7 @@ var _UserCloneNeedsRegeneration = User(struct {
|
||||
ProviderIdentifier sql.NullString
|
||||
Provider string
|
||||
ProfilePicURL string
|
||||
Groups string
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of Node.
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -528,6 +528,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||
Valid: true,
|
||||
},
|
||||
ProfilePicURL: "https://cdn.casbin.org/img/casbin.svg",
|
||||
Groups: `["org1/department1","org1/department2"]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user