Map explicit tenant groups into OIDC claims
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 26s

This commit is contained in:
tegwick 2026-07-28 00:34:19 +02:00
parent 993a4dd589
commit e8b4eded88
3 changed files with 96 additions and 3 deletions

View file

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

View file

@ -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{

View file

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