go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/tokenserver/appengine/impl/delegation/config.go (about)

     1  // Copyright 2016 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package delegation
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"strings"
    21  
    22  	"google.golang.org/protobuf/encoding/prototext"
    23  
    24  	"go.chromium.org/luci/auth/identity"
    25  	"go.chromium.org/luci/config/validation"
    26  
    27  	"go.chromium.org/luci/tokenserver/api/admin/v1"
    28  	"go.chromium.org/luci/tokenserver/appengine/impl/utils/identityset"
    29  	"go.chromium.org/luci/tokenserver/appengine/impl/utils/policy"
    30  )
    31  
    32  // delegationCfg is name of the main config file with the policy.
    33  //
    34  // Also used as a name for the imported configs in the datastore, so change it
    35  // very carefully.
    36  const delegationCfg = "delegation.cfg"
    37  
    38  const (
    39  	// Requestor is magical token that may be used in the config and requests as
    40  	// a substitute for caller's ID.
    41  	//
    42  	// See config.proto for more info.
    43  	Requestor = "REQUESTOR"
    44  
    45  	// Projects is a magical token that can be used in allowed_to_impersonate to
    46  	// indicate that the caller can impersonate "project:*" identities.
    47  	//
    48  	// TODO(vadimsh): Get rid of it.
    49  	Projects = "PROJECTS"
    50  )
    51  
    52  // Rules is queryable representation of delegation.cfg rules.
    53  type Rules struct {
    54  	revision   string            // config revision this policy is imported from
    55  	rules      []*delegationRule // preprocessed policy rules
    56  	requestors *identityset.Set  // union of all 'Requestor' fields in all rules
    57  }
    58  
    59  // RulesQuery contains parameters to match against the delegation rules.
    60  //
    61  // Used by 'FindMatchingRule'.
    62  type RulesQuery struct {
    63  	Requestor identity.Identity // who is requesting the token
    64  	Delegator identity.Identity // what identity will be delegated/impersonated
    65  	Audience  *identityset.Set  // the requested audience set (delegatees)
    66  	Services  *identityset.Set  // the requested target services set
    67  }
    68  
    69  // delegationRule is preprocessed admin.DelegationRule message.
    70  //
    71  // This object is used by 'FindMatchingRule'.
    72  type delegationRule struct {
    73  	rule *admin.DelegationRule // the original unaltered rule proto
    74  
    75  	requestors *identityset.Set // matched to RulesQuery.Requestor
    76  	delegators *identityset.Set // matched to RulesQuery.Delegator
    77  	audience   *identityset.Set // matched to RulesQuery.Audience
    78  	services   *identityset.Set // matched to RulesQuery.Services
    79  
    80  	addRequestorAsDelegator bool // if true, add RulesQuery.Requestor to 'delegators' set
    81  	addRequestorToAudience  bool // if true, add RulesQuery.Requestor to 'audience' set
    82  	addProjectsAsDelegators bool // if true, add 'project:*' to 'delegators' set
    83  }
    84  
    85  // RulesCache is a stateful object with parsed delegation.cfg rules.
    86  //
    87  // It uses policy.Policy internally to manage datastore-cached copy of imported
    88  // delegation configs.
    89  //
    90  // Use NewRulesCache() to create a new instance. Each instance owns its own
    91  // in-memory cache, but uses same shared datastore cache.
    92  //
    93  // There's also a process global instance of RulesCache (GlobalRulesCache var)
    94  // which is used by the main process. Unit tests don't use it though to avoid
    95  // relying on shared state.
    96  type RulesCache struct {
    97  	policy policy.Policy // holds cached *parsedRules
    98  }
    99  
   100  // GlobalRulesCache is the process-wide rules cache.
   101  var GlobalRulesCache = NewRulesCache()
   102  
   103  // NewRulesCache properly initializes RulesCache instance.
   104  func NewRulesCache() *RulesCache {
   105  	return &RulesCache{
   106  		policy: policy.Policy{
   107  			Name:     delegationCfg,        // used as part of datastore keys
   108  			Fetch:    fetchConfigs,         // see below
   109  			Validate: validateConfigBundle, // see config_validation.go
   110  			Prepare:  prepareRules,         // see below
   111  		},
   112  	}
   113  }
   114  
   115  // ImportConfigs refetches delegation.cfg and updates datastore copy of it.
   116  //
   117  // Called from cron.
   118  func (rc *RulesCache) ImportConfigs(c context.Context) (rev string, err error) {
   119  	return rc.policy.ImportConfigs(c)
   120  }
   121  
   122  // SetupConfigValidation registers the config validation rules.
   123  func (rc *RulesCache) SetupConfigValidation(rules *validation.RuleSet) {
   124  	rules.Add("services/${appid}", delegationCfg, func(ctx *validation.Context, configSet, path string, content []byte) error {
   125  		cfg := &admin.DelegationPermissions{}
   126  		if err := prototext.Unmarshal(content, cfg); err != nil {
   127  			ctx.Errorf("not a valid DelegationPermissions proto message - %s", err)
   128  		} else {
   129  			validateDelegationCfg(ctx, cfg)
   130  		}
   131  		return nil
   132  	})
   133  }
   134  
   135  // Rules returns in-memory copy of delegation rules, ready for querying.
   136  func (rc *RulesCache) Rules(c context.Context) (*Rules, error) {
   137  	q, err := rc.policy.Queryable(c)
   138  	if err != nil {
   139  		return nil, err
   140  	}
   141  	return q.(*Rules), nil
   142  }
   143  
   144  // fetchConfigs loads proto messages with rules from the config.
   145  func fetchConfigs(c context.Context, f policy.ConfigFetcher) (policy.ConfigBundle, error) {
   146  	cfg := &admin.DelegationPermissions{}
   147  	if err := f.FetchTextProto(c, delegationCfg, cfg); err != nil {
   148  		return nil, err
   149  	}
   150  	return policy.ConfigBundle{delegationCfg: cfg}, nil
   151  }
   152  
   153  // prepareRules converts validated configs into *Rules.
   154  //
   155  // Returns them as policy.Queryable object to satisfy policy.Policy API.
   156  func prepareRules(c context.Context, cfg policy.ConfigBundle, revision string) (policy.Queryable, error) {
   157  	parsed, ok := cfg[delegationCfg].(*admin.DelegationPermissions)
   158  	if !ok {
   159  		return nil, fmt.Errorf("wrong type of delegation.cfg - %T", cfg[delegationCfg])
   160  	}
   161  
   162  	rules := make([]*delegationRule, 0, len(parsed.Rules)+1)
   163  	for _, msg := range parsed.Rules {
   164  		rule, err := makeDelegationRule(c, msg)
   165  		if err != nil {
   166  			return nil, err
   167  		}
   168  		rules = append(rules, rule)
   169  	}
   170  
   171  	// Add an implicit rule that allows trusted LUCI microservices to grab
   172  	// delegation tokens for 'project:*' identities. "auth-luci-services" is a
   173  	// magical group also mentioned in luci-py's components.auth.
   174  	//
   175  	// TODO(vadimsh): Currently relied on by Buildbucket. Buildbucket should
   176  	// switch to using 'project:...' identities directly without going through
   177  	// the token server.
   178  	rule, err := makeDelegationRule(c, &admin.DelegationRule{
   179  		Name:                 "allow-project-identities",
   180  		Requestor:            []string{"group:auth-luci-services"},
   181  		AllowedToImpersonate: []string{Projects},
   182  		AllowedAudience:      []string{Requestor},
   183  		TargetService:        []string{"*"},
   184  		MaxValidityDuration:  86400,
   185  	})
   186  	if err != nil {
   187  		panic(err) // should be impossible, this is a hardcoded rule
   188  	}
   189  	rules = append(rules, rule)
   190  
   191  	requestors := make([]*identityset.Set, len(rules))
   192  	for i, r := range rules {
   193  		requestors[i] = r.requestors
   194  	}
   195  
   196  	return &Rules{
   197  		revision:   revision,
   198  		rules:      rules,
   199  		requestors: identityset.Union(requestors...),
   200  	}, nil
   201  }
   202  
   203  // makeDelegationRule preprocesses admin.DelegationRule proto.
   204  //
   205  // It also double checks that the rule is passing validation. The check may
   206  // fail if new code uses old configs, still stored in the datastore.
   207  func makeDelegationRule(c context.Context, rule *admin.DelegationRule) (*delegationRule, error) {
   208  	ctx := &validation.Context{Context: c}
   209  	validateRule(ctx, rule.Name, rule)
   210  	if err := ctx.Finalize(); err != nil {
   211  		return nil, err
   212  	}
   213  
   214  	// The main validation step has been done above. Here we just assert that
   215  	// everything looks sane (it should). See corresponding chunks of
   216  	// 'ValidateRule' code.
   217  	requestors, err := identityset.FromStrings(rule.Requestor, nil)
   218  	if err != nil {
   219  		panic(err)
   220  	}
   221  	delegators, err := identityset.FromStrings(rule.AllowedToImpersonate, skipRequestorOrProjects)
   222  	if err != nil {
   223  		panic(err)
   224  	}
   225  	audience, err := identityset.FromStrings(rule.AllowedAudience, skipRequestor)
   226  	if err != nil {
   227  		panic(err)
   228  	}
   229  	services, err := identityset.FromStrings(rule.TargetService, nil)
   230  	if err != nil {
   231  		panic(err)
   232  	}
   233  
   234  	return &delegationRule{
   235  		rule:                    rule,
   236  		requestors:              requestors,
   237  		delegators:              delegators,
   238  		audience:                audience,
   239  		services:                services,
   240  		addRequestorAsDelegator: sliceHasString(rule.AllowedToImpersonate, Requestor),
   241  		addRequestorToAudience:  sliceHasString(rule.AllowedAudience, Requestor),
   242  		addProjectsAsDelegators: sliceHasString(rule.AllowedToImpersonate, Projects),
   243  	}, nil
   244  }
   245  
   246  func skipRequestor(s string) bool {
   247  	return s == Requestor
   248  }
   249  
   250  func skipRequestorOrProjects(s string) bool {
   251  	return s == Requestor || s == Projects
   252  }
   253  
   254  func sliceHasString(slice []string, str string) bool {
   255  	for _, s := range slice {
   256  		if s == str {
   257  			return true
   258  		}
   259  	}
   260  	return false
   261  }
   262  
   263  // ConfigRevision is part of policy.Queryable interface.
   264  func (r *Rules) ConfigRevision() string {
   265  	return r.revision
   266  }
   267  
   268  // IsAuthorizedRequestor returns true if the caller belongs to 'requestor' set
   269  // of at least one rule.
   270  func (r *Rules) IsAuthorizedRequestor(c context.Context, id identity.Identity) (bool, error) {
   271  	return r.requestors.IsMember(c, id)
   272  }
   273  
   274  // FindMatchingRule finds one and only one rule matching the query.
   275  //
   276  // If multiple rules match or none rules match, an error is returned.
   277  func (r *Rules) FindMatchingRule(c context.Context, q *RulesQuery) (*admin.DelegationRule, error) {
   278  	var matches []*admin.DelegationRule
   279  	for _, rule := range r.rules {
   280  		switch yes, err := rule.matchesQuery(c, q); {
   281  		case err != nil:
   282  			return nil, err // usually transient
   283  		case yes:
   284  			matches = append(matches, rule.rule)
   285  		}
   286  	}
   287  
   288  	if len(matches) == 0 {
   289  		return nil, fmt.Errorf("no matching delegation rules in the config")
   290  	}
   291  
   292  	if len(matches) > 1 {
   293  		names := make([]string, len(matches))
   294  		for i, m := range matches {
   295  			names[i] = fmt.Sprintf("%q", m.Name)
   296  		}
   297  		return nil, fmt.Errorf(
   298  			"ambiguous request, multiple delegation rules match (%s)",
   299  			strings.Join(names, ", "))
   300  	}
   301  
   302  	return matches[0], nil
   303  }
   304  
   305  // matchesQuery returns true if this rule matches the query.
   306  //
   307  // See doc in config.proto, DelegationRule for exact description of when this
   308  // happens. Basically, all sets in rule must be supersets of corresponding sets
   309  // in RulesQuery.
   310  //
   311  // May return transient errors.
   312  func (rule *delegationRule) matchesQuery(c context.Context, q *RulesQuery) (bool, error) {
   313  	// Rule's 'requestor' set contains the requestor?
   314  	switch found, err := rule.requestors.IsMember(c, q.Requestor); {
   315  	case err != nil:
   316  		return false, err
   317  	case !found:
   318  		return false, nil
   319  	}
   320  
   321  	// Rule's 'delegators' set contains the identity being delegated/impersonated?
   322  	switch yes, err := rule.matchesDelegator(c, q); {
   323  	case err != nil:
   324  		return false, err
   325  	case !yes:
   326  		return false, nil
   327  	}
   328  
   329  	// Rule's 'audience' is superset of requested audience?
   330  	allowedAudience := rule.audience
   331  	if rule.addRequestorToAudience {
   332  		allowedAudience = identityset.Extend(allowedAudience, q.Requestor)
   333  	}
   334  	if !allowedAudience.IsSuperset(q.Audience) {
   335  		return false, nil
   336  	}
   337  
   338  	// Rule's allowed targets is superset of requested targets?
   339  	if !rule.services.IsSuperset(q.Services) {
   340  		return false, nil
   341  	}
   342  
   343  	return true, nil
   344  }
   345  
   346  // matchesDelegator is true if 'q.Delegator' is in 'delegators' set (logically).
   347  func (rule *delegationRule) matchesDelegator(c context.Context, q *RulesQuery) (bool, error) {
   348  	if rule.addProjectsAsDelegators && q.Delegator.Kind() == identity.Project {
   349  		return true, nil
   350  	}
   351  	allowedDelegators := rule.delegators
   352  	if rule.addRequestorAsDelegator {
   353  		allowedDelegators = identityset.Extend(allowedDelegators, q.Requestor)
   354  	}
   355  	return allowedDelegators.IsMember(c, q.Delegator)
   356  }