diff --git a/go.mod b/go.mod index 8af7d9ea..fa1378fc 100755 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103 github.com/oapi-codegen/runtime v1.6.0 github.com/onsi/gomega v1.42.1 - github.com/openshift-hyperfleet/hyperfleet-api-spec v1.0.26 + github.com/openshift-hyperfleet/hyperfleet-api-spec v1.0.27 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.2 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 8405f7a4..c3ee97db 100644 --- a/go.sum +++ b/go.sum @@ -190,8 +190,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/openshift-hyperfleet/hyperfleet-api-spec v1.0.26 h1:i+hXWKhj/WTuwzAiTzmj+bmRjhJzaByQUOnC68SDnkM= -github.com/openshift-hyperfleet/hyperfleet-api-spec v1.0.26/go.mod h1:KITzIAd8HcMpH5lXdHFjgk45dvL6XLpP3wwz8iK+KCI= +github.com/openshift-hyperfleet/hyperfleet-api-spec v1.0.27 h1:wtLN7KFgsDHaYDBElMcOUjHVeqjRs6dlGOWiobS1/qk= +github.com/openshift-hyperfleet/hyperfleet-api-spec v1.0.27/go.mod h1:KITzIAd8HcMpH5lXdHFjgk45dvL6XLpP3wwz8iK+KCI= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/pkg/api/presenters/resource.go b/pkg/api/presenters/resource.go index 82cde18f..8ca6dd91 100644 --- a/pkg/api/presenters/resource.go +++ b/pkg/api/presenters/resource.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" + "gorm.io/datatypes" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" @@ -54,6 +56,7 @@ func PresentResource(r *api.Resource) openapi.Resource { } labels := presentLabels(r.Labels) + tenancy := presentTenancy(r.Tenancy) resp := openapi.Resource{ Id: r.ID, @@ -62,6 +65,7 @@ func PresentResource(r *api.Resource) openapi.Resource { Href: util.PtrString(r.Href), Spec: spec, Labels: labels, + Tenancy: tenancy, Generation: r.Generation, CreatedTime: r.CreatedTime, UpdatedTime: r.UpdatedTime, @@ -172,3 +176,17 @@ func presentLabels(labels []api.ResourceLabel) *map[string]string { } return &m } + +func presentTenancy(t datatypes.JSON) *map[string]string { + if len(t) == 0 { + return nil + } + var m map[string]string + if err := json.Unmarshal(t, &m); err != nil { + return nil + } + if m == nil { + m = map[string]string{} + } + return &m +} diff --git a/pkg/api/presenters/resource_test.go b/pkg/api/presenters/resource_test.go index e0c59c3a..e970e79f 100644 --- a/pkg/api/presenters/resource_test.go +++ b/pkg/api/presenters/resource_test.go @@ -264,6 +264,57 @@ func TestPresentResource_WithReferences(t *testing.T) { Expect(*refs["wif_config"][1].Id).To(Equal("wif-2")) } +func TestPresentResource_WithTenancy(t *testing.T) { + RegisterTestingT(t) + + now := time.Now() + resource := &api.Resource{ + Meta: api.Meta{ID: "id", CreatedTime: now, UpdatedTime: now}, + Kind: "Channel", + Name: "test", + Spec: datatypes.JSON(`{}`), + Tenancy: datatypes.JSON(`{"org":"acme"}`), + CreatedBy: "user@test.com", + UpdatedBy: "user@test.com", + } + + resp := PresentResource(resource) + Expect(resp.Tenancy).ToNot(BeNil()) + Expect(*resp.Tenancy).To(HaveKeyWithValue("org", "acme")) +} + +func TestPresentResource_EmptyTenancy(t *testing.T) { + RegisterTestingT(t) + + now := time.Now() + resource := &api.Resource{ + Meta: api.Meta{ID: "id", CreatedTime: now, UpdatedTime: now}, + Kind: "Channel", + Name: "test", + Spec: datatypes.JSON(`{}`), + Tenancy: datatypes.JSON(`{}`), + CreatedBy: "user@test.com", + UpdatedBy: "user@test.com", + } + + resp := PresentResource(resource) + Expect(resp.Tenancy).ToNot(BeNil(), "empty tenancy should present as explicit {}, not be omitted") + Expect(*resp.Tenancy).To(BeEmpty()) +} + +func TestConvertResource_IgnoresTenancyInBody(t *testing.T) { + RegisterTestingT(t) + + body := []byte(`{"kind":"Channel","name":"stable","spec":{"is_default":true},"tenancy":{"org":"forged"}}`) + var req openapi.ResourceCreateRequest + err := json.Unmarshal(body, &req) + Expect(err).NotTo(HaveOccurred()) + + resource, convErr := ConvertResource(&req) + Expect(convErr).NotTo(HaveOccurred()) + Expect(resource.Tenancy).To(BeEmpty()) +} + func TestPresentResourceList(t *testing.T) { RegisterTestingT(t) diff --git a/pkg/handlers/resource_handler_test.go b/pkg/handlers/resource_handler_test.go index beadbb72..7a66104b 100644 --- a/pkg/handlers/resource_handler_test.go +++ b/pkg/handlers/resource_handler_test.go @@ -1005,6 +1005,7 @@ func TestResourceHandler_Patch_RejectsUnknownFields(t *testing.T) { {"rejects id", `{"id":"some-id","spec":{"is_default":true}}`}, {"rejects generation", `{"generation":5,"spec":{"is_default":true}}`}, {"rejects kind", `{"kind":"Channel","spec":{"is_default":true}}`}, + {"rejects tenancy", `{"tenancy":{"org":"acme"},"spec":{"is_default":true}}`}, } for _, tt := range tests { @@ -1080,6 +1081,7 @@ func TestResourceHandler_PatchByOwner_RejectsUnknownFields(t *testing.T) { {"rejects id", `{"id":"some-id","spec":{"enabled":true}}`}, {"rejects generation", `{"generation":5,"spec":{"enabled":true}}`}, {"rejects kind", `{"kind":"Version","spec":{"enabled":true}}`}, + {"rejects tenancy", `{"tenancy":{"org":"acme"},"spec":{"enabled":true}}`}, } for _, tt := range tests { diff --git a/pkg/services/resource.go b/pkg/services/resource.go index dad46b6b..37526a3c 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -14,6 +14,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/metrics" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/tenant" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" ) @@ -130,6 +131,7 @@ func (s *sqlResourceService) Create( if resource.UpdatedBy == "" { resource.UpdatedBy = username } + resource.Tenancy = tenant.TenancyJSON(ctx) resource, err := s.resourceDao.Create(ctx, resource) if err != nil { diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 7b7689b8..b97adcfd 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -9,6 +9,7 @@ import ( "time" . "github.com/onsi/gomega" + "gorm.io/datatypes" "gorm.io/gorm" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" @@ -16,6 +17,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/tenant" ) const ( @@ -409,6 +411,55 @@ func TestResourceService_Create_SetsUserFromAuthContext(t *testing.T) { Expect(result.UpdatedBy).To(Equal("user@test.com")) } +func TestResourceService_Create_StampsTenancyFromContext(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + ctx := tenant.WithTenant(context.Background(), &tenant.ResolvedTenant{ + Dimensions: map[string]string{"org": "acme"}, + }) + resource := testResource("Channel", "ch-1", "stable") + + result, svcErr := svc.Create(ctx, "Channel", resource, nil) + Expect(svcErr).To(BeNil()) + Expect(string(result.Tenancy)).To(MatchJSON(`{"org":"acme"}`)) +} + +func TestResourceService_Create_SystemIdentityGetsEmptyTenancy(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + ctx := tenant.WithTenant(context.Background(), &tenant.ResolvedTenant{ + System: true, + Dimensions: map[string]string{"org": "acme"}, + }) + resource := testResource("Channel", "ch-1", "stable") + + result, svcErr := svc.Create(ctx, "Channel", resource, nil) + Expect(svcErr).To(BeNil()) + Expect(string(result.Tenancy)).To(MatchJSON(`{}`)) +} + +func TestResourceService_Create_NoTenantContext_GetsEmptyTenancy(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + resource := testResource("Channel", "ch-1", "stable") + + result, svcErr := svc.Create(context.Background(), "Channel", resource, nil) + Expect(svcErr).To(BeNil()) + Expect(string(result.Tenancy)).To(MatchJSON(`{}`)) +} + func TestResourceService_Create_PreservesExplicitValues(t *testing.T) { RegisterTestingT(t) setupTestDescriptors() @@ -564,6 +615,23 @@ func TestResourceService_Patch_SpecChanged_IncrementsGeneration(t *testing.T) { Expect(result.Generation).To(Equal(int32(2))) } +func TestResourceService_Patch_DoesNotModifyTenancy(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + existing := testResource("Channel", "ch-1", "stable") + existing.Tenancy = datatypes.JSON(`{"org":"acme"}`) + mockDao.addResource(existing) + + patch := &api.ResourcePatch{Spec: map[string]interface{}{"key": "new-value"}} + result, svcErr := svc.Patch(context.Background(), "Channel", "ch-1", patch) + Expect(svcErr).To(BeNil()) + Expect(string(result.Tenancy)).To(MatchJSON(`{"org":"acme"}`)) +} + func TestResourceService_Patch_LabelsChanged_IncrementsGeneration(t *testing.T) { RegisterTestingT(t) setupTestDescriptors() diff --git a/pkg/tenant/context.go b/pkg/tenant/context.go new file mode 100644 index 00000000..689374ea --- /dev/null +++ b/pkg/tenant/context.go @@ -0,0 +1,43 @@ +package tenant + +import ( + "context" + "encoding/json" + + "gorm.io/datatypes" +) + +type contextKey struct{} + +// ResolvedTenant holds the tenant identity resolved from gateway-injected request headers. +type ResolvedTenant struct { + Dimensions map[string]string + System bool +} + +// WithTenant attaches a resolved tenant identity to the context. +func WithTenant(ctx context.Context, t *ResolvedTenant) context.Context { + return context.WithValue(ctx, contextKey{}, t) +} + +// FromContext returns the tenant identity attached to ctx, or nil if none was resolved. +func FromContext(ctx context.Context) *ResolvedTenant { + if t, ok := ctx.Value(contextKey{}).(*ResolvedTenant); ok { + return t + } + return nil +} + +// TenancyJSON returns the caller's tenancy map as JSONB for storage on created resources. +// System and unscoped/absent callers get an empty map, which no tenant-scoped query can ever match. +func TenancyJSON(ctx context.Context) datatypes.JSON { + t := FromContext(ctx) + if t == nil || t.System || len(t.Dimensions) == 0 { + return datatypes.JSON([]byte("{}")) + } + b, err := json.Marshal(t.Dimensions) + if err != nil { + return datatypes.JSON([]byte("{}")) + } + return datatypes.JSON(b) +} diff --git a/pkg/tenant/context_test.go b/pkg/tenant/context_test.go new file mode 100644 index 00000000..88deee32 --- /dev/null +++ b/pkg/tenant/context_test.go @@ -0,0 +1,74 @@ +package tenant + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" +) + +func TestFromContext_NoTenant(t *testing.T) { + RegisterTestingT(t) + + got := FromContext(context.Background()) + Expect(got).To(BeNil()) +} + +func TestWithTenant_RoundTrip(t *testing.T) { + RegisterTestingT(t) + + want := &ResolvedTenant{Dimensions: map[string]string{"org": "acme"}} + ctx := WithTenant(context.Background(), want) + + got := FromContext(ctx) + Expect(got).To(Equal(want)) +} + +func TestTenancyJSON(t *testing.T) { + tests := []struct { + name string + ctx context.Context + want string + }{ + { + name: "no tenant in context", + ctx: context.Background(), + want: "{}", + }, + { + name: "system identity", + ctx: WithTenant(context.Background(), &ResolvedTenant{System: true, Dimensions: map[string]string{"org": "acme"}}), + want: "{}", + }, + { + name: "empty dimensions", + ctx: WithTenant(context.Background(), &ResolvedTenant{Dimensions: map[string]string{}}), + want: "{}", + }, + { + name: "nil dimensions", + ctx: WithTenant(context.Background(), &ResolvedTenant{}), + want: "{}", + }, + { + name: "tenant with single dimension", + ctx: WithTenant(context.Background(), &ResolvedTenant{Dimensions: map[string]string{"org": "acme"}}), + want: `{"org":"acme"}`, + }, + { + name: "tenant with multiple dimensions", + ctx: WithTenant(context.Background(), &ResolvedTenant{ + Dimensions: map[string]string{"org": "acme", "project": "project-1"}, + }), + want: `{"org":"acme","project":"project-1"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + got := TenancyJSON(tt.ctx) + Expect(string(got)).To(MatchJSON(tt.want)) + }) + } +}