Skip to content
Closed
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
294 changes: 183 additions & 111 deletions pkg/rbac/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,20 @@ limitations under the License.
package rbac

import (
"fmt"
"sort"
"strings"

rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
sets "k8s.io/apimachinery/pkg/util/sets"

"sigs.k8s.io/controller-tools/pkg/genall"
"sigs.k8s.io/controller-tools/pkg/markers"
)

var (
// RuleDefinition is a marker for defining RBAC rules.
// Call ToRule on the value to get a Kubernetes RBAC policy rule.
RuleDefinition = markers.Must(markers.MakeDefinition("kubebuilder:rbac", markers.DescribesPackage, Rule{}))
)
// RuleDefinition is a marker for defining RBAC rules.
// Call ToRule on the value to get a Kubernetes RBAC policy rule.
var RuleDefinition = markers.Must(markers.MakeDefinition("kubebuilder:rbac", markers.DescribesPackage, Rule{}))

// +controllertools:marker:generateHelp:category=RBAC

Expand All @@ -63,83 +61,107 @@ type Rule struct {
Namespace string `marker:",optional"`
}

// ruleKey represents the resources and non-resources a Rule applies.
type ruleKey struct {
Groups string
Resources string
ResourceNames string
URLs string
}
func newSet(strings []string) sets.String {
if strings == nil { // preserve nils for back-compat with existing code
return nil
}

func (key ruleKey) String() string {
return fmt.Sprintf("%s + %s + %s + %s", key.Groups, key.Resources, key.ResourceNames, key.URLs)
return sets.NewString(strings...)
}

// ruleKeys implements sort.Interface
type ruleKeys []ruleKey

func (keys ruleKeys) Len() int { return len(keys) }
func (keys ruleKeys) Swap(i, j int) { keys[i], keys[j] = keys[j], keys[i] }
func (keys ruleKeys) Less(i, j int) bool { return keys[i].String() < keys[j].String() }

// key normalizes the Rule and returns a ruleKey object.
func (r *Rule) key() ruleKey {
r.normalize()
return ruleKey{
Groups: strings.Join(r.Groups, "&"),
Resources: strings.Join(r.Resources, "&"),
ResourceNames: strings.Join(r.ResourceNames, "&"),
URLs: strings.Join(r.URLs, "&"),
func (r *Rule) Normalize() *NormalizedRule {
result := &NormalizedRule{
Groups: newSet(r.Groups),
Resources: newSet(r.Resources),
ResourceNames: newSet(r.ResourceNames),
Verbs: newSet(r.Verbs),
URLs: newSet(r.URLs),
Namespace: r.Namespace,
}

// simplify Resources and Verbs which both support special "*" to mean 'any'
// if this is specified, remove all other names
if result.Resources.Has("*") {
result.Resources = sets.NewString("*")
}

if result.Verbs.Has("*") {
result.Verbs = sets.NewString("*")
}

// fix the group names, since letting people type "core" is nice
if result.Groups.Has("core") {
result.Groups.Delete("core")
result.Groups.Insert("")
}

result.GenerateComparisonKey()

return result
}

// addVerbs adds new verbs into a Rule.
// The duplicates in `r.Verbs` will be removed, and then `r.Verbs` will be sorted.
func (r *Rule) addVerbs(verbs []string) {
r.Verbs = removeDupAndSort(append(r.Verbs, verbs...))
type NormalizedRule struct {
// if two different rules have the same comparison key then they can have their verbs merged
// this key should comprise all the fields below except Verbs and Namespace (since rules
// are partitioned by namespace before merging)
ComparisonKey string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I don't understand why this is needed. If one rule is a superset of another rule, the subet rule can be removed. Why is any additional merging needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to handle verb list merging (a part of the existing codebase as well); if two rules match exactly the same things then they can be unified and their verb lists merged.


Namespace string
Groups sets.String
Resources sets.String
ResourceNames sets.String
URLs sets.String

Verbs sets.String
}

// normalize removes duplicates from each field of a Rule, and sorts each field.
func (r *Rule) normalize() {
r.Groups = removeDupAndSort(r.Groups)
r.Resources = removeDupAndSort(r.Resources)
r.ResourceNames = removeDupAndSort(r.ResourceNames)
r.Verbs = removeDupAndSort(r.Verbs)
r.URLs = removeDupAndSort(r.URLs)
// GenerateComparisonKey generates the ComparisonKey
func (nr *NormalizedRule) GenerateComparisonKey() {
nr.ComparisonKey = strings.Join(
[]string{
strings.Join(setToSorted(nr.Groups), "&"),
strings.Join(setToSorted(nr.Resources), "&"),
strings.Join(setToSorted(nr.ResourceNames), "&"),
strings.Join(setToSorted(nr.URLs), "&"),
},
" + ")
}

// removeDupAndSort removes duplicates in strs, sorts the items, and returns a
// new slice of strings.
func removeDupAndSort(strs []string) []string {
set := make(map[string]bool)
for _, str := range strs {
if _, ok := set[str]; !ok {
set[str] = true
}
func setToSorted(set sets.String) []string {
if set == nil { // preserve nils
return nil
}

var result []string
for str := range set {
result = append(result, str)
}
result := set.UnsortedList()
sort.Strings(result)
return result
}

// Subsumes indicates if one rule entirely determines another,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be due to the fact that I am not a native English speaker, but I find a name like IsSuperset way more obvious.

// meaning that the other is unnecessary.
// Remember that Kubernetes RBAC rules are purely additive, there
// are no deny rules.
func (nr *NormalizedRule) Subsumes(other *NormalizedRule) bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can directly tell you that this is wrong:

  • Empty groups does not mean all groups, it means no group
  • Same for Resources
  • Resources support the * operator to indicate all resources

Also, if we introduce something like that it needs thorough unit testing. This is not an area where we can introduce correctness issues.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have updated this versus the documentation on the PolicyRule struct. Thanks, this is the kind of feedback I wanted on the correctness. 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have been building out the unit tests (given that there were none to start with) but definitely could do with more.

// See the code for documentation of these fields: https://github.com/kubernetes/api/blob/v0.23.6/rbac/v1/types.go#L49
return nr.Namespace == other.Namespace &&
(nr.Groups.IsSuperset(other.Groups)) &&
// Resources supports special "*" to mean "any"
(nr.Resources.Has("*") || nr.Resources.IsSuperset(other.Resources)) &&
// Empty ResourceNames means "any"
(len(nr.ResourceNames) == 0 || nr.ResourceNames.IsSuperset(other.ResourceNames)) &&
nr.URLs.IsSuperset(other.URLs) && // TODO: check? also URLs can have "*" at specific locations
// Verbs support special "*" to mean "any"
(nr.Verbs.Has("*") || nr.Verbs.IsSuperset(other.Verbs))
}

// ToRule converts this rule to its Kubernetes API form.
func (r *Rule) ToRule() rbacv1.PolicyRule {
// fix the group names first, since letting people type "core" is nice
for i, group := range r.Groups {
if group == "core" {
r.Groups[i] = ""
}
}
func (nr *NormalizedRule) ToRule() rbacv1.PolicyRule {
return rbacv1.PolicyRule{
APIGroups: r.Groups,
Verbs: r.Verbs,
Resources: r.Resources,
ResourceNames: r.ResourceNames,
NonResourceURLs: r.URLs,
APIGroups: setToSorted(nr.Groups),
Verbs: setToSorted(nr.Verbs),
Resources: setToSorted(nr.Resources),
ResourceNames: setToSorted(nr.ResourceNames),
NonResourceURLs: setToSorted(nr.URLs),
}
}

Expand All @@ -162,52 +184,7 @@ func (Generator) RegisterMarkers(into *markers.Registry) error {
// GenerateRoles generate a slice of objs representing either a ClusterRole or a Role object
// The order of the objs in the returned slice is stable and determined by their namespaces.
func GenerateRoles(ctx *genall.GenerationContext, roleName string) ([]interface{}, error) {
rulesByNS := make(map[string][]*Rule)
for _, root := range ctx.Roots {
markerSet, err := markers.PackageMarkers(ctx.Collector, root)
if err != nil {
root.AddError(err)
}

// group RBAC markers by namespace
for _, markerValue := range markerSet[RuleDefinition.Name] {
rule := markerValue.(Rule)
namespace := rule.Namespace
if _, ok := rulesByNS[namespace]; !ok {
rules := make([]*Rule, 0)
rulesByNS[namespace] = rules
}
rulesByNS[namespace] = append(rulesByNS[namespace], &rule)
}
}

// NormalizeRules merge Rule with the same ruleKey and sort the Rules
NormalizeRules := func(rules []*Rule) []rbacv1.PolicyRule {
ruleMap := make(map[ruleKey]*Rule)
// all the Rules having the same ruleKey will be merged into the first Rule
for _, rule := range rules {
key := rule.key()
if _, ok := ruleMap[key]; !ok {
ruleMap[key] = rule
continue
}
ruleMap[key].addVerbs(rule.Verbs)
}

// sort the Rules in rules according to their ruleKeys
keys := make([]ruleKey, 0, len(ruleMap))
for key := range ruleMap {
keys = append(keys, key)
}
sort.Sort(ruleKeys(keys))

var policyRules []rbacv1.PolicyRule
for _, key := range keys {
policyRules = append(policyRules, ruleMap[key].ToRule())

}
return policyRules
}
rulesByNS := GroupRulesByNamespace(ctx)

// collect all the namespaces and sort them
var namespaces []string
Expand Down Expand Up @@ -253,6 +230,101 @@ func GenerateRoles(ctx *genall.GenerationContext, roleName string) ([]interface{
return objs, nil
}

func GroupRulesByNamespace(ctx *genall.GenerationContext) map[string][]*Rule {
rulesByNS := make(map[string][]*Rule)
for _, root := range ctx.Roots {
markerSet, err := markers.PackageMarkers(ctx.Collector, root)
if err != nil {
root.AddError(err)
}

// group RBAC markers by namespace
for _, markerValue := range markerSet[RuleDefinition.Name] {
rule := markerValue.(Rule)
namespace := rule.Namespace
rulesByNS[namespace] = append(rulesByNS[namespace], &rule)
}
}

return rulesByNS
}

// insertRule inserts a rule into a destination slice, deduplicating via the
// Subsumes function or merging verbs if appropriate
func insertRule(dest []*NormalizedRule, it *NormalizedRule) []*NormalizedRule {
// this is not going to be very fast but the set of rules should always be small
mergeWith := -1
for ix, other := range dest {
if other.Subsumes(it) {
// not needed; another rule handles this case
return dest
}

if it.Subsumes(other) {
// rebuild whole list:
// the 'it' rule subsumes the 'other' rule;
// but it might also subsume other rules in the list, so we
// need to go through and check them as well.
//
// redoing the insertion with `it` first handles this:
result := []*NormalizedRule{it}
for _, d := range dest {
result = insertRule(result, d)
}
Comment on lines +270 to +273
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this omit "other"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could, but since it.Subsumes(other) and we start the new list with it being contained, then other will be omitted by the previous check above. Explicitly skipping it makes the code a bit messier.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you are doing here is both harder to read and less eficient than:

func remove(s []int, i int) []int {
    s[i] = s[len(s)-1]
    return s[:len(s)-1]
}
return append(remove(dest, idx), it)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alvaroaleman that doesn’t result in the quite same outcome; if Rule X is superset of Rule Y in the list, it could also be a superset of Rule Z (and others), so we still need to go through and check the remainder of the list.


return result
}

if it.ComparisonKey == other.ComparisonKey {
// match the same things, can merge their
// verbs (if no better match)
mergeWith = ix
}
}

if mergeWith >= 0 {
// we found one to merge with
insertAll(dest[mergeWith].Verbs, it.Verbs)
return dest
}

// otherwise, insert it
return append(dest, it)
}

func insertAll(set sets.String, other sets.String) {
for value := range other {
set.Insert(value)
}
}

// Sorts the rules for deterministic output:
type ruleSorter []*NormalizedRule

// ruleSorter implements sort.Interface
var _ sort.Interface = ruleSorter{}

func (keys ruleSorter) Len() int { return len(keys) }
func (keys ruleSorter) Swap(i, j int) { keys[i], keys[j] = keys[j], keys[i] }
func (keys ruleSorter) Less(i, j int) bool { return keys[i].ComparisonKey < keys[j].ComparisonKey }

// NormalizeRules merges Rules that can be merged, and sorts the Rules
func NormalizeRules(rules []*Rule) []rbacv1.PolicyRule {
var simplified []*NormalizedRule
for _, rule := range rules {
simplified = insertRule(simplified, rule.Normalize())
}

sort.Sort(ruleSorter(simplified))

result := make([]rbacv1.PolicyRule, len(simplified))
for i := range simplified {
result[i] = simplified[i].ToRule()
}

return result
}

func (g Generator) Generate(ctx *genall.GenerationContext) error {
objs, err := GenerateRoles(ctx, g.RoleName)
if err != nil {
Expand Down
Loading