Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
586 changes: 303 additions & 283 deletions api/v2/api.pb.go

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions api/v2/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ message Client {
bool public = 5;
string name = 6;
string logo_url = 7;
repeated string allowed_groups = 8;
}

// ClientInfo represents an OAuth2 client without sensitive information.
Expand All @@ -24,6 +25,7 @@ message ClientInfo {
bool public = 4;
string name = 5;
string logo_url = 6;
repeated string allowed_groups = 7;
}

// GetClientReq is a request to retrieve client details.
Expand Down
2 changes: 1 addition & 1 deletion api/v2/api_grpc.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 67 additions & 0 deletions config.allowed-groups-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Config for manual testing of allowedGroups (per-client and connector).
# Run: dex serve config.allowed-groups-test.yaml
# Then use example-app with client "example-app" (allowed) or "restricted-app" (deny).
issuer: http://127.0.0.1:5556/dex

storage:
type: sqlite3
config:
file: var/sqlite/dex.db

web:
http: 127.0.0.1:5556

telemetry:
http: 127.0.0.1:5558

staticClients:
# No allowedGroups: any user who passes the connector can complete SSO.
- id: example-app
redirectURIs:
- 'http://127.0.0.1:5555/callback'
name: 'Example App (no restriction)'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0

# allowedGroups: only users in team-a or team-a/admins can complete SSO for this client.
- id: example-app-allowed
redirectURIs:
- 'http://127.0.0.1:5555/callback'
name: 'Example App (allowed groups)'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
allowedGroups:
- "team-a"
- "team-a/admins"

# allowedGroups: user admin has team-a; user "other" does not -> login for this client will 403.
- id: restricted-app
redirectURIs:
- 'http://127.0.0.1:5555/callback'
name: 'Restricted App'
secret: restricted-secret
allowedGroups:
- "other-group"

connectors:
- type: mockCallback
id: mock
name: Example

enablePasswordDB: true

staticPasswords:
# User in team-a, team-a/admins -> can use example-app and example-app-allowed; cannot use restricted-app.
- email: "admin@example.com"
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "admin"
name: "Admin User"
emailVerified: true
preferredUsername: "admin"
groups:
- "team-a"
- "team-a/admins"
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"

signer:
type: local
config:
keysRotationPeriod: "6h"
30 changes: 26 additions & 4 deletions connector/ldap/ldap.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/go-ldap/ldap/v3"

"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/pkg/groups"
)

// Config holds the configuration parameters for the LDAP connector. The LDAP
Expand Down Expand Up @@ -162,6 +163,10 @@ type Config struct {
// The attribute of the group that represents its name.
NameAttr string `json:"nameAttr"`
} `json:"groupSearch"`

// AllowedGroups restricts login to users that belong to at least one of these
// groups. Empty means no restriction (connector-level; for per-client restriction use client.allowedGroups).
AllowedGroups []string `json:"allowedGroups,omitempty"`
}

func scopeString(i int) string {
Expand Down Expand Up @@ -528,12 +533,22 @@ func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username,
return connector.Identity{}, false, err
}

if len(c.AllowedGroups) > 0 && !s.Groups {
return connector.Identity{}, false, &connector.UserNotInRequiredGroupsError{UserID: ident.UserID, Groups: c.AllowedGroups}
}
if s.Groups {
groups, err := c.groups(ctx, user)
groupList, err := c.groups(ctx, user)
if err != nil {
return connector.Identity{}, false, fmt.Errorf("ldap: failed to query groups: %v", err)
}
ident.Groups = groups
ident.Groups = groupList
if len(c.AllowedGroups) > 0 {
matched := groups.Filter(ident.Groups, c.AllowedGroups)
if len(matched) == 0 {
return connector.Identity{}, false, &connector.UserNotInRequiredGroupsError{UserID: ident.UserID, Groups: c.AllowedGroups}
}
ident.Groups = matched
}
}

