Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
*.out
.DS_Store
.idea
.claude
/bin
80 changes: 80 additions & 0 deletions fga/util/util.go
Original file line number Diff line number Diff line change
@@ -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_<group>_<singular>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_<group>_<singular>") 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
}
166 changes: 166 additions & 0 deletions fga/util/util_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading