key-cape/src/internal/server/oidc/tenant_test.go
tegwick 717a97408a Correct platform infrastructure client tenant bindings
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e324-abce-7e51-bb2b-496f097afdb0
2026-09-27 16:43:31 +02:00

175 lines
6.5 KiB
Go

package oidc_test
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"keycape/internal/domain"
"keycape/internal/server/oidc"
)
// The tenant claim is bound at registration time. KeyCape emits exactly the
// registered value and performs no mapping between tenant vocabularies used by
// consuming resource servers (approval stores, policy subjects). See
// docs/tenant-claim-contract.md.
func TestServiceTenantIsBoundToRegistrationAndIgnoresRequestParameters(t *testing.T) {
h := serviceTokenHandler(t)
req := tokenRequest(url.Values{
"grant_type": {"client_credentials"},
"scope": {"finance.qonto.read"},
"tenant": {"tenant:platform"},
"tenant_hint": {"platform"},
})
req.SetBasicAuth("rapp-qonto", "test-service-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
claims := parseJWTPayload(t, token)
if claims["tenant"] != "tenant:friendly:binky" {
t.Fatalf("tenant claim not bound to registration: %v", claims["tenant"])
}
verifyWithJWKS(t, h, token)
}
// A token minted for one registration never carries another registration's
// tenant: this is the wrong-tenant evidence a resource server needs to reject
// a caller by exact string comparison.
func TestServiceTenantsAreDistinctPerRegistration(t *testing.T) {
h := serviceTokenHandler(t)
h.ClientConfig["platform-client"] = &domain.Client{
ClientID: "platform-client",
AllowedScopes: []string{"finance.qonto.read"},
GrantTypes: []string{"client_credentials"},
ClientType: "confidential",
ClientSecret: "other-service-secret",
ServiceSubject: "service:platform-client",
Tenant: "tenant:platform",
Roles: []string{"platform"},
}
for clientID, want := range map[string]string{
"rapp-qonto": "tenant:friendly:binky",
"platform-client": "tenant:platform",
} {
secret := "test-service-secret"
if clientID == "platform-client" {
secret = "other-service-secret"
}
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}, "scope": {"finance.qonto.read"}})
req.SetBasicAuth(clientID, secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("%s: status %d", clientID, w.Code)
}
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
if got := parseJWTPayload(t, token)["tenant"]; got != want {
t.Fatalf("%s: tenant %v, want %v", clientID, got, want)
}
}
}
// A human token carries the directory tenant, defaulting to the platform tenant
// (tenant:coulomb) rather than an empty claim.
func TestHumanTenantClaimUsesDirectoryValueThenPlatformDefault(t *testing.T) {
for _, tc := range []struct {
name string
tenant string
want string
}{
{"directory", "tenant:friendly:binky", "tenant:friendly:binky"},
{"default", "", "tenant:coulomb"},
} {
t.Run(tc.name, func(t *testing.T) {
user := aliceUser()
user.Tenant = tc.tenant
sessions := oidc.NewSessionStore()
h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": user}})
verifier := "test-verifier"
code := seededSession(sessions, verifier)
w := httptest.NewRecorder()
h.ServeHTTP(w, tokenRequest(url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}}))
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
access := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
if got := parseJWTPayload(t, access)["tenant"]; got != tc.want {
t.Fatalf("tenant %v, want %v", got, tc.want)
}
})
}
}
// Issuance evidence for the aligned approval chain: a token minted for the
// approval registrations carries tenant:platform exactly, and never the
// rejected alias spellings, even when the caller asks for them
// (decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6).
func TestApprovalClientIssuesExactPlatformTenantAndRejectsAliases(t *testing.T) {
h := serviceTokenHandler(t)
h.ClientConfig["secrets-engine-approval"] = &domain.Client{
ClientID: "secrets-engine-approval",
Audience: "approval-engine",
AllowedScopes: []string{"approval:read", "approval:consume"},
GrantTypes: []string{"client_credentials"},
ClientType: "confidential",
ClientSecret: "approval-test-secret",
ServiceSubject: "service:coulomb-workload-test",
Tenant: "tenant:platform",
Roles: []string{"secrets-engine"},
}
for _, requested := range []string{"", "platform", "tenant:coulomb", "TENANT:PLATFORM"} {
values := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
if requested != "" {
values.Set("tenant", requested)
values.Set("tenant_hint", requested)
}
req := tokenRequest(values)
req.SetBasicAuth("secrets-engine-approval", "approval-test-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("requested %q: status %d", requested, w.Code)
}
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
claims := parseJWTPayload(t, token)
if claims["tenant"] != "tenant:platform" {
t.Fatalf("requested %q: tenant %v, want tenant:platform", requested, claims["tenant"])
}
if claims["aud"] != "approval-engine" {
t.Fatalf("requested %q: audience %v", requested, claims["aud"])
}
verifyWithJWKS(t, h, token)
}
}
// Platform ownership does not elevate workload clients. Request parameters
// cannot change a workload registration's tenant.
func TestUnrelatedServiceClientKeepsCoulombTenant(t *testing.T) {
h := serviceTokenHandler(t)
h.ClientConfig["coulomb-workload-test"] = &domain.Client{
ClientID: "coulomb-workload-test",
AllowedScopes: []string{"openbao:login"},
GrantTypes: []string{"client_credentials"},
ClientType: "confidential",
ClientSecret: "openbao-test-secret",
ServiceSubject: "service:coulomb-workload-test",
Tenant: "tenant:coulomb",
Roles: []string{"secrets-engine"},
}
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}, "scope": {"openbao:login"}, "tenant": {"tenant:platform"}})
req.SetBasicAuth("coulomb-workload-test", "openbao-test-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
if got := parseJWTPayload(t, token)["tenant"]; got != "tenant:coulomb" {
t.Fatalf("tenant %v, want tenant:coulomb", got)
}
}