if s.OfflineAccess {
Expand Down Expand Up @@ -583,11 +598,18 @@ func (c *ldapConnector) Refresh(ctx context.Context, s connector.Scopes, ident c
newIdent.ConnectorData = ident.ConnectorData

if s.Groups {
groups, err := c.groups(ctx, user)
groupList, err := c.groups(ctx, user)
if err != nil {
return connector.Identity{}, fmt.Errorf("ldap: failed to query groups: %v", err)
}
newIdent.Groups = groups
newIdent.Groups = groupList
if len(c.AllowedGroups) > 0 {
matched := groups.Filter(newIdent.Groups, c.AllowedGroups)
if len(matched) == 0 {
return connector.Identity{}, &connector.UserNotInRequiredGroupsError{UserID: newIdent.UserID, Groups: c.AllowedGroups}
}
newIdent.Groups = matched
}
}
return newIdent, nil
}
Expand Down
42 changes: 42 additions & 0 deletions connector/ldap/ldap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,48 @@ func TestGroupQuery(t *testing.T) {
runTests(t, connectLDAP, c, tests)
}

// TestAllowedGroups verifies connector-level allowedGroups: only users in at least one
// of the allowed groups can log in; others get UserNotInRequiredGroupsError.
func TestAllowedGroups(t *testing.T) {
c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestGroupQuery,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
c.GroupSearch.BaseDN = "ou=Groups,ou=TestGroupQuery,dc=example,dc=org"
c.GroupSearch.UserMatchers = []UserMatcher{
{UserAttr: "DN", GroupAttr: "member"},
}
c.GroupSearch.NameAttr = "cn"
c.AllowedGroups = []string{"developers"} // jane has developers, john has only admins

tests := []subtest{
{
name: "user in allowed group",
username: "jane",
password: "foo",
groups: true,
want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestGroupQuery,dc=example,dc=org",
Username: "jane",
Email: "janedoe@example.com",
EmailVerified: true,
Groups: []string{"developers"}, // filtered to allowed only
},
},
{
name: "user not in allowed group",
username: "john",
password: "bar",
groups: true,
wantErr: true, // john has admins only, not developers
},
}

runTests(t, connectLDAP, c, tests)
}

