diff --git a/.gitignore b/.gitignore index 0183ae4..64895a6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ *.out .DS_Store .idea +.claude /bin \ No newline at end of file diff --git a/fga/util/util.go b/fga/util/util.go new file mode 100644 index 0000000..52f40d9 --- /dev/null +++ b/fga/util/util.go @@ -0,0 +1,80 @@ +// Package util provides utility functions for converting API group and resource names +// into normalized type names suitable for use in authorization systems. +// +// This package is primarily designed for use with Fine-Grained Authorization (FGA) +// systems where consistent naming conventions are required for type definitions. +package util + +import ( + "fmt" + "strings" +) + +// maxRelationLength defines the maximum allowed length for relation names in FGA systems. +// This limit ensures compatibility with authorization backends that have string length +// constraints on relation and type names. +// +// The value of 50 is chosen to accommodate the longest possible relation format: +// "create__s" while leaving room for reasonable group and resource names. +const maxRelationLength = 50 + +// ConvertToTypeName converts an API group and singular resource name into a normalized +// type name suitable for use in authorization systems. +// +// Parameters: +// - group: The API group name (e.g., "apps", "networking.k8s.io", "") +// - singular: The singular form of the resource name (e.g., "deployment", "pod", "Service") +// +// Returns: +// +// A normalized type name string suitable for authorization system usage. +// +// Examples: +// +// ConvertToTypeName("apps", "deployment") → "apps_deployment" +// ConvertToTypeName("", "pod") → "core_pod" +// ConvertToTypeName("networking.k8s.io", "ingress") → "networking_k8s_io_ingress" +// ConvertToTypeName("Apps", "Deployment") → "apps_deployment" +// +// The function handles edge cases gracefully: +// - Empty group names default to "core" +// - Very long names are truncated to respect maxRelationLength +// - Special characters like dots are normalized to underscores +// - Mixed case is normalized to lowercase +func ConvertToTypeName(group, singular string) string { + if group == "" { + group = "core" + } + + // Cap the length of the group_singular string to respect relation length limits + objectType := capGroupSingularLength(group, singular, maxRelationLength) + + // Make sure the result does not start with an underscore (can happen with empty groups) + objectType = strings.TrimPrefix(objectType, "_") + + // Replace dots with underscores in the final objectType for system compatibility + objectType = strings.ReplaceAll(objectType, ".", "_") + + // Convert to lowercase for consistent naming conventions + objectType = strings.ToLower(objectType) + + return objectType +} + +// capGroupSingularLength creates a group_singular string and truncates it if necessary +// to ensure the resulting relation names don't exceed the specified maximum length. +// +// This function is used internally by ConvertToTypeName to handle length constraints +// imposed by authorization systems. It calculates the potential length of the longest +// relation that would be created ("create__") and truncates the +// group_singular combination if needed. +func capGroupSingularLength(group, singular string, maxLength int) string { + groupSingular := fmt.Sprintf("%s_%s", group, singular) + maxRelation := fmt.Sprintf("create_%ss", groupSingular) + + if len(maxRelation) > maxLength && maxLength > 0 { + truncateLen := len(maxRelation) - maxLength + return groupSingular[truncateLen:] + } + return groupSingular +} diff --git a/fga/util/util_test.go b/fga/util/util_test.go new file mode 100644 index 0000000..0672b81 --- /dev/null +++ b/fga/util/util_test.go @@ -0,0 +1,166 @@ +package util + +import ( + "testing" +) + +func TestConvertToTypeName(t *testing.T) { + tests := []struct { + name string + group string + singular string + expected string + }{ + { + name: "basic conversion", + group: "apps", + singular: "deployment", + expected: "apps_deployment", + }, + { + name: "empty group defaults to core", + group: "", + singular: "pod", + expected: "core_pod", + }, + { + name: "group with dots gets replaced with underscores", + group: "networking.k8s.io", + singular: "ingress", + expected: "networking_k8s_io_ingress", + }, + { + name: "single character group and singular", + group: "a", + singular: "b", + expected: "a_b", + }, + { + name: "numeric group and singular", + group: "v1", + singular: "service", + expected: "v1_service", + }, + { + name: "mixed case should be lowercased", + group: "Apps", + singular: "Deployment", + expected: "apps_deployment", + }, + { + name: "short group and singular within limits", + group: "batch", + singular: "job", + expected: "batch_job", + }, + { + name: "handles special characters in group", + group: "group-with-dashes", + singular: "kind", + expected: "group-with-dashes_kind", + }, + { + name: "both group and singular empty", + group: "", + singular: "", + expected: "core_", + }, + { + name: "dots in group get replaced properly", + group: "apps.v1", + singular: "deployment", + expected: "apps_v1_deployment", + }, + { + name: "multiple dots get replaced", + group: "foo.bar.baz", + singular: "resource", + expected: "foo_bar_baz_resource", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertToTypeName(tt.group, tt.singular) + if result != tt.expected { + t.Errorf("ConvertToTypeName(%q, %q) = %q, want %q", tt.group, tt.singular, result, tt.expected) + } + }) + } +} + +func TestCapGroupSingularLength(t *testing.T) { + tests := []struct { + name string + group string + singular string + maxLength int + expected string + }{ + { + name: "group and singular within max length", + group: "apps", + singular: "deployment", + maxLength: 50, + expected: "apps_deployment", + }, + { + name: "empty group with capGroupSingularLength", + group: "", + singular: "pod", + maxLength: 50, + expected: "_pod", + }, + { + name: "short group and singular that stays within limits", + group: "batch", + singular: "job", + maxLength: 30, + expected: "batch_job", + }, + { + name: "exact max length boundary", + group: "test", + singular: "resource", + maxLength: 20, + expected: "est_resource", // "create_test_resources" = 21 chars, truncate 1: "est_resource" + }, + { + name: "successful truncation case", + group: "verylonggroup", + singular: "job", + maxLength: 20, + expected: "onggroup_job", // "create_verylonggroup_jobs" = 25 chars, truncate 5: "onggroup_job" + }, + { + name: "truncation case with very long singular", + group: "short", + singular: "verylongkindnamethatexceedseverything", + maxLength: 10, + expected: "ng", // Additional "s" in relation makes it one char shorter + }, + { + name: "maxLength zero returns original groupKind", + group: "apps", + singular: "deployment", + maxLength: 0, + expected: "apps_deployment", + }, + { + name: "truncation within group length", + group: "exactlength", + singular: "test", + maxLength: 15, + expected: "th_test", // "create_exactlength_tests" = 24 chars, truncate 9: "th_test" + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := capGroupSingularLength(tt.group, tt.singular, tt.maxLength) + if result != tt.expected { + t.Errorf("capGroupSingularLength(%q, %q, %d) = %q, want %q", tt.group, tt.singular, tt.maxLength, result, tt.expected) + } + }) + } +}