Map explicit tenant groups into OIDC claims
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 26s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 26s
This commit is contained in:
parent
993a4dd589
commit
e8b4eded88
3 changed files with 96 additions and 3 deletions
|
|
@ -290,6 +290,7 @@ func mapEntryToUser(entry *ldap.Entry) domain.User {
|
||||||
for _, dn := range memberOfs {
|
for _, dn := range memberOfs {
|
||||||
groups = append(groups, groupNameFromDN(dn))
|
groups = append(groups, groupNameFromDN(dn))
|
||||||
}
|
}
|
||||||
|
tenant, roles := identityEnvelopeFromGroups(groups)
|
||||||
|
|
||||||
return domain.User{
|
return domain.User{
|
||||||
ID: entry.DN,
|
ID: entry.DN,
|
||||||
|
|
@ -297,10 +298,48 @@ func mapEntryToUser(entry *ldap.Entry) domain.User {
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
Email: entry.GetAttributeValue("mail"),
|
Email: entry.GetAttributeValue("mail"),
|
||||||
Groups: groups,
|
Groups: groups,
|
||||||
|
Roles: roles,
|
||||||
|
Tenant: tenant,
|
||||||
Enabled: true, // LLDAP does not expose a disabled flag in base schema
|
Enabled: true, // LLDAP does not expose a disabled flag in base schema
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// identityEnvelopeFromGroups maps explicit tenant membership groups to the
|
||||||
|
// coarse IAM Profile envelope. Fine-grained authorization remains flex-auth's
|
||||||
|
// responsibility. Supported names are:
|
||||||
|
//
|
||||||
|
// tenant:<kind>:<slug>:users
|
||||||
|
// tenant:<kind>:<slug>:admins
|
||||||
|
//
|
||||||
|
// Multiple tenant envelopes are deliberately ignored because an interactive
|
||||||
|
// token must carry one unambiguous active tenant.
|
||||||
|
func identityEnvelopeFromGroups(groups []string) (string, []string) {
|
||||||
|
tenants := make(map[string]bool)
|
||||||
|
admins := make(map[string]bool)
|
||||||
|
for _, group := range groups {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(group, "tenant:") && strings.HasSuffix(group, ":users"):
|
||||||
|
tenants[strings.TrimSuffix(group, ":users")] = true
|
||||||
|
case strings.HasPrefix(group, "tenant:") && strings.HasSuffix(group, ":admins"):
|
||||||
|
tenant := strings.TrimSuffix(group, ":admins")
|
||||||
|
tenants[tenant] = true
|
||||||
|
admins[tenant] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tenants) != 1 {
|
||||||
|
return "", []string{}
|
||||||
|
}
|
||||||
|
var tenant string
|
||||||
|
for candidate := range tenants {
|
||||||
|
tenant = candidate
|
||||||
|
}
|
||||||
|
roles := []string{"user"}
|
||||||
|
if admins[tenant] {
|
||||||
|
roles = append(roles, "tenant-admin")
|
||||||
|
}
|
||||||
|
return tenant, roles
|
||||||
|
}
|
||||||
|
|
||||||
// groupNameFromDN extracts the cn value from an LDAP DN such as
|
// groupNameFromDN extracts the cn value from an LDAP DN such as
|
||||||
// "cn=admins,ou=groups,dc=netkingdom,dc=local" → "admins".
|
// "cn=admins,ou=groups,dc=netkingdom,dc=local" → "admins".
|
||||||
// If parsing fails the full DN is returned unchanged.
|
// If parsing fails the full DN is returned unchanged.
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,54 @@ func TestLookupUser_Success(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLookupUser_MapsExplicitTenantAdminGroup(t *testing.T) {
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
return singleEntryResult(
|
||||||
|
"uid=bernd,ou=people,dc=test,dc=local",
|
||||||
|
"bernd", "Bernd", "Worsch", "bernd.worsch@binky-hedgehog.com",
|
||||||
|
[]string{
|
||||||
|
"cn=tenant:friendly:binky:users,ou=groups,dc=test,dc=local",
|
||||||
|
"cn=tenant:friendly:binky:admins,ou=groups,dc=test,dc=local",
|
||||||
|
},
|
||||||
|
), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adapter := makeAdapter(testConfig(), conn)
|
||||||
|
user, err := adapter.LookupUser(context.Background(), "bernd")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if user.Tenant != "tenant:friendly:binky" {
|
||||||
|
t.Fatalf("tenant = %q", user.Tenant)
|
||||||
|
}
|
||||||
|
if len(user.Roles) != 2 || user.Roles[1] != "tenant-admin" {
|
||||||
|
t.Fatalf("roles = %v", user.Roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupUser_IgnoresAmbiguousTenantGroups(t *testing.T) {
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
return singleEntryResult(
|
||||||
|
"uid=multi,ou=people,dc=test,dc=local",
|
||||||
|
"multi", "Multi", "Tenant", "multi@example.test",
|
||||||
|
[]string{
|
||||||
|
"cn=tenant:friendly:binky:users,ou=groups,dc=test,dc=local",
|
||||||
|
"cn=tenant:coulomb:users,ou=groups,dc=test,dc=local",
|
||||||
|
},
|
||||||
|
), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
user, err := makeAdapter(testConfig(), conn).LookupUser(context.Background(), "multi")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if user.Tenant != "" || len(user.Roles) != 0 {
|
||||||
|
t.Fatalf("ambiguous envelope must be empty, got tenant=%q roles=%v", user.Tenant, user.Roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLookupUser_DisplayName_FallsBackToSN(t *testing.T) {
|
func TestLookupUser_DisplayName_FallsBackToSN(t *testing.T) {
|
||||||
dn := "uid=bob,ou=users,dc=netkingdom,dc=local"
|
dn := "uid=bob,ou=users,dc=netkingdom,dc=local"
|
||||||
conn := &mockConn{
|
conn := &mockConn{
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ not move user-domain or authorization ownership into KeyCape.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: KEY-WP-0007-T01
|
id: KEY-WP-0007-T01
|
||||||
status: progress
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ implicit flow, client secret, or dynamic registration is allowed.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: KEY-WP-0007-T02
|
id: KEY-WP-0007-T02
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -48,10 +48,16 @@ unregistered callback denial and successful token exchange through the portal.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: KEY-WP-0007-T03
|
id: KEY-WP-0007-T03
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
Prove issuer, audience, tenant, groups, roles and assurance claims are verified
|
Prove issuer, audience, tenant, groups, roles and assurance claims are verified
|
||||||
by the portal and that tenant administration does not imply platform-root.
|
by the portal and that tenant administration does not imply platform-root.
|
||||||
Complete the Binky user/MFA acceptance through the reusable browser path.
|
Complete the Binky user/MFA acceptance through the reusable browser path.
|
||||||
|
|
||||||
|
2026-07-27: The live client accepts only the exact portal callback and rejects
|
||||||
|
an unregistered callback with `invalid_profile_usage`. The portal begins an
|
||||||
|
S256 PKCE flow and hands authentication to Authelia. LLDAP tenant envelope
|
||||||
|
mapping now recognizes unambiguous `tenant:<kind>:<slug>:users|admins` groups;
|
||||||
|
ambiguous multi-tenant directory envelopes fail closed to no explicit tenant.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue