diff --git a/cmd/root/main.go b/cmd/root/main.go index e687987..e655104 100644 --- a/cmd/root/main.go +++ b/cmd/root/main.go @@ -99,7 +99,7 @@ func loadConfig() *config.RootConfig { StartKind: "K0", EndKind: "K1", }, - Labels: spec.Labels{ + SelectorLabels: spec.SelectorLabels{ "environment": "production", }, KeyBindings: map[string]spec.KeyBinding{ @@ -153,7 +153,7 @@ func loadConfig() *config.RootConfig { }, }, }, - Labels: spec.Labels{ + SelectorLabels: spec.SelectorLabels{ "cloud": "aws", }, }, diff --git a/internal/config/config.go b/internal/config/config.go index 173892c..65e9b50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,14 +40,14 @@ type KryptonRoot struct { // RootConfig is the complete configuration for the root instance combining hierarchy and topology. type RootConfig struct { - Name string `yaml:"name"` - Role spec.AgentRole `yaml:"role"` - Segment spec.HierarchySegment `yaml:"segment"` - Labels spec.Labels `yaml:"labels,omitempty"` - KeyBindings map[string]spec.KeyBinding `yaml:"key_bindings"` - Hierarchy spec.KeyHierarchy `yaml:"hierarchy"` - Topology spec.Topology `yaml:"topology"` - Reconciler ReconcilerConfig `yaml:"reconciler"` + Name string `yaml:"name"` + Role spec.AgentRole `yaml:"role"` + Segment spec.HierarchySegment `yaml:"segment"` + SelectorLabels spec.SelectorLabels `yaml:"selector_labels,omitempty"` + KeyBindings map[string]spec.KeyBinding `yaml:"key_bindings"` + Hierarchy spec.KeyHierarchy `yaml:"hierarchy"` + Topology spec.Topology `yaml:"topology"` + Reconciler ReconcilerConfig `yaml:"reconciler"` } // AgentBootstrapConfig is the minimal configuration that agents load from file on startup. It contains just enough information to connect to root. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 088eb0e..72d009b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -18,7 +18,7 @@ func validRootConfig() *RootConfig { StartKind: "K0", EndKind: "K1", }, - Labels: spec.Labels{"env": "prod"}, + SelectorLabels: spec.SelectorLabels{"env": "prod"}, KeyBindings: map[string]spec.KeyBinding{ "K0": { Vault: spec.VaultSpec{Name: "v", Type: "aws-kms"}, @@ -31,8 +31,20 @@ func validRootConfig() *RootConfig { Hierarchy: spec.KeyHierarchy{ Name: "h", KeySpecs: []spec.KeySpec{ - {Kind: "K0", Role: spec.KeyRoleRoot, Algorithm: spec.KeyAlgorithmAES256}, - {Kind: "K1", Role: spec.KeyRoleDek, Algorithm: spec.KeyAlgorithmAES256}, + { + Kind: "K0", Role: spec.KeyRoleRoot, Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, + }, + { + Kind: "K1", Role: spec.KeyRoleDek, Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + }, + }, + }, }, }, Topology: spec.Topology{}, @@ -103,6 +115,22 @@ func TestValidateRootConfig(t *testing.T) { }, wantErr: spec.ErrAgentNameEmpty, }, + { + name: "invalid LabelsSpec in hierarchy", + modify: func(c *RootConfig) { + c.Hierarchy.KeySpecs[0].LabelsSpec = spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: "invalid", + }, + }, + }, + } + }, + wantErr: spec.ErrLabelValidatorInvalidType, + }, { name: "invalid segment", modify: func(c *RootConfig) { c.Segment.StartKind = "" }, @@ -129,9 +157,9 @@ func TestValidateRootConfig(t *testing.T) { c.Hierarchy = spec.KeyHierarchy{ Name: "h", KeySpecs: []spec.KeySpec{ - {Kind: "K0", Role: spec.KeyRoleRoot, Algorithm: spec.KeyAlgorithmAES256}, - {Kind: "K1", Role: spec.KeyRoleKek, Algorithm: spec.KeyAlgorithmAES256}, - {Kind: "K2", Role: spec.KeyRoleDek, Algorithm: spec.KeyAlgorithmAES256}, + {Kind: "K0", Role: spec.KeyRoleRoot, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, + {Kind: "K1", Role: spec.KeyRoleKek, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, + {Kind: "K2", Role: spec.KeyRoleDek, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, }, } c.Segment = spec.HierarchySegment{StartKind: "K1", EndKind: "K2"} @@ -149,9 +177,9 @@ func TestValidateRootConfig(t *testing.T) { c.Hierarchy = spec.KeyHierarchy{ Name: "h", KeySpecs: []spec.KeySpec{ - {Kind: "K0", Role: spec.KeyRoleRoot, Algorithm: spec.KeyAlgorithmAES256}, - {Kind: "K1", Role: spec.KeyRoleTek, Algorithm: spec.KeyAlgorithmAES256}, - {Kind: "K2", Role: spec.KeyRoleDek, Algorithm: spec.KeyAlgorithmAES256}, + {Kind: "K0", Role: spec.KeyRoleRoot, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, + {Kind: "K1", Role: spec.KeyRoleTek, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, + {Kind: "K2", Role: spec.KeyRoleDek, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, }, } c.Segment = spec.HierarchySegment{StartKind: "K0", EndKind: "K1"} @@ -169,9 +197,9 @@ func TestValidateRootConfig(t *testing.T) { c.Hierarchy = spec.KeyHierarchy{ Name: "h", KeySpecs: []spec.KeySpec{ - {Kind: "K0", Role: spec.KeyRoleRoot, Algorithm: spec.KeyAlgorithmAES256}, - {Kind: "K1", Role: spec.KeyRoleKek, Algorithm: spec.KeyAlgorithmAES256}, - {Kind: "K2", Role: spec.KeyRoleDek, Algorithm: spec.KeyAlgorithmAES256}, + {Kind: "K0", Role: spec.KeyRoleRoot, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, + {Kind: "K1", Role: spec.KeyRoleKek, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, + {Kind: "K2", Role: spec.KeyRoleDek, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, }, } c.Segment = spec.HierarchySegment{StartKind: "K0", EndKind: "K1"} @@ -199,10 +227,10 @@ func TestValidateRootConfig(t *testing.T) { c.Hierarchy = spec.KeyHierarchy{ Name: "h", KeySpecs: []spec.KeySpec{ - {Kind: "K0", Role: spec.KeyRoleRoot, Algorithm: spec.KeyAlgorithmAES256}, - {Kind: "K1", Role: spec.KeyRoleKek, Algorithm: spec.KeyAlgorithmAES256}, - {Kind: "K2", Role: spec.KeyRoleTek, Algorithm: spec.KeyAlgorithmAES256}, - {Kind: "K3", Role: spec.KeyRoleDek, Algorithm: spec.KeyAlgorithmAES256}, + {Kind: "K0", Role: spec.KeyRoleRoot, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, + {Kind: "K1", Role: spec.KeyRoleKek, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, + {Kind: "K2", Role: spec.KeyRoleTek, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, + {Kind: "K3", Role: spec.KeyRoleDek, Algorithm: spec.KeyAlgorithmAES256, LabelsSpec: validLabelsSpec()}, }, } c.Segment = spec.HierarchySegment{StartKind: "K0", EndKind: "K1"} @@ -291,7 +319,7 @@ role: "root" segment: start_kind: "K0" end_kind: "K1" -labels: +selector_labels: environment: "production" key_bindings: K0: @@ -310,15 +338,37 @@ hierarchy: - kind: "K0" role: "root" algorithm: "AES256" + labels_spec: + allow_user_labels: true - kind: "K1" role: "kek" algorithm: "AES256" + labels_spec: + allow_user_labels: true - kind: "K2" role: "tek" algorithm: "AES256" + labels_spec: + allow_user_labels: true + requirements: + type: + is_required: true + validator: + type: enum + params: + values: "production,staging,development" - kind: "K3" role: "dek" algorithm: "AES256" + labels_spec: + allow_user_labels: true + requirements: + env: + is_required: true + validator: + type: regex + params: + pattern: "^(production|staging|development)$" topology: segments: - name: "agent-aws" @@ -336,7 +386,7 @@ topology: vault: name: "aws-dek-vault" type: "in-memory" - labels: + selector_labels: cloud: "aws" reconciler: maxReconcileCount: 7 @@ -361,7 +411,7 @@ reconciler: assert.Equal(t, spec.AgentRole("root"), cfg.Role) assert.Equal(t, "K0", cfg.Segment.StartKind) assert.Equal(t, "K1", cfg.Segment.EndKind) - assert.Equal(t, "production", cfg.Labels["environment"]) + assert.Equal(t, "production", cfg.SelectorLabels["environment"]) assert.Len(t, cfg.KeyBindings, 2) assert.Equal(t, "root-hsm-vault", cfg.KeyBindings["K0"].Vault.Name) assert.Equal(t, "root-vault", cfg.KeyBindings["K1"].Vault.Name) @@ -511,3 +561,9 @@ krypton_root: assert.Contains(t, err.Error(), "failed to read file") }) } + +func validLabelsSpec() spec.LabelsSpec { + return spec.LabelsSpec{ + AllowUserLabels: true, + } +} diff --git a/internal/spec/agent.go b/internal/spec/agent.go index e752a6b..11dcbec 100644 --- a/internal/spec/agent.go +++ b/internal/spec/agent.go @@ -13,24 +13,24 @@ var ( // AgentConfig represents the configuration for an agent, including its name, key bindings, segment, labels, role, hierarchy, and keep-alive settings. type AgentConfig struct { - Name string `json:"name"` - KeyBindings map[string]KeyBinding `json:"key_bindings"` - Segment HierarchySegment `json:"segment"` - Labels Labels `json:"labels"` - Role AgentRole `json:"role"` - Hierarchy KeyHierarchy `json:"hierarchy"` - KeepAlive KeepAliveConfig `json:"keep_alive"` + Name string `json:"name"` + KeyBindings map[string]KeyBinding `json:"key_bindings"` + Segment HierarchySegment `json:"segment"` + SelectorLabels SelectorLabels `json:"selector_labels"` + Role AgentRole `json:"role"` + Hierarchy KeyHierarchy `json:"hierarchy"` + KeepAlive KeepAliveConfig `json:"keep_alive"` } // NewAgentConfig creates a new AgentConfig based on the provided KeyHierarchy and TopologySegment. func NewAgentConfig(h KeyHierarchy, seg TopologySegment) AgentConfig { return AgentConfig{ - Name: seg.Name, - KeyBindings: seg.KeyBindings, - Segment: seg.Segment, - Labels: seg.Labels, - Role: DefaultRole, - Hierarchy: h, - KeepAlive: 30, + Name: seg.Name, + KeyBindings: seg.KeyBindings, + Segment: seg.Segment, + SelectorLabels: seg.SelectorLabels, + Role: DefaultRole, + Hierarchy: h, + KeepAlive: 30, } } diff --git a/internal/spec/agent_test.go b/internal/spec/agent_test.go index cd8b885..0b7b475 100644 --- a/internal/spec/agent_test.go +++ b/internal/spec/agent_test.go @@ -11,8 +11,8 @@ import ( func TestNewAgentConfig(t *testing.T) { // given topologySegment := spec.TopologySegment{ - Name: "segment1", - Labels: map[string]string{"region": "us-west"}, + Name: "segment1", + SelectorLabels: map[string]string{"region": "us-west"}, Segment: spec.HierarchySegment{ StartKind: "K2", EndKind: "K2", @@ -21,7 +21,6 @@ func TestNewAgentConfig(t *testing.T) { "binding1": { Vault: spec.VaultSpec{}, ParentKeyProvider: &spec.ParentKeyProviderRef{}, - Labels: spec.Labels{}, }, }, } @@ -42,13 +41,13 @@ func TestNewAgentConfig(t *testing.T) { } expConfig := spec.AgentConfig{ - Name: "segment1", - KeyBindings: topologySegment.KeyBindings, - Segment: topologySegment.Segment, - Labels: topologySegment.Labels, - Role: spec.DefaultRole, - Hierarchy: expHierarchy, - KeepAlive: 30, + Name: "segment1", + KeyBindings: topologySegment.KeyBindings, + Segment: topologySegment.Segment, + SelectorLabels: topologySegment.SelectorLabels, + Role: spec.DefaultRole, + Hierarchy: expHierarchy, + KeepAlive: 30, } // when diff --git a/internal/spec/export_test.go b/internal/spec/export_test.go index 23476f4..f5ac876 100644 --- a/internal/spec/export_test.go +++ b/internal/spec/export_test.go @@ -1,6 +1,10 @@ package spec var ( - ValidKeyUsageNames = validKeyUsageNames - ValidKeyUsages = validKeyUsages + ValidKeyUsageNames = validKeyUsageNames + ValidKeyUsages = validKeyUsages + InitLabelsSpec = (*LabelsSpec).init + InitLabelRequirement = (*LabelRequirement).init + InitLabelValidator = (*LabelValidator).init + ValidateLabelValidator = (*LabelValidator).validate ) diff --git a/internal/spec/keyhierarchy_test.go b/internal/spec/keyhierarchy_test.go index c64ae7e..f66df65 100644 --- a/internal/spec/keyhierarchy_test.go +++ b/internal/spec/keyhierarchy_test.go @@ -21,9 +21,10 @@ func TestKeyHierarchy(t *testing.T) { Name: "", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -51,9 +52,10 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -65,9 +67,10 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -79,9 +82,10 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleTek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleTek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -93,14 +97,16 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K0", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -112,38 +118,58 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: "", + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: "", + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, expErr: spec.ErrKeySpecAlgorithmInvalid, }, + { + name: "should return error if a key spec in the keys list has an invalid LabelsSpec", + input: &spec.KeyHierarchy{ + Name: "production-hierarchy", + KeySpecs: []spec.KeySpec{ + { + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: false}, + }, + }, + }, + expErr: spec.ErrLabelsSpecRequirementEmpty, + }, { name: "should return error if there are multiple 'root' keys", input: &spec.KeyHierarchy{ Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K1", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K1", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K2", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K2", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K3", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K3", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -155,19 +181,22 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K1", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K1", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K2", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K2", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -179,29 +208,34 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K1", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K1", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K2", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K2", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K3", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K3", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K4", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K4", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -213,24 +247,28 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K1", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K1", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K2", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K2", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K3", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K3", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -242,14 +280,16 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K1", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K1", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -261,14 +301,16 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K1", - Role: spec.KeyRoleTek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K1", + Role: spec.KeyRoleTek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -280,14 +322,16 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K1", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K1", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -299,19 +343,22 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K1", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K1", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K2", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K2", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -323,29 +370,34 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K1", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K1", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K2", - Role: spec.KeyRoleTek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K2", + Role: spec.KeyRoleTek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K3", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K3", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, { - Kind: "K4", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K4", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, @@ -357,9 +409,10 @@ func TestKeyHierarchy(t *testing.T) { Name: "production-hierarchy", KeySpecs: []spec.KeySpec{ { - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, }, diff --git a/internal/spec/keyspec.go b/internal/spec/keyspec.go index d6bcc25..a020269 100644 --- a/internal/spec/keyspec.go +++ b/internal/spec/keyspec.go @@ -50,9 +50,10 @@ var ( // KeySpec defines the properties of a key within a hierarchy, including its kind, role, and algorithm. type KeySpec struct { - Kind KeyKind `yaml:"kind"` - Role KeyRole `yaml:"role"` - Algorithm KeyAlgorithm `yaml:"algorithm"` + Kind KeyKind `yaml:"kind"` + Role KeyRole `yaml:"role"` + Algorithm KeyAlgorithm `yaml:"algorithm"` + LabelsSpec LabelsSpec `yaml:"labels_spec"` } // Usage returns the KeyUsage associated with the KeySpec's role. @@ -87,6 +88,10 @@ func (k KeySpec) Validate() error { return ErrKeySpecAlgorithmInvalid } + if err := k.LabelsSpec.init(); err != nil { + return err + } + return nil } diff --git a/internal/spec/keyspec_test.go b/internal/spec/keyspec_test.go index 591162f..accfc60 100644 --- a/internal/spec/keyspec_test.go +++ b/internal/spec/keyspec_test.go @@ -83,70 +83,88 @@ func TestKeySpec(t *testing.T) { { name: "should return error if kind is empty", input: spec.KeySpec{ - Kind: "", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, expErr: spec.ErrKeySpecKindEmpty, }, { name: "should return error if role is invalid", input: spec.KeySpec{ - Kind: "K0", - Role: "invalid-role", - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: "invalid-role", + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, expErr: spec.ErrKeySpecRoleInvalid, }, { name: "should return error if algorithm is empty", input: spec.KeySpec{ - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: "", + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: "", + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, expErr: spec.ErrKeySpecAlgorithmInvalid, }, { name: "should return error if algorithm is invalid", input: spec.KeySpec{ - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: "some-invalid-algorithm", + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: "some-invalid-algorithm", + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, expErr: spec.ErrKeySpecAlgorithmInvalid, }, { name: "should return nil if role is 'root'", input: spec.KeySpec{ - Kind: "K0", - Role: spec.KeyRoleRoot, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleRoot, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, { name: "should return nil if role is 'kek'", input: spec.KeySpec{ - Kind: "K0", - Role: spec.KeyRoleKek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleKek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, { name: "should return nil if role is 'dek'", input: spec.KeySpec{ - Kind: "K0", - Role: spec.KeyRoleDek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleDek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, }, }, { name: "should return nil if role is 'tek'", input: spec.KeySpec{ - Kind: "K0", - Role: spec.KeyRoleTek, - Algorithm: spec.KeyAlgorithmAES256, + Kind: "K0", + Role: spec.KeyRoleTek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: true}, + }, + }, + { + name: "should return error if LabelsSpec validate fails", + input: spec.KeySpec{ + Kind: "K0", + Role: spec.KeyRoleTek, + Algorithm: spec.KeyAlgorithmAES256, + LabelsSpec: spec.LabelsSpec{AllowUserLabels: false}, }, + expErr: spec.ErrLabelsSpecRequirementEmpty, }, } diff --git a/internal/spec/labelrequirement.go b/internal/spec/labelrequirement.go new file mode 100644 index 0000000..086d22d --- /dev/null +++ b/internal/spec/labelrequirement.go @@ -0,0 +1,15 @@ +package spec + +// LabelRequirement defines whether a label is required and how to validate its value. +type LabelRequirement struct { + IsRequired bool `yaml:"is_required"` // If true, the label must be present + Validator *LabelValidator `yaml:"validator,omitempty"` // Optional value validator +} + +// init performs initialization and validation of the LabelRequirement. +func (lr *LabelRequirement) init() error { + if lr.Validator != nil { + return lr.Validator.init() + } + return nil +} diff --git a/internal/spec/labelrequirement_test.go b/internal/spec/labelrequirement_test.go new file mode 100644 index 0000000..b7e5b67 --- /dev/null +++ b/internal/spec/labelrequirement_test.go @@ -0,0 +1,60 @@ +package spec_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/openkcm/krypton/internal/spec" +) + +func TestLabelRequirementValidate(t *testing.T) { + t.Run("Validate", func(t *testing.T) { + tts := []struct { + name string + subj *spec.LabelRequirement + expErr error + }{ + { + name: "should not return error for nil validator", + subj: &spec.LabelRequirement{ + IsRequired: true, + Validator: nil, + }, + }, + { + name: "should return error for invalid regex validator", + subj: &spec.LabelRequirement{ + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: "invalid", + }, + }, + expErr: spec.ErrLabelValidatorInvalidType, + }, + { + name: "should not return error for valid regex validator", + subj: &spec.LabelRequirement{ + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^[a-zA-Z0-9]+$", + }, + }, + }, + expErr: nil, + }, + } + + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + // when + err := spec.InitLabelRequirement(tt.subj) + + // then + assert.ErrorIs(t, err, tt.expErr) + }) + } + }) +} diff --git a/internal/spec/labelsspec.go b/internal/spec/labelsspec.go new file mode 100644 index 0000000..1c6ca77 --- /dev/null +++ b/internal/spec/labelsspec.go @@ -0,0 +1,105 @@ +package spec + +import ( + "errors" + "fmt" + "maps" + + "github.com/openkcm/krypton/pkg/model" +) + +// LabelsSpec defines validation requirements for labels attached to topology segments. +// It enforces both structural constraints (required vs optional) and value constraints +// (regex patterns or enum values). +// +// When AllowUserLabels is true, labels beyond the declared requirements are permitted +// and an empty Requirements map is considered valid. When false (the default), only +// labels that match a declared requirement are accepted and at least one requirement +// must be defined. +// +// Example YAML configuration: +// +// allow_user_labels: false +// requirements: +// env: +// is_required: true +// validator: +// type: regex +// params: +// pattern: "^(production|staging|development)$" +// region: +// is_required: false +// validator: +// type: enum +// params: +// values: "us-east-1,us-west-2,eu-west-1" +type LabelsSpec struct { + Requirements map[string]LabelRequirement `yaml:"requirements,omitempty"` + AllowUserLabels bool `yaml:"allow_user_labels"` +} + +var ( + ErrLabelsValidationFailed = errors.New("labels validation failed") + ErrLabelsSpecRequirementEmpty = errors.New("label spec requirement is empty") +) + +// Validate validates actual label values against the spec requirements. +// It checks that: +// - All required labels are present +// - All label values pass their validator constraints +// - No unexpected labels are present when AllowUserLabels is false (strict validation) +// +// When AllowUserLabels is true, labels that do not match any requirement are +// silently accepted without validation. +func (ls *LabelsSpec) Validate(l model.Labels) error { + for reqName, req := range ls.Requirements { + value, exists := l[reqName] + if req.IsRequired && !exists { + return fmt.Errorf("%w: missing required label '%s'", ErrLabelsValidationFailed, reqName) + } + if !exists { + continue + } + + validator := req.Validator + if validator != nil { + if err := validator.validate(value); err != nil { + return fmt.Errorf("label '%s': %w", reqName, err) + } + } + } + + if !ls.AllowUserLabels { + for lbName := range l { + if _, exists := ls.Requirements[lbName]; !exists { + return fmt.Errorf("%w: unexpected label '%s'", ErrLabelsValidationFailed, lbName) + } + } + } + + return nil +} + +// Merge merges another LabelsSpec into the current one. +func (ls *LabelsSpec) Merge(src LabelsSpec) { + if ls.Requirements == nil { + ls.Requirements = make(map[string]LabelRequirement) + } + maps.Copy(ls.Requirements, src.Requirements) + ls.AllowUserLabels = src.AllowUserLabels +} + +// init performs initialization and validation of the LabelsSpec. +// It checks that if AllowUserLabels is false, at least one requirement is defined. +// It also validates each individual LabelRequirement. +func (ls *LabelsSpec) init() error { + if !ls.AllowUserLabels && len(ls.Requirements) == 0 { + return fmt.Errorf("%w: no label requirements defined", ErrLabelsSpecRequirementEmpty) + } + for _, req := range ls.Requirements { + if err := req.init(); err != nil { + return err + } + } + return nil +} diff --git a/internal/spec/labelsspec_test.go b/internal/spec/labelsspec_test.go new file mode 100644 index 0000000..ac94df7 --- /dev/null +++ b/internal/spec/labelsspec_test.go @@ -0,0 +1,525 @@ +package spec_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/openkcm/krypton/internal/spec" + "github.com/openkcm/krypton/pkg/model" +) + +func TestLabelsSpecValidate(t *testing.T) { + t.Run("Validate", func(t *testing.T) { + // given + tts := []struct { + name string + subj spec.LabelsSpec + expErr error + }{ + { + name: "should return error for label spec with empty requirements and allowUserLabels is false", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{}, + }, + expErr: spec.ErrLabelsSpecRequirementEmpty, + }, + { + name: "should return error for label spec with nil requirements and allowUserLabels is false", + subj: spec.LabelsSpec{ + Requirements: nil, + }, + expErr: spec.ErrLabelsSpecRequirementEmpty, + }, + { + name: "should return nil for label spec with no requirements but allowUserLabels is true", + subj: spec.LabelsSpec{ + AllowUserLabels: true, + Requirements: map[string]spec.LabelRequirement{}, + }, + expErr: nil, + }, + { + name: "should return error for invalid validator even when allowUserLabels is true", + subj: spec.LabelsSpec{ + AllowUserLabels: true, + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: "invalid", + }, + }, + }, + }, + expErr: spec.ErrLabelValidatorInvalidType, + }, + { + name: "should return error for invalid validator type in label requirement", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "": { + IsRequired: false, + Validator: &spec.LabelValidator{ + Type: "invalid", + }, + }, + }, + }, + expErr: spec.ErrLabelValidatorInvalidType, + }, + { + name: "should return error for empty label spec", + subj: spec.LabelsSpec{}, + expErr: spec.ErrLabelsSpecRequirementEmpty, + }, + { + name: "should return nil for valid label spec", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "production,staging,development", + }, + }, + }, + }, + }, + expErr: nil, + }, + { + name: "should return nil for label spec with no validators", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + "region": { + IsRequired: false, + }, + }, + }, + expErr: nil, + }, + { + name: "should return error for label spec with invalid regex pattern in validator", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^(production|staging|development$", + }, + }, + }, + }, + }, + expErr: spec.ErrLabelValidatorInvalidRegexPattern, + }, + } + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + // when + err := spec.InitLabelsSpec(&tt.subj) + + // then + assert.ErrorIs(t, err, tt.expErr) + }) + } + }) +} + +func TestLabelsSpecValidateLabels(t *testing.T) { + t.Run("ValidateLabels", func(t *testing.T) { + tts := []struct { + name string + subj spec.LabelsSpec + labels model.Labels + expErr error + }{ + { + name: "should return error for missing required label", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{}, + }, + }, + }, + labels: model.Labels{}, + expErr: spec.ErrLabelsValidationFailed, + }, + { + name: "should return nil for missing optional label", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: false, + Validator: &spec.LabelValidator{}, + }, + }, + }, + labels: model.Labels{}, + expErr: nil, + }, + { + name: "should return nil if an unexpected label is present and allowUserLabels is true", + subj: spec.LabelsSpec{ + AllowUserLabels: true, + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + }, + }, + labels: model.Labels{ + "version": "1.0", + "env": "production", + }, + }, + { + name: "should return error if an unexpected label is present and allowUserLabels is false", + subj: spec.LabelsSpec{ + AllowUserLabels: false, + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + }, + }, + labels: model.Labels{ + "version": "1.0", + "env": "production", + }, + expErr: spec.ErrLabelsValidationFailed, + }, + { + name: "should return error if the regex validation fails", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^(production|staging|development)$", + }, + }, + }, + }, + }, + labels: model.Labels{ + "env": "invalid_env", + }, + expErr: spec.ErrLabelValidatorRegexFailed, + }, + { + name: "should return nil if the regex validation passes", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^(production|staging|development)$", + }, + }, + }, + }, + }, + labels: model.Labels{ + "env": "production", + }, + expErr: nil, + }, + { + name: "should return error for invalid regex pattern in validator", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^(production|staging|development$", + }, + }, + }, + }, + }, + labels: model.Labels{ + "env": "production", + }, + expErr: spec.ErrLabelValidatorInvalidRegexPattern, + }, + { + name: "should return error if enum validation fails", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "production,staging,development", + }, + }, + }, + }, + }, + labels: model.Labels{ + "env": "invalid_env", + }, + expErr: spec.ErrLabelValidatorEnumFailed, + }, + { + name: "should return nil if enum validation succeeds", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "production,staging,development", + }, + }, + }, + }, + }, + labels: model.Labels{ + "env": "staging", + }, + }, + { + name: "should return nil for valid labels with multiple requirements", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "production,staging", + }, + }, + }, + "region": { + IsRequired: false, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^[a-z]+-[a-z]+-[0-9]+$", + }, + }, + }, + }, + }, + labels: model.Labels{ + "env": "production", + "region": "us-east-1", + }, + expErr: nil, + }, + { + name: "should return nil for valid labels", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + }, + }, + labels: model.Labels{ + "env": "production", + }, + expErr: nil, + }, + { + name: "should return error for nil labels when required label exists", + subj: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + }, + }, + labels: nil, + expErr: spec.ErrLabelsValidationFailed, + }, + { + name: "should return nil for nil labels when no required labels exist", + subj: spec.LabelsSpec{ + AllowUserLabels: true, + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: false, + }, + }, + }, + labels: nil, + expErr: nil, + }, + } + + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + // when + err := tt.subj.Validate(tt.labels) + + // then + assert.ErrorIs(t, err, tt.expErr) + }) + } + }) +} + +func TestLabelsSpecMerge(t *testing.T) { + t.Run("Merge", func(t *testing.T) { + tts := []struct { + name string + dst spec.LabelsSpec + src spec.LabelsSpec + exp spec.LabelsSpec + }{ + { + name: "should merge requirements and allowUserLabels from src to dst", + dst: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + }, + AllowUserLabels: false, + }, + src: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "region": { + IsRequired: false, + }, + }, + AllowUserLabels: true, + }, + exp: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + "region": { + IsRequired: false, + }, + }, + AllowUserLabels: true, + }, + }, + { + name: "should initialize dst requirements map if it is nil before merging", + dst: spec.LabelsSpec{ + Requirements: nil, + AllowUserLabels: false, + }, + src: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + }, + AllowUserLabels: false, + }, + exp: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + }, + AllowUserLabels: false, + }, + }, + { + name: "should overwrite dst requirements and allowUserLabels with src values", + dst: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "production,staging", + }, + }, + }, + }, + AllowUserLabels: false, + }, + src: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: false, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "development,acceptance", + }, + }, + }, + }, + AllowUserLabels: true, + }, + exp: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: false, + Validator: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "development,acceptance", + }, + }, + }, + }, + AllowUserLabels: true, + }, + }, + { + name: "should merge if the src requirement is nil and allowUserLabels is false", + dst: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + }, + AllowUserLabels: true, + }, + src: spec.LabelsSpec{ + Requirements: nil, + AllowUserLabels: false, + }, + exp: spec.LabelsSpec{ + Requirements: map[string]spec.LabelRequirement{ + "env": { + IsRequired: true, + }, + }, + AllowUserLabels: false, + }, + }, + } + + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + // when + tt.dst.Merge(tt.src) + + // then + assert.Equal(t, tt.exp, tt.dst) + }) + } + }) +} diff --git a/internal/spec/labelvalidator.go b/internal/spec/labelvalidator.go new file mode 100644 index 0000000..cc36f39 --- /dev/null +++ b/internal/spec/labelvalidator.go @@ -0,0 +1,126 @@ +package spec + +import ( + "errors" + "fmt" + "regexp" + "strings" + "sync" +) + +const ( + // ValidatorTypeRegex validates label values against a regular expression pattern + ValidatorTypeRegex ValidatorType = "regex" + // ValidatorTypeEnum validates label values against a comma-separated list of allowed values + ValidatorTypeEnum ValidatorType = "enum" + // ValidatorTypeRegexKey is the params key for regex pattern + ValidatorTypeRegexKey string = "pattern" + // ValidatorTypeEnumKey is the params key for enum values (comma-separated) + ValidatorTypeEnumKey string = "values" +) + +type ( + ValidatorType string + + // LabelValidator validates label values using either regex or enum constraints. + // Validation configuration is lazily compiled and cached for thread-safe reuse. + LabelValidator struct { + Type ValidatorType `yaml:"type"` // "regex" or "enum" + Params map[string]string `yaml:"params,omitempty"` // Configuration parameters + // Internal fields for caching compiled validators + regex *regexp.Regexp + enums map[string]struct{} + validateOnce sync.Once + validateErr error + } +) + +var ( + ErrLabelValidatorInvalidType = errors.New("invalid label validator type") + ErrLabelValidatorMissingRegexPattern = errors.New("missing regex pattern for regex validator") + ErrLabelValidatorInvalidRegexPattern = errors.New("invalid regex pattern") + ErrLabelValidatorMissingEnumValues = errors.New("missing enum values for enum validator") + ErrLabelValidatorEnumFailed = errors.New("enum validation failed") + ErrLabelValidatorRegexFailed = errors.New("regex validation failed") +) + +// init initializes the validator by compiling regex patterns or parsing enum values. +func (lv *LabelValidator) init() error { + if lv == nil { + return nil + } + + lv.validateOnce.Do(func() { + switch lv.Type { + case ValidatorTypeRegex: + pattern, ok := lv.Params[ValidatorTypeRegexKey] + if !ok { + lv.validateErr = ErrLabelValidatorMissingRegexPattern + return + } + lv.regex, lv.validateErr = regexp.Compile(pattern) + if lv.validateErr != nil { + lv.validateErr = fmt.Errorf("%w: invalid regex pattern '%s'", ErrLabelValidatorInvalidRegexPattern, pattern) + } + case ValidatorTypeEnum: + valueStr, ok := lv.Params[ValidatorTypeEnumKey] + if !ok { + lv.validateErr = ErrLabelValidatorMissingEnumValues + return + } + values := strings.Split(valueStr, ",") + lv.enums = make(map[string]struct{}, len(values)) + for _, v := range values { + lv.enums[strings.TrimSpace(v)] = struct{}{} + } + default: + lv.validateErr = ErrLabelValidatorInvalidType + } + }) + + return lv.validateErr +} + +// validate validates the given value against the validator's constraints. +// It returns an error if the value does not satisfy the constraints or if the validator is misconfigured. +func (lv *LabelValidator) validate(value string) error { + if lv == nil { + return nil + } + if err := lv.init(); err != nil { + return err + } + + switch lv.Type { + case ValidatorTypeRegex: + return lv.validateRegex(value) + case ValidatorTypeEnum: + return lv.validateEnum(value) + default: + return fmt.Errorf("%w: %s", ErrLabelValidatorInvalidType, lv.Type) + } +} + +// validateEnum checks if the value is one of the allowed enum values. +func (lv *LabelValidator) validateEnum(value string) error { + if len(lv.enums) == 0 { + return ErrLabelValidatorMissingEnumValues + } + _, exists := lv.enums[value] + if !exists { + return fmt.Errorf("%w: value '%s' is not in enum values", ErrLabelValidatorEnumFailed, value) + } + return nil +} + +// validateRegex checks if the value matches the compiled regex pattern. +func (lv *LabelValidator) validateRegex(value string) error { + if lv.regex == nil { + return ErrLabelValidatorMissingRegexPattern + } + if !lv.regex.MatchString(value) { + pattern := lv.Params[ValidatorTypeRegexKey] + return fmt.Errorf("%w: value '%s' does not match regex pattern '%s'", ErrLabelValidatorRegexFailed, value, pattern) + } + return nil +} diff --git a/internal/spec/labelvalidator_test.go b/internal/spec/labelvalidator_test.go new file mode 100644 index 0000000..5c52ccc --- /dev/null +++ b/internal/spec/labelvalidator_test.go @@ -0,0 +1,186 @@ +package spec_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/openkcm/krypton/internal/spec" +) + +func TestLabelValidatorValidate(t *testing.T) { + t.Run("Validate", func(t *testing.T) { + // given + tts := []struct { + name string + subj *spec.LabelValidator + expErr error + }{ + { + name: "should not return error for nil validator", + subj: nil, + }, + { + name: "should not return error for valid regex validator", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^[a-zA-Z0-9]+$", + }, + }, + }, + { + name: "should not return error for valid enum validator", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "value1,value2,value3", + }, + }, + }, + { + name: "should return error for invalid validator type", + subj: &spec.LabelValidator{ + Type: "invalid", + }, + expErr: spec.ErrLabelValidatorInvalidType, + }, + { + name: "should return error for missing regex pattern", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + }, + expErr: spec.ErrLabelValidatorMissingRegexPattern, + }, + { + name: "should return error for invalid regex pattern", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^[a-zA-Z0-9+$", + }, + }, + expErr: spec.ErrLabelValidatorInvalidRegexPattern, + }, + { + name: "should return error for missing enum values", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + }, + expErr: spec.ErrLabelValidatorMissingEnumValues, + }, + } + + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + // when + err := spec.InitLabelValidator(tt.subj) + + // then + assert.ErrorIs(t, err, tt.expErr) + }) + } + }) +} + +func TestLabelValidatorValidateLabel(t *testing.T) { + t.Run("ValidateLabel", func(t *testing.T) { + // given + tts := []struct { + name string + subj *spec.LabelValidator + value string + expErr error + }{ + { + name: "should return error for invalid regex value", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^[a-zA-Z0-9]+$", + }, + }, + value: "invalid value!", + expErr: spec.ErrLabelValidatorRegexFailed, + }, + { + name: "should return error for invalid regex pattern", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^[a-zA-Z0-9+$", + }, + }, + value: "validValue123", + expErr: spec.ErrLabelValidatorInvalidRegexPattern, + }, + { + name: "should return nil for valid regex value", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeRegex, + Params: map[string]string{ + spec.ValidatorTypeRegexKey: "^[a-zA-Z0-9]+$", + }, + }, + value: "validValue123", + expErr: nil, + }, + { + name: "should return error for invalid enum value", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "value1,value2,value3", + }, + }, + value: "value", + expErr: spec.ErrLabelValidatorEnumFailed, + }, + { + name: "should return nil for valid enum value", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "value1,value2,value3", + }, + }, + value: "value2", + expErr: nil, + }, + { + name: "should handle enum values with surrounding whitespace", + subj: &spec.LabelValidator{ + Type: spec.ValidatorTypeEnum, + Params: map[string]string{ + spec.ValidatorTypeEnumKey: "value1, value2 , value3", + }, + }, + value: "value2", + }, + { + name: "should return error for invalid validator type", + subj: &spec.LabelValidator{ + Type: "invalid", + }, + value: "anyValue", + expErr: spec.ErrLabelValidatorInvalidType, + }, + { + name: "should return nil for nil validator", + subj: nil, + value: "anyValue", + expErr: nil, + }, + } + + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + // when + err := spec.ValidateLabelValidator(tt.subj, tt.value) + + // then + assert.ErrorIs(t, err, tt.expErr) + }) + } + }) +} diff --git a/internal/spec/topology.go b/internal/spec/topology.go index 731bbdd..bbf172f 100644 --- a/internal/spec/topology.go +++ b/internal/spec/topology.go @@ -18,8 +18,8 @@ var ( ErrKeyBindingsEmpty = errors.New("key bindings cannot be empty") ) -// Labels is a key-value map for metadata -type Labels map[string]string +// SelectorLabels is a key-value map for metadata +type SelectorLabels map[string]string // VaultType defines the type of vault (e.g., "open-bao", "aws-kms", "gcp-kms") type VaultType string @@ -56,15 +56,14 @@ type VaultSpec struct { type KeyBinding struct { Vault VaultSpec `yaml:"vault"` // Storage backend configuration ParentKeyProvider *ParentKeyProviderRef `yaml:"parent_key_provider,omitempty"` // Where to get parent keys for unwrapping - Labels Labels `yaml:"labels,omitempty"` // Per-binding labels } // TopologySegment defines an agent's portion of the hierarchy type TopologySegment struct { - Name string `yaml:"name"` // Agent name (must match cert CN) - Segment HierarchySegment `yaml:"segment"` // Keys this agent manages - KeyBindings map[string]KeyBinding `yaml:"key_bindings"` // All dependencies per key kind (key = kind name) - Labels Labels `yaml:"labels,omitempty"` + Name string `yaml:"name"` // Agent name (must match cert CN) + Segment HierarchySegment `yaml:"segment"` // Keys this agent manages + KeyBindings map[string]KeyBinding `yaml:"key_bindings"` // All dependencies per key kind (key = kind name) + SelectorLabels SelectorLabels `yaml:"selector_labels,omitempty"` } // Topology defines the deployment layout diff --git a/internal/spec/topology_test.go b/internal/spec/topology_test.go index f994e97..7bd13a8 100644 --- a/internal/spec/topology_test.go +++ b/internal/spec/topology_test.go @@ -83,7 +83,6 @@ func TestValidateKeyBinding(t *testing.T) { ParentKeyProvider: &ParentKeyProviderRef{ AgentName: "root", }, - Labels: Labels{"env": "prod"}, }, wantErr: nil, }, @@ -174,8 +173,8 @@ func TestValidateTopologySegment(t *testing.T) { StartKind: "K2", EndKind: "K3", }, - KeyBindings: validKeyBindings, - Labels: Labels{"cloud": "aws"}, + KeyBindings: validKeyBindings, + SelectorLabels: SelectorLabels{"cloud": "aws"}, }, wantErr: nil, }, diff --git a/pkg/api/v1/proto/agents/setup_test.go b/pkg/api/v1/proto/agents/setup_test.go index 8fa0484..d35c903 100644 --- a/pkg/api/v1/proto/agents/setup_test.go +++ b/pkg/api/v1/proto/agents/setup_test.go @@ -134,8 +134,8 @@ func createDatabase(t *testing.T) *sql.DB { func validRootConfig(agentName string) config.RootConfig { expSegment := spec.TopologySegment{ - Name: agentName, - Labels: map[string]string{"region": "us-west"}, + Name: agentName, + SelectorLabels: map[string]string{"region": "us-west"}, Segment: spec.HierarchySegment{ StartKind: "K2", EndKind: "K2", @@ -152,10 +152,6 @@ func validRootConfig(agentName string) config.RootConfig { ParentKeyProvider: &spec.ParentKeyProviderRef{ AgentName: agentName, }, - Labels: spec.Labels{ - "env": "prod", - "app": "myapp", - }, }, }, }