func TestGroupsOnUserEntity(t *testing.T) {
c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org"
Expand Down
8 changes: 7 additions & 1 deletion pkg/groups/groups_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ func TestFilter(t *testing.T) {
given, required, expected []string
}{
"nothing given": {given: []string{}, required: []string{"ops"}, expected: []string{}},
"nil given": {given: nil, required: []string{"ops"}, expected: []string{}},
"nil required": {given: []string{"foo"}, required: nil, expected: []string{}},
"both nil": {given: nil, required: nil, expected: []string{}},
"exactly one match": {given: []string{"foo"}, required: []string{"foo"}, expected: []string{"foo"}},
"no group of the required ones": {given: []string{"foo", "bar"}, required: []string{"baz"}, expected: []string{}},
"no group of the required ones": {given: []string{"foo", "bar"}, required: []string{"baz"}, expected: []string{}},
"subset matching": {given: []string{"foo", "bar", "baz"}, required: []string{"bar", "baz"}, expected: []string{"bar", "baz"}},
"duplicate in required": {given: []string{"a", "b"}, required: []string{"a", "a", "b"}, expected: []string{"a", "b"}},
"duplicate in given": {given: []string{"a", "a", "b"}, required: []string{"a"}, expected: []string{"a", "a"}},
"order preserved from given": {given: []string{"z", "x", "y"}, required: []string{"y", "z"}, expected: []string{"z", "y"}},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
Expand Down
43 changes: 23 additions & 20 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,14 @@ func (d dexAPI) GetClient(ctx context.Context, req *api.GetClientReq) (*api.GetC

return &api.GetClientResp{
Client: &api.Client{
Id: c.ID,
Name: c.Name,
Secret: c.Secret,
RedirectUris: c.RedirectURIs,
TrustedPeers: c.TrustedPeers,
Public: c.Public,
LogoUrl: c.LogoURL,
Id: c.ID,
Name: c.Name,
Secret: c.Secret,
RedirectUris: c.RedirectURIs,
TrustedPeers: c.TrustedPeers,
Public: c.Public,
LogoUrl: c.LogoURL,
AllowedGroups: c.AllowedGroups,
},
}, nil
}
Expand All @@ -82,13 +83,14 @@ func (d dexAPI) CreateClient(ctx context.Context, req *api.CreateClientReq) (*ap
}

c := storage.Client{
ID: req.Client.Id,
Secret: req.Client.Secret,
RedirectURIs: req.Client.RedirectUris,
TrustedPeers: req.Client.TrustedPeers,
Public: req.Client.Public,
Name: req.Client.Name,
LogoURL: req.Client.LogoUrl,
ID: req.Client.Id,
Secret: req.Client.Secret,
RedirectURIs: req.Client.RedirectUris,
TrustedPeers: req.Client.TrustedPeers,
Public: req.Client.Public,
Name: req.Client.Name,
LogoURL: req.Client.LogoUrl,
AllowedGroups: req.Client.AllowedGroups,
}
if err := d.s.CreateClient(ctx, c); err != nil {
if err == storage.ErrAlreadyExists {
Expand Down Expand Up @@ -155,12 +157,13 @@ func (d dexAPI) ListClients(ctx context.Context, req *api.ListClientReq) (*api.L
clients := make([]*api.ClientInfo, 0, len(clientList))
for _, client := range clientList {
c := api.ClientInfo{
Id: client.ID,
Name: client.Name,
RedirectUris: client.RedirectURIs,
TrustedPeers: client.TrustedPeers,
Public: client.Public,
LogoUrl: client.LogoURL,
Id: client.ID,
Name: client.Name,
RedirectUris: client.RedirectURIs,
TrustedPeers: client.TrustedPeers,
Public: client.Public,
LogoUrl: client.LogoURL,
AllowedGroups: client.AllowedGroups,
}
clients = append(clients, &c)
}
Expand Down
50 changes: 50 additions & 0 deletions server/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,56 @@ func TestUpdateClient(t *testing.T) {
}
}

// TestCreateClientWithAllowedGroups verifies that a client created with AllowedGroups
// persists and is returned correctly via GetClient.
func TestCreateClientWithAllowedGroups(t *testing.T) {
logger := newLogger(t)
s := memory.New(logger)

apiClient := newAPI(t, s, logger)
defer apiClient.Close()

ctx := t.Context()

createReq := &api.CreateClientReq{
Client: &api.Client{
Id: "allowed-groups-client",
Secret: "secret",
RedirectUris: []string{"http://localhost/callback"},
Name: "Test",
LogoUrl: "",
AllowedGroups: []string{"team-a", "team-b"},
},
}
resp, err := apiClient.CreateClient(ctx, createReq)
if err != nil {
t.Fatalf("CreateClient: %v", err)
}
if resp.AlreadyExists {
t.Fatal("expected new client, got AlreadyExists")
}

getResp, err := apiClient.GetClient(ctx, &api.GetClientReq{Id: createReq.Client.Id})
if err != nil {
t.Fatalf("GetClient: %v", err)
}
if getResp.Client == nil {
t.Fatal("GetClient returned no client")
}
if !slices.Equal(getResp.Client.AllowedGroups, []string{"team-a", "team-b"}) {
t.Errorf("GetClient AllowedGroups: got %v, want [team-a team-b]", getResp.Client.AllowedGroups)
}

// Verify storage roundtrip
stored, err := s.GetClient(ctx, createReq.Client.Id)
if err != nil {
t.Fatalf("storage GetClient: %v", err)
}
if !slices.Equal(stored.AllowedGroups, []string{"team-a", "team-b"}) {
t.Errorf("stored AllowedGroups: got %v, want [team-a team-b]", stored.AllowedGroups)
}
}

func TestCreateConnector(t *testing.T) {
t.Setenv("DEX_API_CONNECTORS_CRUD", "true")

Expand Down
Loading