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
|
# use_expiry_from_token: false
|
||||||
#
|
#
|
||||||
# # The OIDC scopes to use, defaults to "openid", "profile" and "email".
|
# # 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
|
# # Custom scopes can be configured as needed, be sure to always include the
|
||||||
# # required "openid" scope.
|
# # required "openid" scope.
|
||||||
# scope: ["openid", "profile", "email"]
|
# scope: ["openid", "profile", "email", "groups"]
|
||||||
#
|
#
|
||||||
# # Only verified email addresses are synchronized to the user profile by
|
# # Only verified email addresses are synchronized to the user profile by
|
||||||
# # default. Unverified emails may be allowed in case an identity provider
|
# # 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` |
|
| 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 |
|
| 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 |
|
| 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
|
## Limitations
|
||||||
|
|
||||||
- Support for OpenID Connect aims to be generic and vendor independent. It offers only limited support for quirks of
|
- Support for OpenID Connect aims to be generic and vendor independent. It offers only limited support for quirks of
|
||||||
specific identity providers.
|
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 provided by the identity provider needs to adhere to this pattern:
|
||||||
- The username must be at least two characters long.
|
- The username must be at least two characters long.
|
||||||
- It must only contain letters, digits, hyphens, dots, underscores, and up to a single `@`.
|
- 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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -122,6 +126,13 @@ func (x *User) GetProfilePicUrl() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *User) GetGroups() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Groups
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
type CreateUserRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
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 = "" +
|
const file_headscale_v1_user_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\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" +
|
"\x04User\x12\x0e\n" +
|
||||||
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" +
|
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" +
|
||||||
"\x04name\x18\x02 \x01(\tR\x04name\x129\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" +
|
"\vprovider_id\x18\x06 \x01(\tR\n" +
|
||||||
"providerId\x12\x1a\n" +
|
"providerId\x12\x1a\n" +
|
||||||
"\bprovider\x18\a \x01(\tR\bprovider\x12&\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" +
|
"\x11CreateUserRequest\x12\x12\n" +
|
||||||
"\x04name\x18\x01 \x01(\tR\x04name\x12!\n" +
|
"\x04name\x18\x01 \x01(\tR\x04name\x12!\n" +
|
||||||
"\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x14\n" +
|
"\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x14\n" +
|
||||||
|
|||||||
@ -1528,6 +1528,13 @@
|
|||||||
},
|
},
|
||||||
"profilePicUrl": {
|
"profilePicUrl": {
|
||||||
"type": "string"
|
"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 },
|
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
|
// Fix the provider identifier for users that have a double slash in the
|
||||||
// provider identifier.
|
// provider identifier.
|
||||||
{
|
{
|
||||||
@ -315,6 +336,7 @@ AND auth_key_id NOT IN (
|
|||||||
provider_identifier text,
|
provider_identifier text,
|
||||||
provider text,
|
provider text,
|
||||||
profile_pic_url text,
|
profile_pic_url text,
|
||||||
|
groups text,
|
||||||
created_at datetime,
|
created_at datetime,
|
||||||
updated_at datetime,
|
updated_at datetime,
|
||||||
deleted_at datetime
|
deleted_at datetime
|
||||||
@ -381,8 +403,8 @@ AND auth_key_id NOT IN (
|
|||||||
|
|
||||||
// Copy data directly using SQL
|
// Copy data directly using SQL
|
||||||
dataCopySQL := []string{
|
dataCopySQL := []string{
|
||||||
`INSERT INTO users (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, 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`,
|
FROM users_old`,
|
||||||
|
|
||||||
`INSERT INTO pre_auth_keys (id, key, user_id, reusable, ephemeral, used, tags, expiration, created_at)
|
`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",
|
"202502131714",
|
||||||
"202502171819",
|
"202502171819",
|
||||||
"202505091439",
|
"202505091439",
|
||||||
|
"202505141323",
|
||||||
"202505141324",
|
"202505141324",
|
||||||
|
|
||||||
// As of 2025-07-02, no new IDs should be added here.
|
// As of 2025-07-02, no new IDs should be added here.
|
||||||
|
|||||||
@ -12,6 +12,7 @@ CREATE TABLE users(
|
|||||||
provider_identifier text,
|
provider_identifier text,
|
||||||
provider text,
|
provider text,
|
||||||
profile_pic_url text,
|
profile_pic_url text,
|
||||||
|
groups text,
|
||||||
|
|
||||||
created_at datetime,
|
created_at datetime,
|
||||||
updated_at datetime,
|
updated_at datetime,
|
||||||
|
|||||||
@ -35,6 +35,7 @@ var _UserCloneNeedsRegeneration = User(struct {
|
|||||||
ProviderIdentifier sql.NullString
|
ProviderIdentifier sql.NullString
|
||||||
Provider string
|
Provider string
|
||||||
ProfilePicURL string
|
ProfilePicURL string
|
||||||
|
Groups string
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
// Clone makes a deep copy of Node.
|
// Clone makes a deep copy of Node.
|
||||||
|
|||||||
@ -124,8 +124,11 @@ var _UserViewNeedsRegeneration = User(struct {
|
|||||||
ProviderIdentifier sql.NullString
|
ProviderIdentifier sql.NullString
|
||||||
Provider string
|
Provider string
|
||||||
ProfilePicURL string
|
ProfilePicURL string
|
||||||
|
Groups string
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
|
func (v UserView) Groups() string { return v.ж.Groups }
|
||||||
|
|
||||||
// View returns a read-only view of Node.
|
// View returns a read-only view of Node.
|
||||||
func (p *Node) View() NodeView {
|
func (p *Node) View() NodeView {
|
||||||
return NodeView{ж: p}
|
return NodeView{ж: p}
|
||||||
|
|||||||
@ -95,6 +95,11 @@ type User struct {
|
|||||||
Provider string
|
Provider string
|
||||||
|
|
||||||
ProfilePicURL 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 {
|
func (u *User) StringID() string {
|
||||||
@ -139,6 +144,39 @@ func (u *User) profilePicURL() string {
|
|||||||
return u.ProfilePicURL
|
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 {
|
func (u *User) TailscaleUser() tailcfg.User {
|
||||||
return tailcfg.User{
|
return tailcfg.User{
|
||||||
ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded
|
ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded
|
||||||
@ -205,6 +243,7 @@ func (u *User) Proto() *v1.User {
|
|||||||
ProviderId: u.ProviderIdentifier.String,
|
ProviderId: u.ProviderIdentifier.String,
|
||||||
Provider: u.Provider,
|
Provider: u.Provider,
|
||||||
ProfilePicUrl: u.ProfilePicURL,
|
ProfilePicUrl: u.ProfilePicURL,
|
||||||
|
Groups: u.GetGroups(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,4 +486,7 @@ func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) {
|
|||||||
u.DisplayName = claims.Name
|
u.DisplayName = claims.Name
|
||||||
u.ProfilePicURL = claims.ProfilePictureURL
|
u.ProfilePicURL = claims.ProfilePictureURL
|
||||||
u.Provider = util.RegisterMethodOIDC
|
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,
|
Valid: true,
|
||||||
},
|
},
|
||||||
ProfilePicURL: "https://cdn.casbin.org/img/casbin.svg",
|
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_id = 6;
|
||||||
string provider = 7;
|
string provider = 7;
|
||||||
string profile_pic_url = 8;
|
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 {
|
message CreateUserRequest {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user