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

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