diff --git a/pkg/connector/clusterrole.go b/pkg/connector/clusterrole.go index 75303f0a..2b564008 100644 --- a/pkg/connector/clusterrole.go +++ b/pkg/connector/clusterrole.go @@ -88,11 +88,11 @@ func (c *clusterRoleBuilder) List(ctx context.Context, parentResourceID *v2.Reso func clusterRoleResource(clusterRole *rbacv1.ClusterRole) (*v2.Resource, error) { // Prepare profile with standard metadata profile := map[string]interface{}{ - "name": clusterRole.Name, - "uid": string(clusterRole.UID), - "creationTimestamp": clusterRole.CreationTimestamp.String(), - "labels": StringMapToAnyMap(clusterRole.Labels), - "annotations": StringMapToAnyMap(clusterRole.Annotations), + metadataKeyName: clusterRole.Name, + metadataKeyUID: string(clusterRole.UID), + metadataKeyCreationTimestamp: clusterRole.CreationTimestamp.String(), + metadataKeyLabels: StringMapToAnyMap(clusterRole.Labels), + metadataKeyAnnotations: StringMapToAnyMap(clusterRole.Annotations), } // Add aggregation rule if present diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index e94b4425..7787b061 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -25,13 +25,27 @@ const ( ResourceTypeRoleBinding = "rolebinding" SubjectTypeGroup = "Group" SubjectTypeUser = "User" + + // Standard Kubernetes object-metadata keys used in resource profiles. + metadataKeyName = "name" + metadataKeyNamespace = "namespace" + metadataKeyUID = "uid" + metadataKeyCreationTimestamp = "creationTimestamp" + metadataKeyLabels = "labels" + metadataKeyAnnotations = "annotations" + + // verbGet is the Kubernetes "get" RBAC verb. + verbGet = "get" + + // kindRole is the RBAC RoleRef kind for namespaced Roles. + kindRole = "Role" ) // Resource type definitions. var ( ResourceTypeNamespace = &v2.ResourceType{Id: "namespace", DisplayName: "Namespace"} ResourceTypeServiceAccount = &v2.ResourceType{Id: "service_account", DisplayName: "Service Account", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}} - ResourceTypeRole = &v2.ResourceType{Id: "role", DisplayName: "Role", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}} + ResourceTypeRole = &v2.ResourceType{Id: "role", DisplayName: kindRole, Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}} ResourceTypeClusterRole = &v2.ResourceType{Id: "cluster_role", DisplayName: "Cluster Role", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}} ResourceTypeSecret = &v2.ResourceType{Id: "secret", DisplayName: "Secret", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}} ResourceTypeConfigMap = &v2.ResourceType{Id: "configmap", DisplayName: "Config Map"} @@ -348,7 +362,7 @@ func (k *Kubernetes) GetMatchingRoleBindings(ctx context.Context, namespace, rol var result []rbacv1.RoleBinding for _, binding := range k.roleBindingsCache { - if binding.Namespace == namespace && binding.RoleRef.Kind == "Role" && binding.RoleRef.Name == roleName { + if binding.Namespace == namespace && binding.RoleRef.Kind == kindRole && binding.RoleRef.Name == roleName { result = append(result, binding) } } diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index a6e03fa2..6534f716 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -59,8 +59,8 @@ func generateWildcardResource(resourceType *v2.ResourceType) (*v2.Resource, erro // Create basic profile data profile := map[string]interface{}{ - "name": displayName, - "uid": "wildcard-" + resourceType.Id, + metadataKeyName: displayName, + metadataKeyUID: "wildcard-" + resourceType.Id, } // Handle different resource types differently to add appropriate traits. diff --git a/pkg/connector/kubegroup.go b/pkg/connector/kubegroup.go index dda87e3d..ae5c8f7a 100644 --- a/pkg/connector/kubegroup.go +++ b/pkg/connector/kubegroup.go @@ -80,7 +80,7 @@ func (k *kubeGroupBuilder) List(ctx context.Context, parentResourceID *v2.Resour // Extract group subjects from bindings for _, binding := range resp.Items { for _, subject := range binding.Subjects { - if subject.Kind == "Group" { + if subject.Kind == SubjectKindGroup { // Process group k.processGroup(ctx, subject.Name, &rv) } @@ -99,11 +99,11 @@ func (k *kubeGroupBuilder) List(ctx context.Context, parentResourceID *v2.Resour // Prepare for phase 2 bag = &pagination.Bag{} - bag.Push(pagination.PageState{Token: "clusterrolebindings"}) + bag.Push(pagination.PageState{Token: ResourceTypeClusterRoleBindings}) } // Phase 2: Process ClusterRoleBindings - if pageState == "clusterrolebindings" { + if pageState == ResourceTypeClusterRoleBindings { // Set up list options with pagination opts := metav1.ListOptions{ Limit: ResourcesPageSize, @@ -120,7 +120,7 @@ func (k *kubeGroupBuilder) List(ctx context.Context, parentResourceID *v2.Resour // Extract group subjects from bindings for _, binding := range resp.Items { for _, subject := range binding.Subjects { - if subject.Kind == "Group" { + if subject.Kind == SubjectKindGroup { // Process group k.processGroup(ctx, subject.Name, &rv) } @@ -174,7 +174,7 @@ func (k *kubeGroupBuilder) processGroup(ctx context.Context, groupName string, r func (k *kubeGroupBuilder) kubeGroupResource(groupName string) (*v2.Resource, error) { // Create profile profile := map[string]interface{}{ - "name": groupName, + metadataKeyName: groupName, } // Create resource with group trait options diff --git a/pkg/connector/kubeuser.go b/pkg/connector/kubeuser.go index e8273569..9bfe65ba 100644 --- a/pkg/connector/kubeuser.go +++ b/pkg/connector/kubeuser.go @@ -70,7 +70,7 @@ func (k *kubeUserBuilder) List(ctx context.Context, parentResourceID *v2.Resourc // Extract user subjects from bindings for _, binding := range resp.Items { for _, subject := range binding.Subjects { - if subject.Kind == "User" { + if subject.Kind == SubjectKindUser { // Process user k.processUser(ctx, subject.Name, &rv) } @@ -89,11 +89,11 @@ func (k *kubeUserBuilder) List(ctx context.Context, parentResourceID *v2.Resourc // Prepare for phase 2 bag = &pagination.Bag{} - bag.Push(pagination.PageState{Token: "clusterrolebindings"}) + bag.Push(pagination.PageState{Token: ResourceTypeClusterRoleBindings}) } // Phase 2: Process ClusterRoleBindings - if pageState == "clusterrolebindings" { + if pageState == ResourceTypeClusterRoleBindings { // Set up list options with pagination opts := metav1.ListOptions{ Limit: ResourcesPageSize, @@ -110,7 +110,7 @@ func (k *kubeUserBuilder) List(ctx context.Context, parentResourceID *v2.Resourc // Extract user subjects from bindings for _, binding := range resp.Items { for _, subject := range binding.Subjects { - if subject.Kind == "User" { + if subject.Kind == SubjectKindUser { // Process user k.processUser(ctx, subject.Name, &rv) } @@ -164,7 +164,7 @@ func (k *kubeUserBuilder) processUser(ctx context.Context, username string, reso func (k *kubeUserBuilder) kubeUserResource(username string) (*v2.Resource, error) { // Create profile profile := map[string]interface{}{ - "name": username, + metadataKeyName: username, } // Create resource with user trait options diff --git a/pkg/connector/namespace.go b/pkg/connector/namespace.go index de439712..ddd6d896 100644 --- a/pkg/connector/namespace.go +++ b/pkg/connector/namespace.go @@ -85,11 +85,11 @@ func (n *namespaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour func namespaceResource(ns *corev1.Namespace) (*v2.Resource, error) { // Prepare profile with standard metadata profile := map[string]interface{}{ - "name": ns.Name, - "uid": string(ns.UID), - "creationTimestamp": ns.CreationTimestamp.String(), - "labels": StringMapToAnyMap(ns.Labels), - "annotations": StringMapToAnyMap(ns.Annotations), + metadataKeyName: ns.Name, + metadataKeyUID: string(ns.UID), + metadataKeyCreationTimestamp: ns.CreationTimestamp.String(), + metadataKeyLabels: StringMapToAnyMap(ns.Labels), + metadataKeyAnnotations: StringMapToAnyMap(ns.Annotations), } // Add status phase if available diff --git a/pkg/connector/role.go b/pkg/connector/role.go index e7d221f7..e4ecac20 100644 --- a/pkg/connector/role.go +++ b/pkg/connector/role.go @@ -81,18 +81,18 @@ func (r *roleBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, func roleResource(role *rbacv1.Role) (*v2.Resource, error) { // Prepare profile with standard metadata profile := map[string]interface{}{ - "name": role.Name, - "namespace": role.Namespace, - "uid": string(role.UID), - "creationTimestamp": role.CreationTimestamp.String(), + metadataKeyName: role.Name, + metadataKeyNamespace: role.Namespace, + metadataKeyUID: string(role.UID), + metadataKeyCreationTimestamp: role.CreationTimestamp.String(), } // Only add labels and annotations if they're not nil to avoid proto conversion issues if role.Labels != nil { - profile["labels"] = StringMapToAnyMap(role.Labels) + profile[metadataKeyLabels] = StringMapToAnyMap(role.Labels) } if role.Annotations != nil { - profile["annotations"] = StringMapToAnyMap(role.Annotations) + profile[metadataKeyAnnotations] = StringMapToAnyMap(role.Annotations) } // Get parent namespace resource ID diff --git a/pkg/connector/secret.go b/pkg/connector/secret.go index b9d70c65..5a0d79d1 100644 --- a/pkg/connector/secret.go +++ b/pkg/connector/secret.go @@ -3,6 +3,7 @@ package connector import ( "context" "fmt" + "strings" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -20,7 +21,7 @@ import ( // Standard verb entitlements for Kubernetes resources. var standardResourceVerbs = []string{ - "get", + verbGet, "list", "watch", "create", @@ -110,19 +111,25 @@ func secretResource(secret *corev1.Secret) (*v2.Resource, error) { // Create profile with standard metadata profile := map[string]interface{}{ - "name": secret.Name, - "namespace": secret.Namespace, - "uid": string(secret.UID), - "creationTimestamp": secret.CreationTimestamp.String(), - "labels": StringMapToAnyMap(secret.Labels), - "annotations": StringMapToAnyMap(secret.Annotations), - "type": string(secret.Type), + metadataKeyName: secret.Name, + metadataKeyNamespace: secret.Namespace, + metadataKeyUID: string(secret.UID), + metadataKeyCreationTimestamp: secret.CreationTimestamp.String(), + metadataKeyLabels: StringMapToAnyMap(secret.Labels), + metadataKeyAnnotations: StringMapToAnyMap(secret.Annotations), + "type": string(secret.Type), } + // Classify the secret's cryptographic kind onto the NHI spine. + credentialType, credentialDetail := secretCredentialType(secret.Type) + // Secret trait options secretOptions := []rs.SecretTraitOption{ // Set creation time from metadata rs.WithSecretCreatedAt(secret.CreationTimestamp.Time), + // NHI spine: cryptographic class + platform-specific detail + rs.WithSecretType(credentialType), + rs.WithSecretDetail(credentialDetail), // Create a custom trait option for the profile func(t *v2.SecretTrait) error { profileStruct, err := structpb.NewStruct(profile) @@ -160,6 +167,39 @@ func secretResource(secret *corev1.Secret) (*v2.Resource, error) { return resource, nil } +// secretCredentialType maps a Kubernetes secret type onto the NHI spine +// CredentialType and a dotted-lowercase axis-2 detail (e.g. +// "k8s.secret.service_account_token"). TLS secrets carry an x509 certificate +// and SSH-auth secrets carry an asymmetric key pair; everything else (opaque, +// service-account tokens, docker creds, basic-auth, bootstrap tokens) is an +// opaque static secret. +func secretCredentialType(secretType corev1.SecretType) (v2.SecretTrait_CredentialType, string) { + detail := "k8s.secret." + normalizeSecretType(secretType) + switch secretType { + case corev1.SecretTypeTLS: + return v2.SecretTrait_CREDENTIAL_TYPE_CERTIFICATE, detail + case corev1.SecretTypeSSHAuth: + return v2.SecretTrait_CREDENTIAL_TYPE_ASYMMETRIC_KEY, detail + default: + return v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET, detail + } +} + +// normalizeSecretType reduces a Kubernetes secret type to a dotted-lowercase +// token: it drops any "/" prefix, lowercases, and replaces "-" with +// "_" (e.g. "kubernetes.io/service-account-token" -> "service_account_token"). +// An empty type defaults to Opaque, matching Kubernetes semantics. +func normalizeSecretType(secretType corev1.SecretType) string { + s := string(secretType) + if s == "" { + s = string(corev1.SecretTypeOpaque) + } + if idx := strings.LastIndex(s, "/"); idx >= 0 { + s = s[idx+1:] + } + return strings.ReplaceAll(strings.ToLower(s), "-", "_") +} + // Entitlements returns standard verb entitlements for Secret resources. func (s *secretBuilder) Entitlements(ctx context.Context, resource *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { var entitlements []*v2.Entitlement diff --git a/pkg/connector/secret_test.go b/pkg/connector/secret_test.go new file mode 100644 index 00000000..012c77d9 --- /dev/null +++ b/pkg/connector/secret_test.go @@ -0,0 +1,65 @@ +package connector + +import ( + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +// TestSecretCredentialType verifies the Kubernetes secret type -> NHI spine +// CredentialType + axis-2 detail mapping. +func TestSecretCredentialType(t *testing.T) { + testCases := []struct { + name string + secretType corev1.SecretType + wantType v2.SecretTrait_CredentialType + wantDetail string + }{ + { + name: "service account token", + secretType: corev1.SecretTypeServiceAccountToken, + wantType: v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET, + wantDetail: "k8s.secret.service_account_token", + }, + { + name: "tls is a certificate", + secretType: corev1.SecretTypeTLS, + wantType: v2.SecretTrait_CREDENTIAL_TYPE_CERTIFICATE, + wantDetail: "k8s.secret.tls", + }, + { + name: "ssh auth is an asymmetric key", + secretType: corev1.SecretTypeSSHAuth, + wantType: v2.SecretTrait_CREDENTIAL_TYPE_ASYMMETRIC_KEY, + wantDetail: "k8s.secret.ssh_auth", + }, + { + name: "opaque is a static secret", + secretType: corev1.SecretTypeOpaque, + wantType: v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET, + wantDetail: "k8s.secret.opaque", + }, + { + name: "basic auth is a static secret", + secretType: corev1.SecretTypeBasicAuth, + wantType: v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET, + wantDetail: "k8s.secret.basic_auth", + }, + { + name: "empty type defaults to opaque", + secretType: corev1.SecretType(""), + wantType: v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET, + wantDetail: "k8s.secret.opaque", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gotType, gotDetail := secretCredentialType(tc.secretType) + assert.Equal(t, tc.wantType, gotType) + assert.Equal(t, tc.wantDetail, gotDetail) + }) + } +} diff --git a/pkg/connector/serviceaccount.go b/pkg/connector/serviceaccount.go index aa8f1a93..d8f2f70f 100644 --- a/pkg/connector/serviceaccount.go +++ b/pkg/connector/serviceaccount.go @@ -92,12 +92,12 @@ func (s *serviceAccountBuilder) List(ctx context.Context, parentResourceID *v2.R func serviceAccountResource(serviceAccount *corev1.ServiceAccount) (*v2.Resource, error) { // Prepare profile with standard metadata profile := map[string]interface{}{ - "name": serviceAccount.Name, - "namespace": serviceAccount.Namespace, - "uid": string(serviceAccount.UID), - "creationTimestamp": serviceAccount.CreationTimestamp.String(), - "labels": StringMapToAnyMap(serviceAccount.Labels), - "annotations": StringMapToAnyMap(serviceAccount.Annotations), + metadataKeyName: serviceAccount.Name, + metadataKeyNamespace: serviceAccount.Namespace, + metadataKeyUID: string(serviceAccount.UID), + metadataKeyCreationTimestamp: serviceAccount.CreationTimestamp.String(), + metadataKeyLabels: StringMapToAnyMap(serviceAccount.Labels), + metadataKeyAnnotations: StringMapToAnyMap(serviceAccount.Annotations), } // Add secrets if present