From b84c4888ddc5cbdae05effc809eabb76dbedcae7 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Fri, 3 Jul 2026 14:52:53 +0200 Subject: [PATCH 1/2] feat: add reserved --filter root value for root-level-only config Add a reserved `root` value to the --filter flag (pull, push, diff) that selects only root-level tenant/workspace config, excluding nested resource collections (clients, idps, pools, policies, etc.). `root` is composable with resource names, e.g. `--filter root,clients`, and resolves per context: FilterPatch now takes the set of known collection keys (TenantCollectionKeys / ServerCollectionKeys) and treats every other top-level key as root-level. Not a breaking change: `root` was previously a no-op filter value and all existing filter tokens are unaffected. --- cmd/diff.go | 5 +- cmd/pull.go | 5 +- cmd/push.go | 5 +- internal/cac/client/client.go | 2 +- internal/cac/client/tenant_client.go | 2 +- internal/cac/storage/server_storage.go | 2 +- internal/cac/storage/server_storage_test.go | 2 +- internal/cac/storage/tenant_storage.go | 2 +- internal/cac/storage/tenant_storage_test.go | 2 +- internal/cac/utils/model.go | 39 ++++++++- internal/cac/utils/model_test.go | 88 +++++++++++++++++++-- 11 files changed, 137 insertions(+), 17 deletions(-) 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..e24b02c 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.ServerCollectionKeys); 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..1dd7d31 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.TenantCollectionKeys); err != nil { return nil, err } diff --git a/internal/cac/storage/server_storage.go b/internal/cac/storage/server_storage.go index d43b098..7e5168a 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.ServerCollectionKeys); 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..9bf0f73 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.ServerCollectionKeys) 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..5898c98 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.TenantCollectionKeys); 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..70e5220 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.TenantCollectionKeys) 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..4bdafb8 100644 --- a/internal/cac/utils/model.go +++ b/internal/cac/utils/model.go @@ -73,14 +73,51 @@ 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. every top-level key that is not a known nested sub-resource collection. +const RootFilter = "root" + +// TenantCollectionKeys are the top-level tenant keys that are nested sub-resource +// collections rather than root-level tenant config. They mirror the keys the +// tenant storage layer splits into separate files/directories. +var TenantCollectionKeys = []string{ + "pools", "schemas", "mfa_methods", "themes", "servers", +} + +// ServerCollectionKeys are the top-level workspace keys that are nested sub-resource +// collections rather than root-level workspace config. They mirror the keys the +// server storage layer splits into separate files/directories. +var ServerCollectionKeys = []string{ + "clients", "idps", "claims", "custom_apps", "gateways", + "policy_execution_points", "pools", "scopes_without_service", + "script_execution_points", "server_consent", "ciba_authentication_service", + "servers_bindings", "services", "theme_binding", "webhooks", "scripts", + "policies", +} + +func FilterPatch(patch models.Rfc7396PatchOperation, filters []string, collections []string) (models.Rfc7396PatchOperation, error) { if len(filters) == 0 { return patch, nil } var newPatch = models.Rfc7396PatchOperation{} + collectionSet := make(map[string]struct{}, len(collections)) + for _, c := range collections { + collectionSet[c] = struct{}{} + } + for _, filter := range filters { + if filter == RootFilter { + for k, v := range patch { + if _, isCollection := collectionSet[k]; !isCollection { + 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..39efe4b 100644 --- a/internal/cac/utils/model_test.go +++ b/internal/cac/utils/model_test.go @@ -10,10 +10,11 @@ import ( func TestFilterPatch(t *testing.T) { tcs := []struct { - name string - server models.Rfc7396PatchOperation - filters []string - expected models.Rfc7396PatchOperation + name string + server models.Rfc7396PatchOperation + filters []string + collections []string + expected models.Rfc7396PatchOperation }{ { name: "only clients", @@ -29,7 +30,8 @@ func TestFilterPatch(t *testing.T) { }, }, }, - filters: []string{"clients"}, + filters: []string{"clients"}, + collections: utils.ServerCollectionKeys, 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"}, + collections: utils.ServerCollectionKeys, 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}, + collections: utils.ServerCollectionKeys, + 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"}, + collections: utils.ServerCollectionKeys, + 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}, + collections: utils.TenantCollectionKeys, + 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}, + collections: utils.ServerCollectionKeys, + 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.collections) require.NoError(t, err) From 8a171796a1c878794faf09991ec466c34c9fb0d9 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Fri, 3 Jul 2026 15:12:21 +0200 Subject: [PATCH 2/2] refactor: derive --filter root fields from the model instead of a static list Replace the hand-maintained TenantCollectionKeys/ServerCollectionKeys exclusion lists with root-field sets derived via reflection from the models the storage layer already serializes the root tenant/server file into (smodels.Tenant and smodels.ServerDump, with nested dependencies stripped). `--filter root` now keeps exactly the top-level keys defined on those root models, so new root fields added upstream are picked up automatically with no list to keep in sync. FilterPatch takes the root-key set instead of a collection exclusion list. --- internal/cac/client/client.go | 2 +- internal/cac/client/tenant_client.go | 2 +- internal/cac/storage/server_storage.go | 2 +- internal/cac/storage/server_storage_test.go | 2 +- internal/cac/storage/tenant_storage.go | 2 +- internal/cac/storage/tenant_storage_test.go | 2 +- internal/cac/utils/model.go | 55 ++++++++++++--------- internal/cac/utils/model_test.go | 38 +++++++------- 8 files changed, 56 insertions(+), 49 deletions(-) diff --git a/internal/cac/client/client.go b/internal/cac/client/client.go index e24b02c..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, utils.ServerCollectionKeys); 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 1dd7d31..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, utils.TenantCollectionKeys); 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 7e5168a..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, utils.ServerCollectionKeys); 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 9bf0f73..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, utils.ServerCollectionKeys) + 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 5898c98..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, utils.TenantCollectionKeys); 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 70e5220..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, utils.TenantCollectionKeys) + 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 4bdafb8..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" ) @@ -74,43 +78,46 @@ var staticFilterMappings = map[string]string{ } // RootFilter is a reserved --filter value that selects only root-level config, -// i.e. every top-level key that is not a known nested sub-resource collection. +// i.e. the tenant/workspace's own fields, excluding nested sub-resources. const RootFilter = "root" -// TenantCollectionKeys are the top-level tenant keys that are nested sub-resource -// collections rather than root-level tenant config. They mirror the keys the -// tenant storage layer splits into separate files/directories. -var TenantCollectionKeys = []string{ - "pools", "schemas", "mfa_methods", "themes", "servers", -} +// 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]()) +) -// ServerCollectionKeys are the top-level workspace keys that are nested sub-resource -// collections rather than root-level workspace config. They mirror the keys the -// server storage layer splits into separate files/directories. -var ServerCollectionKeys = []string{ - "clients", "idps", "claims", "custom_apps", "gateways", - "policy_execution_points", "pools", "scopes_without_service", - "script_execution_points", "server_consent", "ciba_authentication_service", - "servers_bindings", "services", "theme_binding", "webhooks", "scripts", - "policies", +// 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, collections []string) (models.Rfc7396PatchOperation, error) { +func FilterPatch(patch models.Rfc7396PatchOperation, filters []string, rootKeys map[string]struct{}) (models.Rfc7396PatchOperation, error) { if len(filters) == 0 { return patch, nil } var newPatch = models.Rfc7396PatchOperation{} - collectionSet := make(map[string]struct{}, len(collections)) - for _, c := range collections { - collectionSet[c] = struct{}{} - } - for _, filter := range filters { if filter == RootFilter { - for k, v := range patch { - if _, isCollection := collectionSet[k]; !isCollection { + for k := range rootKeys { + if v, ok := patch[k]; ok { newPatch[k] = v } } diff --git a/internal/cac/utils/model_test.go b/internal/cac/utils/model_test.go index 39efe4b..9604386 100644 --- a/internal/cac/utils/model_test.go +++ b/internal/cac/utils/model_test.go @@ -10,11 +10,11 @@ import ( func TestFilterPatch(t *testing.T) { tcs := []struct { - name string - server models.Rfc7396PatchOperation - filters []string - collections []string - expected models.Rfc7396PatchOperation + name string + server models.Rfc7396PatchOperation + filters []string + rootKeys map[string]struct{} + expected models.Rfc7396PatchOperation }{ { name: "only clients", @@ -30,8 +30,8 @@ func TestFilterPatch(t *testing.T) { }, }, }, - filters: []string{"clients"}, - collections: utils.ServerCollectionKeys, + filters: []string{"clients"}, + rootKeys: utils.ServerRootKeys, expected: models.Rfc7396PatchOperation{ "clients": models.TreeClients{ "123": models.TreeClient{ @@ -57,8 +57,8 @@ func TestFilterPatch(t *testing.T) { Type: "asd", }, }, - filters: []string{"scopes", "ciba"}, - collections: utils.ServerCollectionKeys, + filters: []string{"scopes", "ciba"}, + rootKeys: utils.ServerRootKeys, expected: models.Rfc7396PatchOperation{ "scopes_without_service": models.TreeScopes{ "456": models.TreeScope{ @@ -83,8 +83,8 @@ func TestFilterPatch(t *testing.T) { "456": models.TreeScope{Description: "some scope"}, }, }, - filters: []string{utils.RootFilter}, - collections: utils.ServerCollectionKeys, + filters: []string{utils.RootFilter}, + rootKeys: utils.ServerRootKeys, expected: models.Rfc7396PatchOperation{ "name": "workspace1", "grant_types": []string{"authorization_code"}, @@ -102,8 +102,8 @@ func TestFilterPatch(t *testing.T) { "456": models.TreeIDP{Name: "idp1"}, }, }, - filters: []string{utils.RootFilter, "clients"}, - collections: utils.ServerCollectionKeys, + filters: []string{utils.RootFilter, "clients"}, + rootKeys: utils.ServerRootKeys, expected: models.Rfc7396PatchOperation{ "name": "workspace1", "clients": models.TreeClients{ @@ -123,8 +123,8 @@ func TestFilterPatch(t *testing.T) { "456": models.TreeSchema{Name: "schema1"}, }, }, - filters: []string{utils.RootFilter}, - collections: utils.TenantCollectionKeys, + filters: []string{utils.RootFilter}, + rootKeys: utils.TenantRootKeys, expected: models.Rfc7396PatchOperation{ "name": "tenant1", "settings": map[string]any{"key": "value"}, @@ -137,15 +137,15 @@ func TestFilterPatch(t *testing.T) { "123": models.TreeClient{ClientName: "client1"}, }, }, - filters: []string{utils.RootFilter}, - collections: utils.ServerCollectionKeys, - expected: models.Rfc7396PatchOperation{}, + 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, tc.collections) + actual, err := utils.FilterPatch(tc.server, tc.filters, tc.rootKeys) require.NoError(t, err)