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

@ -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"]`,
},
},
}