--- type: change message: |- completely refactor accessctl (again) This time it's using an actual access control list system, rather than whatever it was doing before. The new system uses a Filter type, rather than Condition, to decide which acl element should have its action (allow or deny) applied. This makes testing way easier, since all the different matching conditions are now individual filters, and so are tested individually. change_hash: AFgN0hormIlO0VWkLKnAdSDZeVRbh0Wj8LLXOMVQEK+L credentials: - type: pgp_signature pub_key_id: 95C46FA6A41148AC body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl5yoi8ACgkQlcRvpqQRSKwo/g//QkSA80APnPtwcibNeaoUwduH1Xim8pmi5JScKGsOypYkE0Iy+fO3fRNz4r7Tm6qNn7O04fDsiXlxPojxn7+NDFCQArVgoJk3jVTRDBW7LpahWJsYPP1SBjGtaR9o0bOpclXblTMIcteTkLM94AeASqLaEY8StO+5PX/82AkFRQ8E6m9R2HCmgwbBhqwWp8936x8ekMFbSSi0TMIyV4rpd0wj4mjvjjBwa3ArGmH/ynwabPCFuuqMT6996N1zoDn5EqZA5jGrf+Q7rxsI6t1bOnLmg9NGMQYRaZLAVZrp5P6G5XR3et4Gz/2AphAEgYJM3yLbEjZW6Daa77CgTNHXde7gCaWqyfcKlVGPi29/O+2IXhpjwxHwGpsBgEdX9227zapL+jwSAOdUVj8n6C8I8BGqpT7rTwA53yxlbSwXlkttvAn/lGT5X4lK74YfkzMXMEBZKzsb/dQEPyP2Y+AG6z2D4Bs/4szsCiUXF9aG2Yx1o45lVXTTdPUNLIsnhBjM7usbQRg8i5kC+OC9AVCi8E+lf0/Qgp0cUb6QLH47bHvDTH7UluY1bgSLZy+Zjaisvl3a0aK/UspywWN/fFgOrz2cDw232n8IC+Zi4LSKm7dXDRFbC1JNzrwAPP1ifboOrltwKroOsDNaVGhX8ABahNjmrUO4JgE7gvX+zxXb+/I= account: mediocregophermain
parent
5ebb6597a8
commit
1f422511d5
@ -1,202 +1,151 @@ |
|||||||
|
// Package accessctl implements functionality related to allowing or denying
|
||||||
|
// actions in a repo based on who is taking what actions.
|
||||||
package accessctl |
package accessctl |
||||||
|
|
||||||
import ( |
import ( |
||||||
|
"dehub/sigcred" |
||||||
|
"errors" |
||||||
"fmt" |
"fmt" |
||||||
"sort" |
|
||||||
|
|
||||||
"github.com/bmatcuk/doublestar" |
yaml "gopkg.in/yaml.v2" |
||||||
) |
) |
||||||
|
|
||||||
var ( |
// DefaultAccessControlsStr is the encoded form of the default access control
|
||||||
// DefaultSignatureCondition represents the Condition which is applied for
|
// set which is applied to all CommitRequests if no user-supplied ones match.
|
||||||
// default access controls. It requires a single signature credential from
|
//
|
||||||
// any account defined in the Config.
|
// The effect of these AccessControls is to allow all commit types on any branch
|
||||||
DefaultSignatureCondition = Condition{ |
// (with the exception of the main branch, which only allows change commits), as
|
||||||
Signature: &ConditionSignature{ |
// long as the commit has one signature from a configured account.
|
||||||
AnyAccount: true, |
var DefaultAccessControlsStr = ` |
||||||
Count: "1", |
- action: allow |
||||||
}, |
filters: |
||||||
|
- type: not |
||||||
|
filter: |
||||||
|
type: branch |
||||||
|
pattern: main |
||||||
|
- type: signature |
||||||
|
any_account: true |
||||||
|
count: 1 |
||||||
|
|
||||||
|
- action: allow |
||||||
|
filters: |
||||||
|
- type: branch |
||||||
|
pattern: main |
||||||
|
- type: commit_type |
||||||
|
commit_type: change |
||||||
|
- type: signature |
||||||
|
any_account: true |
||||||
|
count: 1 |
||||||
|
|
||||||
|
- action: deny |
||||||
|
` |
||||||
|
|
||||||
|
// DefaultAccessControls is the decoded form of DefaultAccessControlsStr.
|
||||||
|
var DefaultAccessControls = func() []AccessControl { |
||||||
|
var acl []AccessControl |
||||||
|
if err := yaml.Unmarshal([]byte(DefaultAccessControlsStr), &acl); err != nil { |
||||||
|
panic(err) |
||||||
} |
} |
||||||
|
return acl |
||||||
|
}() |
||||||
|
|
||||||
// DefaultChangeAccessControl represents the ChangeAccessControl which is
|
// CommitRequest is used to describe a set of interactions which are being
|
||||||
// applied when a changed file's path does not match any defined patterns
|
// requested to be performed.
|
||||||
// within a BranchAccessControl.
|
type CommitRequest struct { |
||||||
DefaultChangeAccessControl = ChangeAccessControl{ |
// Type describes what type of commit is being requested. Possibilities are
|
||||||
FilePathPattern: "**", |
// determined by the requester.
|
||||||
Condition: DefaultSignatureCondition, |
Type string |
||||||
} |
|
||||||
|
|
||||||
// DefaultCredentialAccessControl represents the CredentialAccessControl
|
|
||||||
// which is applied when a BranchAccessControl does not have a defined
|
|
||||||
// CredentialAccessControl.
|
|
||||||
DefaultCredentialAccessControl = CredentialAccessControl{ |
|
||||||
Condition: DefaultSignatureCondition, |
|
||||||
} |
|
||||||
|
|
||||||
// DefaultBranchAccessControls represents the BranchAccessControls which are
|
|
||||||
// applied when the name of a branch being interacted with does not match
|
|
||||||
// any defined patterns within the Config.
|
|
||||||
DefaultBranchAccessControls = []BranchAccessControl{ |
|
||||||
{ |
|
||||||
BranchPattern: "main", |
|
||||||
CredentialAccessControl: &CredentialAccessControl{ |
|
||||||
Condition: Condition{Never: new(ConditionNever)}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
{ |
|
||||||
BranchPattern: "**", |
|
||||||
}, |
|
||||||
} |
|
||||||
) |
|
||||||
|
|
||||||
// any account can do anything, except main branch can only get change commits
|
|
||||||
|
|
||||||
// BranchAccessControl represents an access control object defined for the
|
|
||||||
// purpose of controlling who is able to perform what interactions with a
|
|
||||||
// branch.
|
|
||||||
type BranchAccessControl struct { |
|
||||||
BranchPattern string `yaml:"branch_pattern"` |
|
||||||
ChangeAccessControls []ChangeAccessControl `yaml:"change_access_controls,omitempty"` |
|
||||||
CredentialAccessControl *CredentialAccessControl `yaml:"credential_access_control,omitempty"` |
|
||||||
} |
|
||||||
|
|
||||||
// ChangeAccessControl represents an access control object being defined in the
|
|
||||||
// Config for the purpose of controlling who is able to change which files.
|
|
||||||
type ChangeAccessControl struct { |
|
||||||
FilePathPattern string `yaml:"file_path_pattern"` |
|
||||||
Condition Condition `yaml:"condition"` |
|
||||||
} |
|
||||||
|
|
||||||
// CredentialAccessControl represents an access control object being defined in
|
|
||||||
// the Config for the purpose of controlling who is able to create credential
|
|
||||||
// commits.
|
|
||||||
type CredentialAccessControl struct { |
|
||||||
Condition Condition `yaml:"condition"` |
|
||||||
} |
|
||||||
|
|
||||||
// MatchInteractions is used as an input to Match to describe all
|
|
||||||
// interactions which are being attempted on a particular Branch.
|
|
||||||
type MatchInteractions struct { |
|
||||||
// Branch is the name of the branch the interactions are being attempted on.
|
// Branch is the name of the branch the interactions are being attempted on.
|
||||||
// It is required.
|
// It is required.
|
||||||
Branch string |
Branch string |
||||||
|
|
||||||
// FilePathsChanged is the set of file paths (relative to the repo root)
|
// Credentials are the Credential objects attached to the commit.
|
||||||
// which have been modified in some way.
|
Credentials []sigcred.Credential |
||||||
FilePathsChanged []string |
|
||||||
|
|
||||||
// CredentialAdded indicates a credential commit is being added to the
|
// FilesChanged is the set of file paths (relative to the repo root) which
|
||||||
// Branch.
|
// have been modified in some way.
|
||||||
CredentialAdded bool |
FilesChanged []string |
||||||
} |
} |
||||||
|
|
||||||
// MatchedChangeAccessControl contains information about a ChangeAccessControl
|
// Action describes what action an AccessControl should perform
|
||||||
// which was matched in Match
|
// when given a CommitRequest.
|
||||||
type MatchedChangeAccessControl struct { |
type Action string |
||||||
ChangeAccessControl ChangeAccessControl |
|
||||||
|
|
||||||
// FilePaths contains all FilePaths to which this access control was found
|
// Enumerates possible Action values
|
||||||
// to be applicable.
|
const ( |
||||||
FilePaths []string |
ActionAllow Action = "allow" |
||||||
} |
ActionDeny Action = "deny" |
||||||
|
|
||||||
// MatchedCredentialAccessControl contains information about a
|
|
||||||
// CredentialAccessControl which was matched in Match.
|
|
||||||
type MatchedCredentialAccessControl struct { |
|
||||||
CredentialAccessControl CredentialAccessControl |
|
||||||
} |
|
||||||
|
|
||||||
// MatchResult is the result returned from the Match method.
|
// ActionNext is used internally when a request does not match an
|
||||||
type MatchResult struct { |
// AccessControl's filters. It _could_ be used in the Config as well, but it
|
||||||
// BranchPattern indicates the BranchPattern field of the
|
// would be pretty pointless to do so, so we don't talk about it.
|
||||||
// BranchAccessControl object which matched the inputs.
|
ActionNext Action = "next" |
||||||
BranchPattern string |
) |
||||||
|
|
||||||
// ChangeAccessControls indicates which ChangeAccessControl objects matched
|
|
||||||
// the files being changed.
|
|
||||||
ChangeAccessControls []MatchedChangeAccessControl |
|
||||||
|
|
||||||
// CredentialAccessControls indicates which CredentialAccessControl object
|
// AccessControl describes a set of Filters, and the Actions which should be
|
||||||
// matched for a credential commit. Will be nil if CredentialAdded was
|
// taken on a CommitRequest if those Filters all match on the CommitRequest.
|
||||||
// false.
|
type AccessControl struct { |
||||||
CredentialAccessControl *MatchedCredentialAccessControl |
Action Action `yaml:"action"` |
||||||
|
Filters []Filter `yaml:"filters"` |
||||||
} |
} |
||||||
|
|
||||||
// Match takes in a set of access controls and a set of interactions taking
|
// ActionForCommit returns what Action this AccessControl says to take for a
|
||||||
// place, and returns a MatchResult describing the access controls which should
|
// given CommitRequest. It may return ActionNext if the request is not matched
|
||||||
// be applied to the interactions.
|
// by the AccessControl's Filters.
|
||||||
func Match(accessControls []BranchAccessControl, interactions MatchInteractions) (MatchResult, error) { |
func (ac AccessControl) ActionForCommit(req CommitRequest) (Action, error) { |
||||||
var res MatchResult |
for _, filter := range ac.Filters { |
||||||
|
filterI, err := filter.Interface() |
||||||
accessControls = append(accessControls, DefaultBranchAccessControls...) |
if err != nil { |
||||||
|
return "", fmt.Errorf("casting %+v to a FilterInterface: %w", filter, err) |
||||||
// find the applicable BranchAccessControl
|
|
||||||
var branchAC BranchAccessControl |
} else if err := filterI.MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) { |
||||||
{ |
return ActionNext, nil |
||||||
var ok bool |
|
||||||
var err error |
} else if err != nil { |
||||||
for i := range accessControls { |
// ignore the error here, if we could get the FilterInterface then
|
||||||
ok, err = doublestar.Match(accessControls[i].BranchPattern, interactions.Branch) |
// we should be able to get the type.
|
||||||
if err != nil { |
filterTypeStr, _ := filter.Type() |
||||||
return res, fmt.Errorf("matching branch %q to branch_pattern %q: %w", |
return "", fmt.Errorf("matching commit using filter of type %q: %w", filterTypeStr, err) |
||||||
accessControls[i].BranchPattern, interactions.Branch, err) |
|
||||||
} else if ok { |
|
||||||
branchAC = accessControls[i] |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
if !ok { |
|
||||||
panic(fmt.Sprintf("no patterns matched branch %q, which shouldn't be possible", interactions.Branch)) |
|
||||||
} |
} |
||||||
res.BranchPattern = branchAC.BranchPattern |
|
||||||
} |
} |
||||||
|
return ac.Action, nil |
||||||
|
} |
||||||
|
|
||||||
// determine ChangeAccessControl for each path in FilesChanged
|
// ErrCommitRequestDenied is returned from AssertCanCommit when a particular
|
||||||
{ |
// AccessControl has explicitly disallowed the CommitRequest.
|
||||||
changeACs := append(branchAC.ChangeAccessControls, DefaultChangeAccessControl) |
type ErrCommitRequestDenied struct { |
||||||
acToPaths := map[ChangeAccessControl][]string{} |
By AccessControl |
||||||
for _, path := range interactions.FilePathsChanged { |
} |
||||||
var ok bool |
|
||||||
var err error |
|
||||||
for _, ac := range changeACs { |
|
||||||
if ok, err = doublestar.PathMatch(ac.FilePathPattern, path); err != nil { |
|
||||||
return res, fmt.Errorf("matching path %q to file_path_pattern %q: %w", |
|
||||||
path, ac.FilePathPattern, err) |
|
||||||
} else if ok { |
|
||||||
acToPaths[ac] = append(acToPaths[ac], path) |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
if !ok { |
|
||||||
panic(fmt.Sprintf("no patterns matched change path %q, which shouldn't be possible", path)) |
|
||||||
} |
|
||||||
} |
|
||||||
for ac, paths := range acToPaths { |
|
||||||
res.ChangeAccessControls = append(res.ChangeAccessControls, MatchedChangeAccessControl{ |
|
||||||
ChangeAccessControl: ac, |
|
||||||
FilePaths: paths, |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
// sort result for determinancy
|
func (e ErrCommitRequestDenied) Error() string { |
||||||
sort.Slice(res.ChangeAccessControls, func(i, j int) bool { |
acB, err := yaml.Marshal(e.By) |
||||||
pi := res.ChangeAccessControls[i].ChangeAccessControl.FilePathPattern |
if err != nil { |
||||||
pj := res.ChangeAccessControls[j].ChangeAccessControl.FilePathPattern |
panic(err) |
||||||
return pi < pj |
|
||||||
}) |
|
||||||
} |
} |
||||||
|
return fmt.Sprintf("commit matched and denied by this access control:\n%s", string(acB)) |
||||||
|
} |
||||||
|
|
||||||
// Handle CredentialAccessControl, if applicable
|
// AssertCanCommit asserts that the given CommitRequest is allowed by the given
|
||||||
if interactions.CredentialAdded { |
// AccessControls.
|
||||||
credAC := branchAC.CredentialAccessControl |
func AssertCanCommit(acl []AccessControl, req CommitRequest) error { |
||||||
if credAC == nil { |
acl = append(acl, DefaultAccessControls...) |
||||||
credAC = &DefaultCredentialAccessControl |
for _, ac := range acl { |
||||||
|
action, err := ac.ActionForCommit(req) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
} |
} |
||||||
|
switch action { |
||||||
res.CredentialAccessControl = &MatchedCredentialAccessControl{ |
case ActionNext: |
||||||
CredentialAccessControl: *credAC, |
continue |
||||||
|
case ActionAllow: |
||||||
|
return nil |
||||||
|
case ActionDeny: |
||||||
|
return ErrCommitRequestDenied{By: ac} |
||||||
|
default: |
||||||
|
return fmt.Errorf("invalid action %q", action) |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
return res, nil |
panic("should not be able to get here") |
||||||
} |
} |
||||||
|
@ -1,140 +0,0 @@ |
|||||||
package accessctl |
|
||||||
|
|
||||||
import ( |
|
||||||
"dehub/sigcred" |
|
||||||
"dehub/typeobj" |
|
||||||
"errors" |
|
||||||
"fmt" |
|
||||||
"math" |
|
||||||
"strconv" |
|
||||||
"strings" |
|
||||||
) |
|
||||||
|
|
||||||
// ConditionInterface describes the methods that all Signifiers must implement.
|
|
||||||
type ConditionInterface interface { |
|
||||||
|
|
||||||
// Satisfied asserts that the Condition is satisfied by the given set of
|
|
||||||
// Credentials. If it is not (or something else went wrong) then an error is
|
|
||||||
// returned.
|
|
||||||
//
|
|
||||||
// NOTE that Satisfied assumes the Credential has already been Verify'd.
|
|
||||||
Satisfied([]sigcred.Credential) error |
|
||||||
} |
|
||||||
|
|
||||||
// Condition represents an access control condition being defined in the Config.
|
|
||||||
// Only one of its fields may be filled in at a time.
|
|
||||||
type Condition struct { |
|
||||||
Never *ConditionNever `type:"never"` |
|
||||||
Signature *ConditionSignature `type:"signature"` |
|
||||||
} |
|
||||||
|
|
||||||
// MarshalYAML implements the yaml.Marshaler interface.
|
|
||||||
func (c Condition) MarshalYAML() (interface{}, error) { |
|
||||||
return typeobj.MarshalYAML(c) |
|
||||||
} |
|
||||||
|
|
||||||
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
|
||||||
func (c *Condition) UnmarshalYAML(unmarshal func(interface{}) error) error { |
|
||||||
return typeobj.UnmarshalYAML(c, unmarshal) |
|
||||||
} |
|
||||||
|
|
||||||
// Interface returns the ConditionInterface encapsulated by this Condition
|
|
||||||
// object.
|
|
||||||
func (c Condition) Interface() (ConditionInterface, error) { |
|
||||||
el, _, err := typeobj.Element(c) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
return el.(ConditionInterface), nil |
|
||||||
} |
|
||||||
|
|
||||||
// ConditionNever is a Condition which is never satisfied.
|
|
||||||
type ConditionNever struct{} |
|
||||||
|
|
||||||
// Satisfied always returns an error, because ConditionNever cannot be
|
|
||||||
// satisfied.
|
|
||||||
func (condNever ConditionNever) Satisfied([]sigcred.Credential) error { |
|
||||||
return errors.New("condition of type 'never' cannot be satisfied") |
|
||||||
} |
|
||||||
|
|
||||||
// ConditionSignature represents the configuration of an access control
|
|
||||||
// condition which requires one or more signatures to be present on a commit.
|
|
||||||
//
|
|
||||||
// Either AccountIDs or AnyAccount must be filled in.
|
|
||||||
type ConditionSignature struct { |
|
||||||
AccountIDs []string `yaml:"account_ids,omitempty"` |
|
||||||
AnyAccount bool `yaml:"any_account,omitempty"` |
|
||||||
Count string `yaml:"count"` |
|
||||||
} |
|
||||||
|
|
||||||
var _ ConditionInterface = ConditionSignature{} |
|
||||||
|
|
||||||
func (condSig ConditionSignature) targetNum() (int, error) { |
|
||||||
if !strings.HasSuffix(condSig.Count, "%") { |
|
||||||
return strconv.Atoi(condSig.Count) |
|
||||||
} else if condSig.AnyAccount { |
|
||||||
return 0, errors.New("cannot use AnyAccount and a percent Count together") |
|
||||||
} |
|
||||||
|
|
||||||
percentStr := strings.TrimRight(condSig.Count, "%") |
|
||||||
percent, err := strconv.ParseFloat(percentStr, 64) |
|
||||||
if err != nil { |
|
||||||
return 0, fmt.Errorf("could not parse Count as percent %q: %w", condSig.Count, err) |
|
||||||
} |
|
||||||
targetF := float64(len(condSig.AccountIDs)) * percent / 100 |
|
||||||
targetF = math.Ceil(targetF) |
|
||||||
return int(targetF), nil |
|
||||||
} |
|
||||||
|
|
||||||
// ErrConditionSignatureUnsatisfied is returned from ConditionSignature's
|
|
||||||
// Satisfied method when the Condition has not been satisfied.
|
|
||||||
type ErrConditionSignatureUnsatisfied struct { |
|
||||||
TargetNumAccounts, NumAccounts int |
|
||||||
} |
|
||||||
|
|
||||||
func (err ErrConditionSignatureUnsatisfied) Error() string { |
|
||||||
return fmt.Sprintf("not enough valid signature credentials, requires %d but only had %d", |
|
||||||
err.TargetNumAccounts, err.NumAccounts) |
|
||||||
} |
|
||||||
|
|
||||||
// Satisfied asserts that the given Credentials contains enough signatures to be
|
|
||||||
// satisfied.
|
|
||||||
func (condSig ConditionSignature) Satisfied(creds []sigcred.Credential) error { |
|
||||||
targetN, err := condSig.targetNum() |
|
||||||
if err != nil { |
|
||||||
return fmt.Errorf("could not compute ConditionSignature target number of accounts: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
credAccountIDs := map[string]struct{}{} |
|
||||||
for _, cred := range creds { |
|
||||||
// TODO currently only signature credentials are implemented, so we can
|
|
||||||
// just assume that the given AccountID has provided a sig. In the
|
|
||||||
// future this may not be true.
|
|
||||||
credAccountIDs[cred.AccountID] = struct{}{} |
|
||||||
} |
|
||||||
|
|
||||||
var n int |
|
||||||
if condSig.AnyAccount { |
|
||||||
// TODO this doesn't actually check that the accounts are defined in the
|
|
||||||
// Config.
|
|
||||||
n = len(credAccountIDs) |
|
||||||
} else { |
|
||||||
targetAccountIDs := map[string]struct{}{} |
|
||||||
for _, accountID := range condSig.AccountIDs { |
|
||||||
targetAccountIDs[accountID] = struct{}{} |
|
||||||
} |
|
||||||
for accountID := range targetAccountIDs { |
|
||||||
if _, ok := credAccountIDs[accountID]; ok { |
|
||||||
n++ |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if n < targetN { |
|
||||||
return ErrConditionSignatureUnsatisfied{ |
|
||||||
TargetNumAccounts: targetN, |
|
||||||
NumAccounts: n, |
|
||||||
} |
|
||||||
} |
|
||||||
return nil |
|
||||||
} |
|
@ -1,110 +0,0 @@ |
|||||||
package accessctl |
|
||||||
|
|
||||||
import ( |
|
||||||
"dehub/sigcred" |
|
||||||
"reflect" |
|
||||||
"testing" |
|
||||||
) |
|
||||||
|
|
||||||
func TestConditionSignatureSatisfied(t *testing.T) { |
|
||||||
tests := []struct { |
|
||||||
descr string |
|
||||||
cond ConditionSignature |
|
||||||
credAccountIDs []string |
|
||||||
err error |
|
||||||
}{ |
|
||||||
{ |
|
||||||
descr: "no cred accounts", |
|
||||||
cond: ConditionSignature{ |
|
||||||
AnyAccount: true, |
|
||||||
Count: "1", |
|
||||||
}, |
|
||||||
err: ErrConditionSignatureUnsatisfied{ |
|
||||||
TargetNumAccounts: 1, |
|
||||||
NumAccounts: 0, |
|
||||||
}, |
|
||||||
}, |
|
||||||
{ |
|
||||||
descr: "one cred account", |
|
||||||
cond: ConditionSignature{ |
|
||||||
AnyAccount: true, |
|
||||||
Count: "1", |
|
||||||
}, |
|
||||||
credAccountIDs: []string{"foo"}, |
|
||||||
}, |
|
||||||
{ |
|
||||||
descr: "one matching cred account", |
|
||||||
cond: ConditionSignature{ |
|
||||||
AccountIDs: []string{"foo", "bar"}, |
|
||||||
Count: "1", |
|
||||||
}, |
|
||||||
credAccountIDs: []string{"foo"}, |
|
||||||
}, |
|
||||||
{ |
|
||||||
descr: "no matching cred account", |
|
||||||
cond: ConditionSignature{ |
|
||||||
AccountIDs: []string{"foo", "bar"}, |
|
||||||
Count: "1", |
|
||||||
}, |
|
||||||
credAccountIDs: []string{"baz"}, |
|
||||||
err: ErrConditionSignatureUnsatisfied{ |
|
||||||
TargetNumAccounts: 1, |
|
||||||
NumAccounts: 0, |
|
||||||
}, |
|
||||||
}, |
|
||||||
{ |
|
||||||
descr: "two matching cred accounts", |
|
||||||
cond: ConditionSignature{ |
|
||||||
AccountIDs: []string{"foo", "bar"}, |
|
||||||
Count: "2", |
|
||||||
}, |
|
||||||
credAccountIDs: []string{"foo", "bar"}, |
|
||||||
}, |
|
||||||
{ |
|
||||||
descr: "one matching cred account, missing one", |
|
||||||
cond: ConditionSignature{ |
|
||||||
AccountIDs: []string{"foo", "bar"}, |
|
||||||
Count: "2", |
|
||||||
}, |
|
||||||
credAccountIDs: []string{"foo", "baz"}, |
|
||||||
err: ErrConditionSignatureUnsatisfied{ |
|
||||||
TargetNumAccounts: 2, |
|
||||||
NumAccounts: 1, |
|
||||||
}, |
|
||||||
}, |
|
||||||
{ |
|
||||||
descr: "50 percent matching cred accounts", |
|
||||||
cond: ConditionSignature{ |
|
||||||
AccountIDs: []string{"foo", "bar", "baz"}, |
|
||||||
Count: "50%", |
|
||||||
}, |
|
||||||
credAccountIDs: []string{"foo", "bar"}, |
|
||||||
}, |
|
||||||
{ |
|
||||||
descr: "not 50 percent matching cred accounts", |
|
||||||
cond: ConditionSignature{ |
|
||||||
AccountIDs: []string{"foo", "bar", "baz"}, |
|
||||||
Count: "50%", |
|
||||||
}, |
|
||||||
credAccountIDs: []string{"foo"}, |
|
||||||
err: ErrConditionSignatureUnsatisfied{ |
|
||||||
TargetNumAccounts: 2, |
|
||||||
NumAccounts: 1, |
|
||||||
}, |
|
||||||
}, |
|
||||||
} |
|
||||||
|
|
||||||
for _, test := range tests { |
|
||||||
t.Run(test.descr, func(t *testing.T) { |
|
||||||
creds := make([]sigcred.Credential, len(test.credAccountIDs)) |
|
||||||
for i := range test.credAccountIDs { |
|
||||||
creds[i].AccountID = test.credAccountIDs[i] |
|
||||||
} |
|
||||||
|
|
||||||
err := test.cond.Satisfied(creds) |
|
||||||
if !reflect.DeepEqual(err, test.err) { |
|
||||||
t.Fatalf("Satisfied returned %#v\nexpected %#v", err, test.err) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,102 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"dehub/typeobj" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
) |
||||||
|
|
||||||
|
// ErrFilterNoMatch is returned from a FilterInterface's Match method when the
|
||||||
|
// given request was not matched to the filter due to the request itself (as
|
||||||
|
// opposed to some error in the filter's definition).
|
||||||
|
type ErrFilterNoMatch struct { |
||||||
|
Err error |
||||||
|
} |
||||||
|
|
||||||
|
func (err ErrFilterNoMatch) Error() string { |
||||||
|
return fmt.Sprintf("matching with filter: %s", err.Err.Error()) |
||||||
|
} |
||||||
|
|
||||||
|
// FilterInterface describes the methods that all Filters must implement.
|
||||||
|
type FilterInterface interface { |
||||||
|
// MatchCommit returns nil if the CommitRequest is matched by the filter,
|
||||||
|
// otherwise it returns an error (ErrFilterNoMatch if the error is due to
|
||||||
|
// the CommitRequest).
|
||||||
|
MatchCommit(CommitRequest) error |
||||||
|
} |
||||||
|
|
||||||
|
// Filter represents an access control filter being defined in the Config. Only
|
||||||
|
// one of its fields may be filled at a time.
|
||||||
|
type Filter struct { |
||||||
|
Signature *FilterSignature `type:"signature"` |
||||||
|
Branch *FilterBranch `type:"branch"` |
||||||
|
FilesChanged *FilterFilesChanged `type:"files_changed"` |
||||||
|
CommitType *FilterCommitType `type:"commit_type"` |
||||||
|
Not *FilterNot `type:"not"` |
||||||
|
} |
||||||
|
|
||||||
|
// MarshalYAML implements the yaml.Marshaler interface.
|
||||||
|
func (f Filter) MarshalYAML() (interface{}, error) { |
||||||
|
return typeobj.MarshalYAML(f) |
||||||
|
} |
||||||
|
|
||||||
|
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
||||||
|
func (f *Filter) UnmarshalYAML(unmarshal func(interface{}) error) error { |
||||||
|
return typeobj.UnmarshalYAML(f, unmarshal) |
||||||
|
} |
||||||
|
|
||||||
|
// Interface returns the FilterInterface encapsulated by this Filter.
|
||||||
|
func (f Filter) Interface() (FilterInterface, error) { |
||||||
|
el, _, err := typeobj.Element(f) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return el.(FilterInterface), nil |
||||||
|
} |
||||||
|
|
||||||
|
// Type returns a string describing what type of Filter this object
|
||||||
|
// encapsulates, based on which of its fields are filled in.
|
||||||
|
func (f Filter) Type() (string, error) { |
||||||
|
_, typeStr, err := typeobj.Element(f) |
||||||
|
if err != nil { |
||||||
|
return "", err |
||||||
|
} |
||||||
|
return typeStr, nil |
||||||
|
} |
||||||
|
|
||||||
|
// FilterCommitType filters by what type of commit is being requested. Exactly
|
||||||
|
// one of its fields should be filled.
|
||||||
|
type FilterCommitType struct { |
||||||
|
Type string `yaml:"commit_type"` |
||||||
|
Types []string `yaml:"commit_types"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ FilterInterface = FilterCommitType{} |
||||||
|
|
||||||
|
// MatchCommit implements the method for FilterInterface.
|
||||||
|
func (f FilterCommitType) MatchCommit(req CommitRequest) error { |
||||||
|
switch { |
||||||
|
case f.Type != "": |
||||||
|
if f.Type != req.Type { |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: fmt.Errorf("commit type %q does not match filter's type %q", |
||||||
|
req.Type, f.Type), |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
|
||||||
|
case len(f.Types) > 0: |
||||||
|
for _, typ := range f.Types { |
||||||
|
if typ == req.Type { |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: fmt.Errorf("commit type %q does not match any of filter's types %+v", |
||||||
|
req.Type, f.Types), |
||||||
|
} |
||||||
|
|
||||||
|
default: |
||||||
|
return errors.New(`one of the following fields must be set: "commit_type", "commit_types"`) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,31 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
) |
||||||
|
|
||||||
|
// FilterNot wraps another Filter. If that filter matches, FilterNot does not
|
||||||
|
// match, and vice-versa.
|
||||||
|
type FilterNot struct { |
||||||
|
Filter Filter `yaml:"filter"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ FilterInterface = FilterNot{} |
||||||
|
|
||||||
|
// MatchCommit implements the method for FilterInterface.
|
||||||
|
func (f FilterNot) MatchCommit(req CommitRequest) error { |
||||||
|
fI, err := f.Filter.Interface() |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("casting %+v to a FilterInterface: %w", f.Filter, err) |
||||||
|
|
||||||
|
} else if err := fI.MatchCommit(req); errors.As(err, new(ErrFilterNoMatch)) { |
||||||
|
return nil |
||||||
|
} else if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
return ErrFilterNoMatch{Err: errors.New("sub-filter did match")} |
||||||
|
} |
||||||
|
|
||||||
|
// TODO FilterAll
|
||||||
|
// TODO FilterAny
|
@ -0,0 +1,32 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import "testing" |
||||||
|
|
||||||
|
func TestFilterNot(t *testing.T) { |
||||||
|
runCommitMatchTests(t, []filterCommitMatchTest{ |
||||||
|
{ |
||||||
|
descr: "sub-filter does match", |
||||||
|
filter: FilterNot{ |
||||||
|
Filter: Filter{ |
||||||
|
CommitType: &FilterCommitType{Type: "foo"}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
req: CommitRequest{ |
||||||
|
Type: "foo", |
||||||
|
}, |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "sub-filter does not match", |
||||||
|
filter: FilterNot{ |
||||||
|
Filter: Filter{ |
||||||
|
CommitType: &FilterCommitType{Type: "foo"}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
req: CommitRequest{ |
||||||
|
Type: "bar", |
||||||
|
}, |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
@ -0,0 +1,92 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
|
||||||
|
"github.com/bmatcuk/doublestar" |
||||||
|
) |
||||||
|
|
||||||
|
// StringMatcher is used to match against a string. It can use one of several
|
||||||
|
// methods to match. Only one field should be filled at a time.
|
||||||
|
type StringMatcher struct { |
||||||
|
// Pattern, if set, indicates that the Match method should succeed if this
|
||||||
|
// doublestar pattern matches against the string.
|
||||||
|
Pattern string `yaml:"pattern,omitempty"` |
||||||
|
|
||||||
|
// Patterns, if set, indicates that the Match method should succeed if at
|
||||||
|
// least one of these doublestar patterns matches against the string.
|
||||||
|
Patterns []string `yaml:"patterns,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
func doublestarMatch(pattern, str string) (bool, error) { |
||||||
|
ok, err := doublestar.Match(pattern, str) |
||||||
|
if err != nil { |
||||||
|
return false, fmt.Errorf("matching %q on pattern %q: %w", |
||||||
|
str, pattern, err) |
||||||
|
} |
||||||
|
return ok, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Match operates similarly to the Match method of the FilterInterface, except
|
||||||
|
// it only takes in strings.
|
||||||
|
func (m StringMatcher) Match(str string) error { |
||||||
|
switch { |
||||||
|
case m.Pattern != "": |
||||||
|
if ok, err := doublestarMatch(m.Pattern, str); err != nil { |
||||||
|
return err |
||||||
|
} else if !ok { |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: fmt.Errorf("pattern %q does not match %q", m.Pattern, str), |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
|
||||||
|
case len(m.Patterns) > 0: |
||||||
|
for _, pattern := range m.Patterns { |
||||||
|
if ok, err := doublestarMatch(pattern, str); err != nil { |
||||||
|
return err |
||||||
|
} else if ok { |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: fmt.Errorf("no patterns in %+v match %q", m.Patterns, str), |
||||||
|
} |
||||||
|
|
||||||
|
default: |
||||||
|
return errors.New(`one of the following fields must be set: "pattern", "patterns"`) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// FilterBranch matches a CommitRequest's Branch field using a double-star
|
||||||
|
// pattern.
|
||||||
|
type FilterBranch struct { |
||||||
|
StringMatcher StringMatcher `yaml:",inline"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ FilterInterface = FilterBranch{} |
||||||
|
|
||||||
|
// MatchCommit implements the method for FilterInterface.
|
||||||
|
func (f FilterBranch) MatchCommit(req CommitRequest) error { |
||||||
|
return f.StringMatcher.Match(req.Branch) |
||||||
|
} |
||||||
|
|
||||||
|
// FilterFilesChanged matches a CommitRequest's FilesChanged field using a
|
||||||
|
// double-star pattern. It only matches if all of the CommitRequest's
|
||||||
|
// FilesChanged match.
|
||||||
|
type FilterFilesChanged struct { |
||||||
|
StringMatcher StringMatcher `yaml:",inline"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ FilterInterface = FilterFilesChanged{} |
||||||
|
|
||||||
|
// MatchCommit implements the method for FilterInterface.
|
||||||
|
func (f FilterFilesChanged) MatchCommit(req CommitRequest) error { |
||||||
|
for _, path := range req.FilesChanged { |
||||||
|
if err := f.StringMatcher.Match(path); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,134 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"testing" |
||||||
|
) |
||||||
|
|
||||||
|
func TestStringMatcher(t *testing.T) { |
||||||
|
tests := []struct { |
||||||
|
descr string |
||||||
|
matcher StringMatcher |
||||||
|
str string |
||||||
|
match bool |
||||||
|
}{ |
||||||
|
// Pattern
|
||||||
|
{ |
||||||
|
descr: "pattern exact match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo", |
||||||
|
}, |
||||||
|
str: "foo", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern exact no match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo", |
||||||
|
}, |
||||||
|
str: "bar", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern single star match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/*", |
||||||
|
}, |
||||||
|
str: "foo/bar", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern single star no match 1", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/*", |
||||||
|
}, |
||||||
|
str: "foo", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern single star no match 2", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/*", |
||||||
|
}, |
||||||
|
str: "foo/bar/baz", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern double star match 1", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/**", |
||||||
|
}, |
||||||
|
str: "foo/bar", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern double star match 2", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/**", |
||||||
|
}, |
||||||
|
str: "foo/bar/baz", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "pattern double star no match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Pattern: "foo/**", |
||||||
|
}, |
||||||
|
str: "foo", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
|
||||||
|
// Patterns, assumes individual pattern matching works correctly
|
||||||
|
{ |
||||||
|
descr: "patterns single match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Patterns: []string{"foo"}, |
||||||
|
}, |
||||||
|
str: "foo", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "patterns single no match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Patterns: []string{"foo"}, |
||||||
|
}, |
||||||
|
str: "bar", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "patterns multi first match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Patterns: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
str: "foo", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "patterns multi second match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Patterns: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
str: "bar", |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "patterns multi no match", |
||||||
|
matcher: StringMatcher{ |
||||||
|
Patterns: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
str: "baz", |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, test := range tests { |
||||||
|
t.Run(test.descr, func(t *testing.T) { |
||||||
|
err := test.matcher.Match(test.str) |
||||||
|
if test.match && err != nil { |
||||||
|
t.Fatalf("expected to match, got %v", err) |
||||||
|
} else if !test.match && !errors.As(err, new(ErrFilterNoMatch)) { |
||||||
|
t.Fatalf("expected ErrFilterNoMatch, got %#v", err) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,97 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"math" |
||||||
|
"strconv" |
||||||
|
"strings" |
||||||
|
) |
||||||
|
|
||||||
|
// FilterSignature represents the configuration of a Filter which requires one
|
||||||
|
// or more signature credentials to be present on a commit.
|
||||||
|
//
|
||||||
|
// Either AccountIDs or AnyAccount must be filled in.
|
||||||
|
type FilterSignature struct { |
||||||
|
AccountIDs []string `yaml:"account_ids,omitempty"` |
||||||
|
AnyAccount bool `yaml:"any_account,omitempty"` |
||||||
|
Count string `yaml:"count"` |
||||||
|
} |
||||||
|
|
||||||
|
var _ FilterInterface = FilterSignature{} |
||||||
|
|
||||||
|
func (f FilterSignature) targetNum() (int, error) { |
||||||
|
if !strings.HasSuffix(f.Count, "%") { |
||||||
|
return strconv.Atoi(f.Count) |
||||||
|
} else if f.AnyAccount { |
||||||
|
return 0, errors.New("cannot use AnyAccount and a percent Count together") |
||||||
|
} |
||||||
|
|
||||||
|
percentStr := strings.TrimRight(f.Count, "%") |
||||||
|
percent, err := strconv.ParseFloat(percentStr, 64) |
||||||
|
if err != nil { |
||||||
|
return 0, fmt.Errorf("could not parse Count as percent %q: %w", f.Count, err) |
||||||
|
} |
||||||
|
target := float64(len(f.AccountIDs)) * percent / 100 |
||||||
|
target = math.Ceil(target) |
||||||
|
return int(target), nil |
||||||
|
} |
||||||
|
|
||||||
|
// ErrFilterSignatureUnsatisfied is returned from FilterSignature's
|
||||||
|
// Match method when the filter has not been satisfied.
|
||||||
|
type ErrFilterSignatureUnsatisfied struct { |
||||||
|
TargetNumAccounts, NumAccounts int |
||||||
|
} |
||||||
|
|
||||||
|
func (err ErrFilterSignatureUnsatisfied) Error() string { |
||||||
|
return fmt.Sprintf("not enough valid signature credentials, filter requires %d but only had %d", |
||||||
|
err.TargetNumAccounts, err.NumAccounts) |
||||||
|
} |
||||||
|
|
||||||
|
// MatchCommit returns true if the CommitRequest contains a sufficient number of
|
||||||
|
// signature Credentials.
|
||||||
|
func (f FilterSignature) MatchCommit(req CommitRequest) error { |
||||||
|
targetN, err := f.targetNum() |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("computing target number of accounts: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
credAccountIDs := map[string]struct{}{} |
||||||
|
for _, cred := range req.Credentials { |
||||||
|
// TODO support other kinds of signatures
|
||||||
|
if cred.PGPSignature == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
credAccountIDs[cred.AccountID] = struct{}{} |
||||||
|
} |
||||||
|
|
||||||
|
var n int |
||||||
|
if f.AnyAccount { |
||||||
|
// TODO this doesn't actually check that the accounts are defined in the
|
||||||
|
// Config. It works for now as long as the Credentials are valid, since
|
||||||
|
// only an Account defined in the Config could create a valid
|
||||||
|
// Credential, but once that's not the case this will need to be
|
||||||
|
// revisited.
|
||||||
|
n = len(credAccountIDs) |
||||||
|
} else { |
||||||
|
targetAccountIDs := map[string]struct{}{} |
||||||
|
for _, accountID := range f.AccountIDs { |
||||||
|
targetAccountIDs[accountID] = struct{}{} |
||||||
|
} |
||||||
|
for accountID := range targetAccountIDs { |
||||||
|
if _, ok := credAccountIDs[accountID]; ok { |
||||||
|
n++ |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if n >= targetN { |
||||||
|
return nil |
||||||
|
} |
||||||
|
return ErrFilterNoMatch{ |
||||||
|
Err: ErrFilterSignatureUnsatisfied{ |
||||||
|
NumAccounts: n, |
||||||
|
TargetNumAccounts: targetN, |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,103 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"dehub/sigcred" |
||||||
|
"testing" |
||||||
|
) |
||||||
|
|
||||||
|
func TestFilterSignature(t *testing.T) { |
||||||
|
mkReq := func(accountIDs ...string) CommitRequest { |
||||||
|
creds := make([]sigcred.Credential, len(accountIDs)) |
||||||
|
for i := range accountIDs { |
||||||
|
creds[i].PGPSignature = new(sigcred.CredentialPGPSignature) |
||||||
|
creds[i].AccountID = accountIDs[i] |
||||||
|
} |
||||||
|
return CommitRequest{Credentials: creds} |
||||||
|
} |
||||||
|
|
||||||
|
runCommitMatchTests(t, []filterCommitMatchTest{ |
||||||
|
{ |
||||||
|
descr: "no cred accounts", |
||||||
|
filter: FilterSignature{ |
||||||
|
AnyAccount: true, |
||||||
|
Count: "1", |
||||||
|
}, |
||||||
|
matchErr: ErrFilterSignatureUnsatisfied{ |
||||||
|
TargetNumAccounts: 1, |
||||||
|
NumAccounts: 0, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "one cred account", |
||||||
|
filter: FilterSignature{ |
||||||
|
AnyAccount: true, |
||||||
|
Count: "1", |
||||||
|
}, |
||||||
|
req: mkReq("foo"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "one matching cred account", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar"}, |
||||||
|
Count: "1", |
||||||
|
}, |
||||||
|
req: mkReq("foo"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "no matching cred account", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar"}, |
||||||
|
Count: "1", |
||||||
|
}, |
||||||
|
req: mkReq("baz"), |
||||||
|
matchErr: ErrFilterSignatureUnsatisfied{ |
||||||
|
TargetNumAccounts: 1, |
||||||
|
NumAccounts: 0, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "two matching cred accounts", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar"}, |
||||||
|
Count: "2", |
||||||
|
}, |
||||||
|
req: mkReq("foo", "bar"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "one matching cred account, missing one", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar"}, |
||||||
|
Count: "2", |
||||||
|
}, |
||||||
|
req: mkReq("foo", "baz"), |
||||||
|
matchErr: ErrFilterSignatureUnsatisfied{ |
||||||
|
TargetNumAccounts: 2, |
||||||
|
NumAccounts: 1, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "50 percent matching cred accounts", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar", "baz"}, |
||||||
|
Count: "50%", |
||||||
|
}, |
||||||
|
req: mkReq("foo", "bar"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "not 50 percent matching cred accounts", |
||||||
|
filter: FilterSignature{ |
||||||
|
AccountIDs: []string{"foo", "bar", "baz"}, |
||||||
|
Count: "50%", |
||||||
|
}, |
||||||
|
req: mkReq("foo"), |
||||||
|
matchErr: ErrFilterSignatureUnsatisfied{ |
||||||
|
TargetNumAccounts: 2, |
||||||
|
NumAccounts: 1, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
@ -0,0 +1,88 @@ |
|||||||
|
package accessctl |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"reflect" |
||||||
|
"testing" |
||||||
|
) |
||||||
|
|
||||||
|
type filterCommitMatchTest struct { |
||||||
|
descr string |
||||||
|
filter FilterInterface |
||||||
|
req CommitRequest |
||||||
|
match bool |
||||||
|
|
||||||
|
// assumes match == true, and will ensure that the returned wrapped error is
|
||||||
|
// this one.
|
||||||
|
matchErr error |
||||||
|
} |
||||||
|
|
||||||
|
func runCommitMatchTests(t *testing.T, tests []filterCommitMatchTest) { |
||||||
|
for _, test := range tests { |
||||||
|
t.Run(test.descr, func(t *testing.T) { |
||||||
|
err := test.filter.MatchCommit(test.req) |
||||||
|
shouldMatch := test.match && test.matchErr == nil |
||||||
|
if shouldMatch && err != nil { |
||||||
|
t.Fatalf("expected to match, got %v", err) |
||||||
|
|
||||||
|
} else if shouldMatch { |
||||||
|
return |
||||||
|
|
||||||
|
} else if fErr := new(ErrFilterNoMatch); !errors.As(err, fErr) { |
||||||
|
t.Fatalf("expected ErrFilterNoMatch, got %#v", err) |
||||||
|
|
||||||
|
} else if test.matchErr != nil && !reflect.DeepEqual(fErr.Err, test.matchErr) { |
||||||
|
t.Fatalf("expected err %#v, not %#v", test.matchErr, fErr.Err) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestFilterCommitType(t *testing.T) { |
||||||
|
mkReq := func(commitType string) CommitRequest { |
||||||
|
return CommitRequest{Type: commitType} |
||||||
|
} |
||||||
|
|
||||||
|
runCommitMatchTests(t, []filterCommitMatchTest{ |
||||||
|
{ |
||||||
|
descr: "single match", |
||||||
|
filter: FilterCommitType{ |
||||||
|
Type: "foo", |
||||||
|
}, |
||||||
|
req: mkReq("foo"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "single no match", |
||||||
|
filter: FilterCommitType{ |
||||||
|
Type: "foo", |
||||||
|
}, |
||||||
|
req: mkReq("bar"), |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "multi match first", |
||||||
|
filter: FilterCommitType{ |
||||||
|
Types: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
req: mkReq("foo"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "multi match second", |
||||||
|
filter: FilterCommitType{ |
||||||
|
Types: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
req: mkReq("bar"), |
||||||
|
match: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
descr: "multi no match", |
||||||
|
filter: FilterCommitType{ |
||||||
|
Types: []string{"foo", "bar"}, |
||||||
|
}, |
||||||
|
req: mkReq("baz"), |
||||||
|
match: false, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
Loading…
Reference in new issue