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:
parent
5228cb1a40
commit
209ba5c4ea
12 changed files with 266 additions and 7 deletions
|
|
@ -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"]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue