diff --git a/cmd/diff.go b/cmd/diff.go index 1456186..a0ad937 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -134,9 +134,12 @@ Workspace resources: clients, idps, claims, custom_apps, gateways, policies, pol server_consent, servers_bindings, services, theme_binding, webhooks, ciba (alias of ciba_authentication_service) Tenant resources: pools, schemas, mfa_methods, themes, servers +Reserved: root (only root-level tenant/workspace config, excluding nested resources) Examples: --filter clients - --filter clients,idps,policies`) + --filter clients,idps,policies + --filter root + --filter root,clients`) diffCmd.PersistentFlags().StringVar(&diffConfig.Out, "out", "-", `Diff output destination: a file path or '-' for stdout. Examples: --out - (stdout) diff --git a/cmd/pull.go b/cmd/pull.go index 01676f6..6bde4f7 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -76,8 +76,11 @@ Workspace resources: clients, idps, claims, custom_apps, gateways, policies, pol server_consent, servers_bindings, services, theme_binding, webhooks, ciba (alias of ciba_authentication_service) Tenant resources: pools, schemas, mfa_methods, themes, servers +Reserved: root (only root-level tenant/workspace config, excluding nested resources) Examples: --filter clients --filter clients,idps,policies - --filter scopes --filter pools`) + --filter scopes --filter pools + --filter root + --filter root,clients`) } diff --git a/cmd/push.go b/cmd/push.go index e88910f..32e4e36 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -138,10 +138,13 @@ Workspace resources: clients, idps, claims, custom_apps, gateways, policies, pol server_consent, servers_bindings, services, theme_binding, webhooks, ciba (alias of ciba_authentication_service) Tenant resources: pools, schemas, mfa_methods, themes, servers +Reserved: root (only root-level tenant/workspace config, excluding nested resources) Examples: --filter clients --filter clients,idps,policies - --filter scopes --filter pools`) + --filter scopes --filter pools + --filter root + --filter root,clients`) mustMarkRequired(pushCmd, "method") } diff --git a/internal/cac/client/client.go b/internal/cac/client/client.go index 2966f64..343d214 100644 --- a/internal/cac/client/client.go +++ b/internal/cac/client/client.go @@ -79,7 +79,7 @@ func (c *Client) Read(ctx context.Context, opts ...api.SourceOpt) (models.Rfc739 return nil, errors.Wrap(err, "failed to convert tree server to patch") } - if data, err = utils.FilterPatch(data, options.Filters); err != nil { + if data, err = utils.FilterPatch(data, options.Filters, utils.ServerRootKeys); err != nil { return nil, errors.Wrap(err, "failed to filter patch") } diff --git a/internal/cac/client/tenant_client.go b/internal/cac/client/tenant_client.go index 27e85ba..c846f19 100644 --- a/internal/cac/client/tenant_client.go +++ b/internal/cac/client/tenant_client.go @@ -41,7 +41,7 @@ func (t *TenantClient) Read(ctx context.Context, opts ...api.SourceOpt) (models. return nil, err } - if data, err = utils.FilterPatch(data, options.Filters); err != nil { + if data, err = utils.FilterPatch(data, options.Filters, utils.TenantRootKeys); err != nil { return nil, err } diff --git a/internal/cac/storage/server_storage.go b/internal/cac/storage/server_storage.go index d43b098..7e9f713 100644 --- a/internal/cac/storage/server_storage.go +++ b/internal/cac/storage/server_storage.go @@ -257,7 +257,7 @@ func (s *ServerStorage) Read(ctx context.Context, opts ...api.SourceOpt) (models return nil, err } - if server, err = utils.FilterPatch(server, options.Filters); err != nil { + if server, err = utils.FilterPatch(server, options.Filters, utils.ServerRootKeys); err != nil { return nil, err } diff --git a/internal/cac/storage/server_storage_test.go b/internal/cac/storage/server_storage_test.go index eb4468e..14213e2 100644 --- a/internal/cac/storage/server_storage_test.go +++ b/internal/cac/storage/server_storage_test.go @@ -669,7 +669,7 @@ system: false`, string(bts)) // verifying if the data read from fs is the same as the provided test data - patchData, err = utils.FilterPatch(patchData, tc.filters) + patchData, err = utils.FilterPatch(patchData, tc.filters, utils.ServerRootKeys) require.NoError(t, err) d, err := diff.Tree(patchData, readServer) diff --git a/internal/cac/storage/tenant_storage.go b/internal/cac/storage/tenant_storage.go index 591460b..4611221 100644 --- a/internal/cac/storage/tenant_storage.go +++ b/internal/cac/storage/tenant_storage.go @@ -191,7 +191,7 @@ func (t *TenantStorage) Read(ctx context.Context, opts ...api.SourceOpt) (models tenant["servers"] = servers } - if tenant, err = utils.FilterPatch(tenant, options.Filters); err != nil { + if tenant, err = utils.FilterPatch(tenant, options.Filters, utils.TenantRootKeys); err != nil { return nil, err } diff --git a/internal/cac/storage/tenant_storage_test.go b/internal/cac/storage/tenant_storage_test.go index c3e1517..6e97ff1 100644 --- a/internal/cac/storage/tenant_storage_test.go +++ b/internal/cac/storage/tenant_storage_test.go @@ -329,7 +329,7 @@ updated_at: 0001-01-01T00:00:00.000Z require.NoError(t, err) // verifying if the data read from fs is the same as the provided test data - patchData, err = utils.FilterPatch(patchData, tc.filters) + patchData, err = utils.FilterPatch(patchData, tc.filters, utils.TenantRootKeys) require.NoError(t, err) d, err := diff.Tree(patchData, readServer) diff --git a/internal/cac/utils/model.go b/internal/cac/utils/model.go index 6b74f82..04bbfc8 100644 --- a/internal/cac/utils/model.go +++ b/internal/cac/utils/model.go @@ -1,7 +1,11 @@ package utils import ( + "reflect" + "strings" + "github.com/cloudentity/acp-client-go/clients/hub/models" + smodels "github.com/cloudentity/acp-client-go/clients/system/models" "github.com/go-json-experiment/json" "github.com/pkg/errors" ) @@ -73,7 +77,37 @@ var staticFilterMappings = map[string]string{ "ciba": "ciba_authentication_service", } -func FilterPatch(patch models.Rfc7396PatchOperation, filters []string) (models.Rfc7396PatchOperation, error) { +// RootFilter is a reserved --filter value that selects only root-level config, +// i.e. the tenant/workspace's own fields, excluding nested sub-resources. +const RootFilter = "root" + +// TenantRootKeys and ServerRootKeys hold the top-level JSON field names that make +// up root-level tenant / workspace config. They are derived from the same models +// the storage layer serializes the root tenant/server file into (with nested +// dependencies stripped), so new root fields are picked up automatically without a +// hand-maintained list. +var ( + TenantRootKeys = modelJSONKeys(reflect.TypeFor[smodels.Tenant]()) + ServerRootKeys = modelJSONKeys(reflect.TypeFor[smodels.ServerDump]()) +) + +// modelJSONKeys returns the set of top-level JSON field names of a struct type. +func modelJSONKeys(t reflect.Type) map[string]struct{} { + keys := make(map[string]struct{}, t.NumField()) + + for i := 0; i < t.NumField(); i++ { + name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + + keys[name] = struct{}{} + } + + return keys +} + +func FilterPatch(patch models.Rfc7396PatchOperation, filters []string, rootKeys map[string]struct{}) (models.Rfc7396PatchOperation, error) { if len(filters) == 0 { return patch, nil } @@ -81,6 +115,16 @@ func FilterPatch(patch models.Rfc7396PatchOperation, filters []string) (models.R var newPatch = models.Rfc7396PatchOperation{} for _, filter := range filters { + if filter == RootFilter { + for k := range rootKeys { + if v, ok := patch[k]; ok { + newPatch[k] = v + } + } + + continue + } + if mapped, ok := staticFilterMappings[filter]; ok { filter = mapped } diff --git a/internal/cac/utils/model_test.go b/internal/cac/utils/model_test.go index 27d748e..9604386 100644 --- a/internal/cac/utils/model_test.go +++ b/internal/cac/utils/model_test.go @@ -13,6 +13,7 @@ func TestFilterPatch(t *testing.T) { name string server models.Rfc7396PatchOperation filters []string + rootKeys map[string]struct{} expected models.Rfc7396PatchOperation }{ { @@ -29,7 +30,8 @@ func TestFilterPatch(t *testing.T) { }, }, }, - filters: []string{"clients"}, + filters: []string{"clients"}, + rootKeys: utils.ServerRootKeys, expected: models.Rfc7396PatchOperation{ "clients": models.TreeClients{ "123": models.TreeClient{ @@ -55,7 +57,8 @@ func TestFilterPatch(t *testing.T) { Type: "asd", }, }, - filters: []string{"scopes", "ciba"}, + filters: []string{"scopes", "ciba"}, + rootKeys: utils.ServerRootKeys, expected: models.Rfc7396PatchOperation{ "scopes_without_service": models.TreeScopes{ "456": models.TreeScope{ @@ -67,11 +70,82 @@ func TestFilterPatch(t *testing.T) { }, }, }, + { + name: "root only keeps root-level workspace config and drops collections", + server: models.Rfc7396PatchOperation{ + "name": "workspace1", + "grant_types": []string{"authorization_code"}, + "token_endpoint_auth_methods": []string{"client_secret_basic"}, + "clients": models.TreeClients{ + "123": models.TreeClient{ClientName: "client1"}, + }, + "scopes_without_service": models.TreeScopes{ + "456": models.TreeScope{Description: "some scope"}, + }, + }, + filters: []string{utils.RootFilter}, + rootKeys: utils.ServerRootKeys, + expected: models.Rfc7396PatchOperation{ + "name": "workspace1", + "grant_types": []string{"authorization_code"}, + "token_endpoint_auth_methods": []string{"client_secret_basic"}, + }, + }, + { + name: "root combined with a collection keeps both", + server: models.Rfc7396PatchOperation{ + "name": "workspace1", + "clients": models.TreeClients{ + "123": models.TreeClient{ClientName: "client1"}, + }, + "idps": models.TreeIDPs{ + "456": models.TreeIDP{Name: "idp1"}, + }, + }, + filters: []string{utils.RootFilter, "clients"}, + rootKeys: utils.ServerRootKeys, + expected: models.Rfc7396PatchOperation{ + "name": "workspace1", + "clients": models.TreeClients{ + "123": models.TreeClient{ClientName: "client1"}, + }, + }, + }, + { + name: "root only keeps root-level tenant config and drops collections", + server: models.Rfc7396PatchOperation{ + "name": "tenant1", + "settings": map[string]any{"key": "value"}, + "pools": models.TreePools{ + "123": models.TreePool{Name: "pool1"}, + }, + "schemas": models.TreeSchemas{ + "456": models.TreeSchema{Name: "schema1"}, + }, + }, + filters: []string{utils.RootFilter}, + rootKeys: utils.TenantRootKeys, + expected: models.Rfc7396PatchOperation{ + "name": "tenant1", + "settings": map[string]any{"key": "value"}, + }, + }, + { + name: "root only on a patch of collections yields empty result", + server: models.Rfc7396PatchOperation{ + "clients": models.TreeClients{ + "123": models.TreeClient{ClientName: "client1"}, + }, + }, + filters: []string{utils.RootFilter}, + rootKeys: utils.ServerRootKeys, + expected: models.Rfc7396PatchOperation{}, + }, } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - actual, err := utils.FilterPatch(tc.server, tc.filters) + actual, err := utils.FilterPatch(tc.server, tc.filters, tc.rootKeys) require.NoError(t, err)