From e8b4eded888e0ec91735830ce5177d70b2f138f4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 28 Jul 2026 00:34:19 +0200 Subject: [PATCH] Map explicit tenant groups into OIDC claims --- src/internal/adapters/lldap/adapter.go | 39 +++++++++++++++ src/internal/adapters/lldap/adapter_test.go | 48 +++++++++++++++++++ ...-WP-0007-user-engine-portal-oidc-client.md | 12 +++-- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/internal/adapters/lldap/adapter.go b/src/internal/adapters/lldap/adapter.go index 12c3c93..038ec93 100644 --- a/src/internal/adapters/lldap/adapter.go +++ b/src/internal/adapters/lldap/adapter.go @@ -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:::users +// tenant:::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. diff --git a/src/internal/adapters/lldap/adapter_test.go b/src/internal/adapters/lldap/adapter_test.go index 72c5bb3..dce1571 100644 --- a/src/internal/adapters/lldap/adapter_test.go +++ b/src/internal/adapters/lldap/adapter_test.go @@ -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{ diff --git a/workplans/KEY-WP-0007-user-engine-portal-oidc-client.md b/workplans/KEY-WP-0007-user-engine-portal-oidc-client.md index a0cbc4d..edf4912 100644 --- a/workplans/KEY-WP-0007-user-engine-portal-oidc-client.md +++ b/workplans/KEY-WP-0007-user-engine-portal-oidc-client.md @@ -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:::users|admins` groups; +ambiguous multi-tenant directory envelopes fail closed to no explicit tenant.