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 {
groups = append(groups, groupNameFromDN(dn))
}
tenant, roles := identityEnvelopeFromGroups(groups)
return domain.User{
ID: entry.DN,
@ -297,10 +298,48 @@ func mapEntryToUser(entry *ldap.Entry) domain.User {
DisplayName: displayName,
Email: entry.GetAttributeValue("mail"),
Groups: groups,
Roles: roles,
Tenant: tenant,
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
// "cn=admins,ou=groups,dc=netkingdom,dc=local" → "admins".
// 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) {
dn := "uid=bob,ou=users,dc=netkingdom,dc=local"
conn := &mockConn{

View file

@ -24,7 +24,7 @@ not move user-domain or authorization ownership into KeyCape.
```task
id: KEY-WP-0007-T01
status: progress
status: done
priority: high
```
@ -36,7 +36,7 @@ implicit flow, client secret, or dynamic registration is allowed.
```task
id: KEY-WP-0007-T02
status: wait
status: done
priority: high
```
@ -48,10 +48,16 @@ unregistered callback denial and successful token exchange through the portal.
```task
id: KEY-WP-0007-T03
status: wait
status: progress
priority: high
```
Prove issuer, audience, tenant, groups, roles and assurance claims are verified
by the portal and that tenant administration does not imply platform-root.
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.