oidc groups: expose Groups through the gRPC API
Some checks failed
Build / build-nix (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Has been cancelled
Check Generated Files / check-generated (push) Has been cancelled
Build (main) / container (push) Has been cancelled
Build (main) / binaries (amd64, darwin) (push) Has been cancelled
Build (main) / binaries (amd64, linux) (push) Has been cancelled
Build (main) / binaries (arm64, darwin) (push) Has been cancelled
Build (main) / binaries (arm64, linux) (push) Has been cancelled
NixOS Module Tests / nix-module-check (push) Has been cancelled
Tests / test (push) Has been cancelled
update-flake-lock / lockfile (push) Has been cancelled
GitHub Actions Version Updater / build (push) Has been cancelled
Some checks failed
Build / build-nix (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Has been cancelled
Check Generated Files / check-generated (push) Has been cancelled
Build (main) / container (push) Has been cancelled
Build (main) / binaries (amd64, darwin) (push) Has been cancelled
Build (main) / binaries (amd64, linux) (push) Has been cancelled
Build (main) / binaries (arm64, darwin) (push) Has been cancelled
Build (main) / binaries (arm64, linux) (push) Has been cancelled
NixOS Module Tests / nix-module-check (push) Has been cancelled
Tests / test (push) Has been cancelled
update-flake-lock / lockfile (push) Has been cancelled
GitHub Actions Version Updater / build (push) Has been cancelled
The Groups column is already persisted on users.User (migration 202505141323) and populated from claims.Groups in FromClaim. This makes the value visible through the gRPC/REST surface so external tools (notably Headplane, which is the motivation for storing the claim in the first place) can read group membership without poking at the database. - proto/headscale/v1/user.proto: add `repeated string groups = 9;` with a doc comment describing where the value comes from. - gen/go/headscale/v1/user.pb.go, gen/openapiv2/headscale/v1/headscale.swagger.json: regenerated via `buf generate --template ../buf.gen.yaml -o .. ../proto`. - hscontrol/types/users.go: populate v1.User.Groups in Proto() by decoding the JSON-encoded users.groups column via GetGroups(). - integration/oidc_groups_test.go: drop the sqlite3-via-Execute hack and verify groups through headscale.ListUsers() like every other user-state integration test.
This commit is contained in:
parent
32ea1c1c84
commit
9ca200b6bc
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -243,6 +243,7 @@ func (u *User) Proto() *v1.User {
|
||||
ProviderId: u.ProviderIdentifier.String,
|
||||
Provider: u.Provider,
|
||||
ProfilePicUrl: u.ProfilePicURL,
|
||||
Groups: u.GetGroups(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"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"
|
||||
@ -13,30 +12,27 @@ import (
|
||||
)
|
||||
|
||||
// TestOIDCGroupsPersisted verifies that the `groups` claim from an OIDC
|
||||
// provider is persisted into the users.groups column after the user logs in.
|
||||
// provider is persisted on the user and exposed through the gRPC API.
|
||||
//
|
||||
// The implementation under test:
|
||||
// - User.Groups column (TEXT, JSON-encoded []string) added by migration
|
||||
// 202505141323 in hscontrol/db/db.go.
|
||||
// - User.SetGroups / User.GetGroups in hscontrol/types/users.go.
|
||||
// - 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 a one-element array also work.
|
||||
//
|
||||
// Verification is done by reading the SQLite database inside the headscale
|
||||
// container directly, because the gRPC User message does not currently
|
||||
// expose Groups. Adding groups to the gRPC API is a separate, larger change.
|
||||
// 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 in strict queue order, so keep NodesPerUser=1.
|
||||
// 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 — must round-trip as no Groups stored.
|
||||
// User with empty groups — round-trips as no Groups.
|
||||
oidcMockUserWithGroups("solo", true, nil),
|
||||
},
|
||||
}
|
||||
@ -50,7 +46,7 @@ func TestOIDCGroupsPersisted(t *testing.T) {
|
||||
"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 the IdP emits the claim.
|
||||
// Make sure the OIDC scope set includes "groups" so mockoidc emits the claim.
|
||||
"HEADSCALE_OIDC_SCOPE": "openid,profile,email,groups",
|
||||
}
|
||||
|
||||
@ -71,17 +67,10 @@ func TestOIDCGroupsPersisted(t *testing.T) {
|
||||
headscale, err := scenario.Headscale()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Query the SQLite database inside the headscale container for the groups
|
||||
// column. CLI/gRPC do not expose it yet; this is the authoritative store.
|
||||
const dbPath = "/tmp/integration_test_db.sqlite3"
|
||||
out, err := headscale.Execute([]string{
|
||||
"sqlite3", dbPath,
|
||||
"-cmd", ".mode tabs",
|
||||
"SELECT name, COALESCE(groups, '') FROM users WHERE provider = 'oidc' ORDER BY name;",
|
||||
})
|
||||
require.NoError(t, err, "querying users.groups from sqlite")
|
||||
users, err := headscale.ListUsers()
|
||||
require.NoError(t, err)
|
||||
|
||||
got := parseGroupsRows(t, out)
|
||||
got := groupsByOIDCUserName(users)
|
||||
|
||||
want := map[string][]string{
|
||||
"admin": {"admins", "engineering"},
|
||||
@ -91,40 +80,28 @@ func TestOIDCGroupsPersisted(t *testing.T) {
|
||||
|
||||
for name, wantGroups := range want {
|
||||
gotGroups, ok := got[name]
|
||||
assert.True(t, ok, "user %q not present in users table", name)
|
||||
assert.ElementsMatch(t, wantGroups, gotGroups,
|
||||
"groups mismatch for user %q (raw rows: %q)", name, out)
|
||||
assert.True(t, ok, "OIDC user %q not present in ListUsers response", name)
|
||||
assert.ElementsMatch(t, wantGroups, gotGroups, "groups mismatch for user %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// parseGroupsRows parses the tab-separated output of:
|
||||
//
|
||||
// SELECT name, COALESCE(groups, '') FROM users ...
|
||||
//
|
||||
// Returns a map of username -> decoded groups slice. An empty groups column
|
||||
// (stored as "" by SetGroups when the input slice is empty) decodes to nil.
|
||||
func parseGroupsRows(t *testing.T, raw string) map[string][]string {
|
||||
t.Helper()
|
||||
rows := map[string][]string{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
|
||||
if line == "" {
|
||||
// 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
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
require.Len(t, parts, 2, "unexpected sqlite row format: %q", line)
|
||||
name, groupsJSON := parts[0], parts[1]
|
||||
|
||||
if groupsJSON == "" {
|
||||
rows[name] = nil
|
||||
continue
|
||||
g := append([]string(nil), u.GetGroups()...)
|
||||
sort.Strings(g)
|
||||
if len(g) == 0 {
|
||||
g = nil
|
||||
}
|
||||
var gs []string
|
||||
require.NoError(t, json.Unmarshal([]byte(groupsJSON), &gs),
|
||||
"groups column for %q is not valid JSON: %q", name, groupsJSON)
|
||||
sort.Strings(gs)
|
||||
rows[name] = gs
|
||||
out[u.GetName()] = g
|
||||
}
|
||||
return rows
|
||||
return out
|
||||
}
|
||||
|
||||
// oidcMockUserWithGroups extends [oidcMockUser] with a Groups claim.
|
||||
|
||||
@ -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