Refactor access controls to support multiple branches

message: |-
  Refactor access controls to support multiple branches

  This was a big lift. It implements a backwards incompatible change to overhaul
  access control patterns to also encompass which branch is being interacted with,
  not only which files. The `accessctl` package was significantly rewritten to
  support this, as well as some of the code modifying it. The INTRODUCTION and
  SPEC were also appropriately updated.

  The change to the SPEC is _technically_ backwards incompatible, but it won't
  effect anything. The `access_control` previously being used will just be
  ignored, and the changes to `accessctl` include the definition of fallback
  access controls which will automatically be applied if nothing else matches, so
  when verifying the older history of this repo those will be used.
change_hash: AIfNYLmOLGpuyTiVodT3hDe9lF4E+5DbOTgSdkbjJONb
credentials:
- type: pgp_signature
  pub_key_id: 95C46FA6A41148AC
  body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl5aw0sACgkQlcRvpqQRSKy7kw//UMyS/waV/tE1vntZrMbmEtFmiXPcMVNal76cjhdiF3He50qXoWG6m0qWz+arD1tbjoZml6pvU+Xt45y/Uc54DZizzz0E9azoFW0/uvZiLApftFRArZbT9GhbDs2afalyoTJx/xvQu+a2FD/zfljEWE8Zix+bwHCLojiYHHVA65HFLEt8RsH33jFyzWvS9a2yYqZXL0qrU9tdV68hazdIm1LCp+lyVV74TjwxPAZDOmNAE9l4EjIk1pgr2Qo4u2KwJqCGdVCvka8TiFFYiP7C6319ZhDMyj4m9yZsd1xGtBd9zABVBDgmzCEjt0LI3Tv35lPd2tpFBkjQy0WGcMAhwSHWSP7lxukQMCEB7og/SwtKaExiBJhf1HRO6H9MlhNSW4X1xwUgP+739ixKKUY/RcyXgZ4pkzt6sewAMVbUOcmzXdUvuyDJQ0nhDFowgicpSh1m8tTkN1aLUx18NfnGZRgkgBeE6EpT5/+NBfFwvpiQkXZ3bcMiNhNTU/UnWMyqjKlog+8Ca/7CqgswYppMaw4iPaC54H8P6JTH+XnqDlLKSkvh7PiJJa5nFDG07fqc8lYKm1KGv6virAhEsz/AYKLoNGIsqXt+mYUDLvQpjlRsiN52euxyn5e41LcrH0RidIGMVeaS+7re1pWbkCkMMMtYlnCbC5L6mfrBu6doN8o=
  account: mediocregopher
This commit is contained in:
mediocregopher 2020-02-29 13:02:25 -07:00 committed by Brian Picciano
parent 2add3a2501
commit 76309b51cb
11 changed files with 537 additions and 239 deletions

View File

@ -4,11 +4,3 @@ accounts:
signifiers: signifiers:
- type: pgp_public_key_file - type: pgp_public_key_file
path: ".dehub/mediocregopher.asc" path: ".dehub/mediocregopher.asc"
access_controls:
- pattern: "**"
condition:
type: signature
account_ids:
- mediocregopher
count: 100%

View File

@ -39,9 +39,9 @@ platforms into the git history itself, including dehub's own configuration.
By doing this, the server-side git component can be reduced to a mere By doing this, the server-side git component can be reduced to a mere
pre-receive hook (if anything at all). This opens the door for much more pre-receive hook (if anything at all). This opens the door for much more
lightweight and flexible hosting of git projects, and even more radical lightweight and flexible hosting of git projects, as well as even more radical
solutions like hosting git projects on completely decentralized platforms like solutions; dehub can enable hosting git projects on completely decentralized
IPFS. platforms like IPFS.
### Example ### Example
@ -53,9 +53,10 @@ equivalent of the `master` branch). MyProject's repo would contain a
``` ```
# ... # ...
access_controls: access_controls:
- branch_pattern: trunk
change_access_controls:
# matches all files, but could be used for more fine-grained control # matches all files, but could be used for more fine-grained control
- pattern: "**" - file_path_pattern: "**"
condition: condition:
type: signature type: signature
account_ids: account_ids:
@ -102,8 +103,7 @@ needed to verify commits in `trunk` when they are pushed or pulled.
## dehub Thread Branches ## dehub Thread Branches
The `trunk` branch is the project's source-of-truth; all commits in it must have The `trunk` branch is the project's source-of-truth. Other branches, called
dehub encoded message bodies with acceptable credentials. Other branches, called
threads, are used to coordinate new changes, and then coalesce those changes threads, are used to coordinate new changes, and then coalesce those changes
into a commit suitable for `trunk`. into a commit suitable for `trunk`.

41
SPEC.md
View File

@ -28,20 +28,28 @@ accounts:
- type: "keybase" - type: "keybase"
user: "some_keybase_user_id" user: "some_keybase_user_id"
# access_controls defines under what conditions different files in the repo may # access_controls define who may do what in the repo. The value is a list of
# be modified. For each file modified in a commit, all access control patterns # access control objects, each applying to one or more potential branch names.
# are applied sequentially until one matches, and the associated access control
# conditions are checked. A commit is only allowed if the conditions of all
# modified files are met.
access_controls: access_controls:
# pattern is a glob pattern describing what files this access control # branch_pattern is a glob pattern describing what branch names this access
# control applies to. The first matching branch_pattern for a branch name
# defines which access controls are applied.
- branch_pattern: trunk
# change_access_controls is an array of possible access controls applied for
# files being changed in the branch
change_access_controls:
# file_path_pattern is a glob pattern describing what files this access control
# applies to. Single star matches all characters except path separators, # applies to. Single star matches all characters except path separators,
# double star matches everything. # double star matches everything. The first matching file_path_pattern for a
- pattern: ".dehub/**" # file path (relative to the repo root) defines which access controls are
# applied.
- file_path_pattern: ".dehub/**"
# signature conditions indicate that a commit must be signed by one or # signature conditions indicate that a commit must be signed by one or
# more accounts to be allowed. # more accounts in order to be allowed.
condition: condition:
type: signature type: signature
@ -53,16 +61,27 @@ access_controls:
# count describes how many signatures are required. It can be either a # count describes how many signatures are required. It can be either a
# contrete integer (e.g. 2, meaning any 2 accounts listed by # contrete integer (e.g. 2, meaning any 2 accounts listed by
# account_ids) or a percent. # account_ids) or a percent.
count: 100% count: 100% # all accounts in account_ids must sign
# This catch-all pattern for the rest of the repo requires that changes to # This catch-all pattern for the rest of the repo requires that changes to
# any files not under `.dehub/` are signed by at least one of the # any files not under `.dehub/` are signed by at least one of the
# defined accounts. # defined accounts.
- pattern: "**" - file_path_pattern: "**"
condition: condition:
type: signature type: signature
any_account: true # indicates any account defined in accounts is valid any_account: true # indicates any account defined in accounts is valid
count: 1 count: 1
# If a branch is not matched by any access control object then the following
# default object is implied:
#
# branch_pattern: **
# change_access_controls:
# - file_path_pattern: **
# condition:
# type: signature
# any_account: true
# count: 1
``` ```
# Change Hash # Change Hash

View File

@ -2,52 +2,153 @@ package accessctl
import ( import (
"fmt" "fmt"
"sort"
"github.com/bmatcuk/doublestar" "github.com/bmatcuk/doublestar"
) )
// AccessControl represents an access control object being defined in the var (
// Config. // DefaultChangeAccessControl represents the ChangeAccessControl which is
type AccessControl struct { // applied when a changed file's path does not match any defined patterns
Pattern string `yaml:"pattern"` // within a BranchAccessControl.
DefaultChangeAccessControl = ChangeAccessControl{
FilePathPattern: "**",
Condition: Condition{
Signature: &ConditionSignature{
AnyAccount: true,
Count: "1",
},
},
}
// 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{
// These are currently the same, but they will differ once things like
// comments start being implemented.
{
BranchPattern: "trunk",
ChangeAccessControls: []ChangeAccessControl{DefaultChangeAccessControl},
},
{
BranchPattern: "**",
ChangeAccessControls: []ChangeAccessControl{DefaultChangeAccessControl},
},
}
)
// 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"`
}
// 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"` Condition Condition `yaml:"condition"`
} }
// ErrNoApplicableAccessControls is returned from ApplicableAccessControls when // MatchInteractions is used as an input to Match to describe all
// a changed path has no applicable AccessControls which match it. // interactions which are being attempted on a particular Branch.
type ErrNoApplicableAccessControls struct { type MatchInteractions struct {
Path string // Branch is the name of the branch the interactions are being attempted on.
// It is required.
Branch string
// FilePathsChanged is the set of file paths (relative to the repo root)
// which have been modified in some way.
FilePathsChanged []string
} }
func (err ErrNoApplicableAccessControls) Error() string { // MatchedChangeAccessControl contains information about a ChangeAccessControl
return fmt.Sprintf("no AccessControls which apply to changed file %q", err.Path) // which was matched in Match
type MatchedChangeAccessControl struct {
ChangeAccessControl ChangeAccessControl
// FilePaths contains all FilePaths to which this access control was found
// to be applicable.
FilePaths []string
} }
// ApplicableAccessControls returns a subset of the given AccessControls which // MatchResult is the result returned from the Match method.
// are applicable to the given file paths (ie those whose Conditions must be met type MatchResult struct {
// in order for the changes to go through. // BranchPattern indicates the BranchPattern field of the
func ApplicableAccessControls(accessControls []AccessControl, filesChanged []string) ([]AccessControl, error) { // BranchAccessControl object which matched the inputs.
applicableSet := map[AccessControl]struct{}{} BranchPattern string
for _, path := range filesChanged {
var any bool // ChangeAccessControls indicates which ChangeAccessControl objects matched
for _, ac := range accessControls { // the files being changed.
if ok, err := doublestar.PathMatch(ac.Pattern, path); err != nil { ChangeAccessControls []MatchedChangeAccessControl
return nil, fmt.Errorf("error matching path %q to patterrn %q: %w", }
path, ac.Pattern, err)
// Match takes in a set of access controls and a set of interactions taking
// place, and returns a MatchResult describing the access controls which should
// be applied to the interactions.
func Match(accessControls []BranchAccessControl, interactions MatchInteractions) (MatchResult, error) {
var res MatchResult
accessControls = append(accessControls, DefaultBranchAccessControls...)
// find the applicable BranchAccessControl
var branchAC BranchAccessControl
{
var ok bool
var err error
for i := range accessControls {
ok, err = doublestar.Match(accessControls[i].BranchPattern, interactions.Branch)
if err != nil {
return res, fmt.Errorf("error matching branch %q to pattern %q: %w",
accessControls[i].BranchPattern, interactions.Branch, err)
} else if ok { } else if ok {
applicableSet[ac] = struct{}{} branchAC = accessControls[i]
any = true
break break
} }
} }
if !any { if !ok {
return nil, ErrNoApplicableAccessControls{Path: path} panic(fmt.Sprintf("no patterns matched branch %q, which shouldn't be possible", interactions.Branch))
} }
res.BranchPattern = branchAC.BranchPattern
} }
applicable := make([]AccessControl, 0, len(applicableSet)) // determine ChangeAccessControl for each path in FilesChanged
for ac := range applicableSet { {
applicable = append(applicable, ac) changeACs := append(branchAC.ChangeAccessControls, DefaultChangeAccessControl)
acToPaths := map[ChangeAccessControl][]string{}
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("error matching path %q to patterrn %q: %w",
path, ac.FilePathPattern, err)
} else if ok {
acToPaths[ac] = append(acToPaths[ac], path)
break
} }
return applicable, nil }
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
sort.Slice(res.ChangeAccessControls, func(i, j int) bool {
pi := res.ChangeAccessControls[i].ChangeAccessControl.FilePathPattern
pj := res.ChangeAccessControls[j].ChangeAccessControl.FilePathPattern
return pi < pj
})
}
return res, nil
} }

View File

@ -1,117 +1,177 @@
package accessctl package accessctl
import ( import (
"errors"
"reflect" "reflect"
"sort"
"testing" "testing"
"github.com/davecgh/go-spew/spew"
) )
func TestApplicableAccessControls(t *testing.T) { func normalizeResult(res MatchResult) MatchResult {
if len(res.ChangeAccessControls) == 0 {
res.ChangeAccessControls = nil
}
return res
}
func TestMatch(t *testing.T) {
secondCond := Condition{
Signature: &ConditionSignature{
AnyAccount: true,
Count: "2",
},
}
tests := []struct { tests := []struct {
descr string descr string
patterns, filesChanged []string
exp []string branchACs []BranchAccessControl
expErrPath string interactions MatchInteractions
result MatchResult
}{ }{
{ {
descr: "empty input empty output", descr: "empty input empty result",
result: MatchResult{
BranchPattern: "**",
},
}, },
{ {
descr: "empty patterns", descr: "empty access controls",
filesChanged: []string{"foo", "bar"}, interactions: MatchInteractions{
expErrPath: "foo", Branch: "trunk",
FilePathsChanged: []string{"foo", "bar"},
},
result: MatchResult{
BranchPattern: "trunk",
ChangeAccessControls: []MatchedChangeAccessControl{
{
ChangeAccessControl: DefaultChangeAccessControl,
FilePaths: []string{"foo", "bar"},
},
},
},
}, },
{ {
descr: "empty filesChanged", descr: "empty filesPathsChanged",
patterns: []string{"patternA", "patternB"}, branchACs: DefaultBranchAccessControls,
interactions: MatchInteractions{Branch: "trunk"},
result: MatchResult{BranchPattern: "trunk"},
}, },
{ {
descr: "no applicable files", descr: "no matching branch patterns",
filesChanged: []string{"foo"}, branchACs: []BranchAccessControl{{
patterns: []string{"bar"}, BranchPattern: "dunk",
expErrPath: "foo", ChangeAccessControls: []ChangeAccessControl{{
FilePathPattern: "**",
Condition: secondCond,
}},
}},
interactions: MatchInteractions{
Branch: "crunk",
FilePathsChanged: []string{"foo"},
},
result: MatchResult{
BranchPattern: "**",
ChangeAccessControls: []MatchedChangeAccessControl{{
ChangeAccessControl: DefaultChangeAccessControl,
FilePaths: []string{"foo"},
}},
},
}, },
{ {
descr: "all applicable files", descr: "no matching files",
filesChanged: []string{"foo", "bar"}, branchACs: []BranchAccessControl{{
patterns: []string{"**"}, BranchPattern: "trunk",
exp: []string{"**"}, ChangeAccessControls: []ChangeAccessControl{{
FilePathPattern: "boo",
Condition: secondCond,
}},
}},
interactions: MatchInteractions{
Branch: "trunk",
FilePathsChanged: []string{"foo"},
},
result: MatchResult{
BranchPattern: "trunk",
ChangeAccessControls: []MatchedChangeAccessControl{{
ChangeAccessControl: DefaultChangeAccessControl,
FilePaths: []string{"foo"},
}},
},
}, },
{ {
descr: "pattern precedent", descr: "branch pattern precedent",
filesChanged: []string{"foo"}, branchACs: []BranchAccessControl{
patterns: []string{"foo", "**"}, {
exp: []string{"foo"}, BranchPattern: "trunk",
ChangeAccessControls: []ChangeAccessControl{{
FilePathPattern: "foo",
Condition: secondCond,
}},
}, },
{ {
descr: "pattern precedent inv", BranchPattern: "**",
filesChanged: []string{"foo"}, ChangeAccessControls: []ChangeAccessControl{
patterns: []string{"**", "foo"}, DefaultChangeAccessControl,
exp: []string{"**"}, },
},
},
interactions: MatchInteractions{
Branch: "trunk",
FilePathsChanged: []string{"foo"},
},
result: MatchResult{
BranchPattern: "trunk",
ChangeAccessControls: []MatchedChangeAccessControl{{
ChangeAccessControl: ChangeAccessControl{
FilePathPattern: "foo",
Condition: secondCond,
},
FilePaths: []string{"foo"},
}},
},
}, },
{ {
descr: "individual matches", descr: "multiple files matching FilePathPatterns",
filesChanged: []string{"foo", "bar/baz"}, branchACs: []BranchAccessControl{{
patterns: []string{"foo", "bar/baz"}, BranchPattern: "trunk",
exp: []string{"foo", "bar/baz"}, ChangeAccessControls: []ChangeAccessControl{{
FilePathPattern: "foo*",
Condition: secondCond,
}},
}},
interactions: MatchInteractions{
Branch: "trunk",
FilePathsChanged: []string{"foo_a", "bar", "foo_b"},
},
result: MatchResult{
BranchPattern: "trunk",
ChangeAccessControls: []MatchedChangeAccessControl{
{
ChangeAccessControl: DefaultChangeAccessControl,
FilePaths: []string{"bar"},
}, },
{ {
descr: "star match dir", ChangeAccessControl: ChangeAccessControl{
filesChanged: []string{"foo", "bar/baz"}, FilePathPattern: "foo*",
patterns: []string{"foo", "bar/*"}, Condition: secondCond,
exp: []string{"foo", "bar/*"}, },
FilePaths: []string{"foo_a", "foo_b"},
},
}, },
{
descr: "star not match dir",
filesChanged: []string{"foo", "bar/baz/biz"},
patterns: []string{"foo", "bar/*"},
expErrPath: "bar/baz/biz",
}, },
{
descr: "doublestar match dir",
filesChanged: []string{"foo", "bar/bar", "bar/baz/biz"},
patterns: []string{"foo", "bar/**"},
exp: []string{"foo", "bar/**"},
}, },
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.descr, func(t *testing.T) { t.Run(test.descr, func(t *testing.T) {
accessControls := make([]AccessControl, len(test.patterns)) res, err := Match(test.branchACs, test.interactions)
for i := range test.patterns { if err != nil {
accessControls[i] = AccessControl{Pattern: test.patterns[i]} t.Fatalf("error matching: %v", err)
} }
res, expRes := normalizeResult(res), normalizeResult(test.result)
out, err := ApplicableAccessControls(accessControls, test.filesChanged) if !reflect.DeepEqual(res, expRes) {
if err != nil && test.expErrPath == "" { t.Fatalf("expected:%s\ngot: %s", spew.Sdump(expRes), spew.Sdump(res))
t.Fatalf("unexpected error: %v", err)
} else if test.expErrPath != "" {
if noAppErr := (ErrNoApplicableAccessControls{}); !errors.As(err, &noAppErr) {
t.Fatalf("expected ErrNoApplicableAccessControls for path %q, but got %v", test.expErrPath, err)
} else if test.expErrPath != noAppErr.Path {
t.Fatalf("expected ErrNoApplicableAccessControls for path %q, but got one for path %q", test.expErrPath, noAppErr.Path)
}
return
}
outPatterns := make([]string, len(out))
for i := range out {
outPatterns[i] = out[i].Pattern
}
clean := func(s []string) []string {
if len(s) == 0 {
return nil
}
sort.Strings(s)
return s
}
outPatterns = clean(outPatterns)
test.exp = clean(test.exp)
if !reflect.DeepEqual(outPatterns, test.exp) {
t.Fatalf("expected: %+v\ngot: %+v", test.exp, outPatterns)
} }
}) })
} }

View File

@ -174,6 +174,7 @@ var subCmds = []subCmd{
descr: "verifies one or more commits as having the proper credentials", descr: "verifies one or more commits as having the proper credentials",
body: func(sctx subCmdCtx) error { body: func(sctx subCmdCtx) error {
rev := sctx.flag.String("rev", "HEAD", "Revision of commit to verify") rev := sctx.flag.String("rev", "HEAD", "Revision of commit to verify")
branch := sctx.flag.String("branch", "", "Branch that the revision is on. If not given then the currently checked out branch is assumed")
sctx.flagParse() sctx.flagParse()
h, err := sctx.repo().GitRepo.ResolveRevision(plumbing.Revision(*rev)) h, err := sctx.repo().GitRepo.ResolveRevision(plumbing.Revision(*rev))
@ -181,7 +182,16 @@ var subCmds = []subCmd{
return fmt.Errorf("could not resolve revision %q: %w", *rev, err) return fmt.Errorf("could not resolve revision %q: %w", *rev, err)
} }
if err := sctx.repo().VerifyChangeCommit(*h); err != nil { var branchName plumbing.ReferenceName
if *branch == "" {
if branchName, err = sctx.repo().CheckedOutBranch(); err != nil {
return fmt.Errorf("could not determined currently checked out branch: %w", err)
}
} else {
branchName = plumbing.NewBranchReferenceName(*branch)
}
if err := sctx.repo().VerifyChangeCommit(branchName, *h); err != nil {
return fmt.Errorf("could not verify commit at %q (%s): %w", *rev, *h, err) return fmt.Errorf("could not verify commit at %q (%s): %w", *rev, *h, err)
} }
@ -209,15 +219,14 @@ var subCmds = []subCmd{
} else if err != nil { } else if err != nil {
return fmt.Errorf("error reading next line from stdin: %w", err) return fmt.Errorf("error reading next line from stdin: %w", err)
} }
fmt.Printf("Processing line %q\n", strings.TrimSpace(line))
lineParts := strings.Fields(line) lineParts := strings.Fields(line)
if len(lineParts) < 3 { if len(lineParts) < 3 {
return fmt.Errorf("malformed pre-receive hook stdin line %q", line) return fmt.Errorf("malformed pre-receive hook stdin line %q", line)
} }
if plumbing.ReferenceName(lineParts[2]) != dehub.Trunk { branchName := plumbing.ReferenceName(lineParts[2])
return fmt.Errorf("only commits to the trunk branch are allowed at the moment (tried to push to %q)", lineParts[2])
}
// the zeroRevision gets sent on the very first push // the zeroRevision gets sent on the very first push
const zeroRevision plumbing.Revision = "0000000000000000000000000000000000000000" const zeroRevision plumbing.Revision = "0000000000000000000000000000000000000000"
@ -269,8 +278,8 @@ var subCmds = []subCmd{
for i := len(hashesToCheck) - 1; i >= 0; i-- { for i := len(hashesToCheck) - 1; i >= 0; i-- {
hash := hashesToCheck[i] hash := hashesToCheck[i]
fmt.Printf("Verifying change commit %q\n", hash) fmt.Printf("Verifying change commit %q\n", hash)
if err := sctx.repo().VerifyChangeCommit(hash); err != nil { if err := sctx.repo().VerifyChangeCommit(branchName, hash); err != nil {
return fmt.Errorf("could not verify change commit %q", hash) return fmt.Errorf("could not verify change commit %q: %w", hash, err)
} }
} }
fmt.Println("All pushed commits have been verified, well done.") fmt.Println("All pushed commits have been verified, well done.")

View File

@ -116,8 +116,8 @@ func (r *Repo) HasStagedChanges() (bool, error) {
return any, nil return any, nil
} }
// NewChangeCommit constructs a ChangeCommit using the given SignifierInterface // NewChangeCommit constructs a ChangeCommit. If sig is given then it is used to
// to create a Credential for it. // create a Credential for the ChangeCommit.
func (r *Repo) NewChangeCommit(msg, accountID string, sig sigcred.SignifierInterface) (ChangeCommit, error) { func (r *Repo) NewChangeCommit(msg, accountID string, sig sigcred.SignifierInterface) (ChangeCommit, error) {
_, headTree, err := r.head() _, headTree, err := r.head()
if errors.Is(err, plumbing.ErrReferenceNotFound) { if errors.Is(err, plumbing.ErrReferenceNotFound) {
@ -131,49 +131,38 @@ func (r *Repo) NewChangeCommit(msg, accountID string, sig sigcred.SignifierInter
return ChangeCommit{}, err return ChangeCommit{}, err
} }
// this is necessarily different than headTree for the case of there being changeHash := genChangeHash(nil, msg, headTree, stagedTree)
// no HEAD (ie it's the first commit). In that case we want headTree to be
// empty (because it's being used to generate the change hash), but we want var creds []sigcred.Credential
// the signifier to use the raw fs (because that's where the signifier's if sig != nil {
// data might be). // this is necessarily different than headTree for the case of there
// being no HEAD (ie it's the first commit). In that case we want
// headTree to be empty (because it's being used to generate the change
// hash), but we want the signifier to use the raw fs (because that's
// where the signifier's data might be).
sigFS, err := r.headOrRawFS() sigFS, err := r.headOrRawFS()
if err != nil { if err != nil {
return ChangeCommit{}, err return ChangeCommit{}, err
} }
cfg, err := r.loadConfig(sigFS)
if err != nil {
return ChangeCommit{}, fmt.Errorf("could not load config: %w", err)
}
changeHash := genChangeHash(nil, msg, headTree, stagedTree)
cred, err := sig.Sign(sigFS, changeHash) cred, err := sig.Sign(sigFS, changeHash)
if err != nil { if err != nil {
return ChangeCommit{}, fmt.Errorf("failed to sign commit hash: %w", err) return ChangeCommit{}, fmt.Errorf("failed to sign commit hash: %w", err)
} }
cred.AccountID = accountID cred.AccountID = accountID
creds = append(creds, cred)
// This isn't strictly necessary, but we want to save people the effort of
// creating an invalid commit, pushing it, having it be rejected, then
// having to reset on the commit.
err = r.assertAccessControls(
cfg.AccessControls, []sigcred.Credential{cred},
headTree, stagedTree,
)
if err != nil {
return ChangeCommit{}, fmt.Errorf("commit would not satisfy access controls: %w", err)
} }
return ChangeCommit{ return ChangeCommit{
Message: msg, Message: msg,
ChangeHash: changeHash, ChangeHash: changeHash,
Credentials: []sigcred.Credential{cred}, Credentials: creds,
}, nil }, nil
} }
func (r *Repo) assertAccessControls( func (r *Repo) assertAccessControls(
accessCtls []accessctl.AccessControl, creds []sigcred.Credential, accessCtls []accessctl.BranchAccessControl, creds []sigcred.Credential,
from, to *object.Tree, branch plumbing.ReferenceName, from, to *object.Tree,
) error { ) error {
filesChanged, err := calcDiff(from, to) filesChanged, err := calcDiff(from, to)
if err != nil { if err != nil {
@ -185,18 +174,23 @@ func (r *Repo) assertAccessControls(
pathsChanged[i] = filesChanged[i].path pathsChanged[i] = filesChanged[i].path
} }
accessCtls, err = accessctl.ApplicableAccessControls(accessCtls, pathsChanged) matchRes, err := accessctl.Match(accessCtls, accessctl.MatchInteractions{
Branch: branch.Short(),
FilePathsChanged: pathsChanged,
})
if err != nil { if err != nil {
return fmt.Errorf("could not determine applicable access controls: %w", err) return fmt.Errorf("could not determine applicable access controls: %w", err)
} }
for _, accessCtl := range accessCtls { for _, matchedAC := range matchRes.ChangeAccessControls {
condInt, err := accessCtl.Condition.Interface() ac := matchedAC.ChangeAccessControl
condInt, err := ac.Condition.Interface()
if err != nil { if err != nil {
return fmt.Errorf("could not cast Condition to interface: %w", err) return fmt.Errorf("could not cast Condition of file path pattern %q to interface: %w",
ac.FilePathPattern, err)
} else if err := condInt.Satisfied(creds); err != nil { } else if err := condInt.Satisfied(creds); err != nil {
return fmt.Errorf("access control for pattern %q not satisfied: %w", return fmt.Errorf("access control of file path pattern %q not satisfied: %w\nFiles matched:\n%s",
accessCtl.Pattern, err) ac.FilePathPattern, err, strings.Join(matchedAC.FilePaths, "\n"))
} }
} }
@ -204,8 +198,8 @@ func (r *Repo) assertAccessControls(
} }
// VerifyChangeCommit verifies that the change commit at the given hash, which // VerifyChangeCommit verifies that the change commit at the given hash, which
// is presumably on the repo trunk, is gucci. // is presumably on the given branch, is gucci.
func (r *Repo) VerifyChangeCommit(h plumbing.Hash) error { func (r *Repo) VerifyChangeCommit(branch plumbing.ReferenceName, h plumbing.Hash) error {
commit, err := r.GitRepo.CommitObject(h) commit, err := r.GitRepo.CommitObject(h)
if err != nil { if err != nil {
return fmt.Errorf("could not retrieve commit object: %w", err) return fmt.Errorf("could not retrieve commit object: %w", err)
@ -241,7 +235,7 @@ func (r *Repo) VerifyChangeCommit(h plumbing.Hash) error {
err = r.assertAccessControls( err = r.assertAccessControls(
cfg.AccessControls, changeCommit.Credentials, cfg.AccessControls, changeCommit.Credentials,
parentTree, commitTree, branch, parentTree, commitTree,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to satisfy all access controls: %w", err) return fmt.Errorf("failed to satisfy all access controls: %w", err)

View File

@ -1,14 +1,13 @@
package dehub package dehub
import ( import (
"dehub/accessctl"
"dehub/sigcred" "dehub/sigcred"
"errors"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"github.com/davecgh/go-spew/spew" "github.com/davecgh/go-spew/spew"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
) )
@ -82,7 +81,7 @@ func TestChangeCommitVerify(t *testing.T) {
account := h.cfg.Accounts[0] account := h.cfg.Accounts[0]
changeCommit, hash := h.changeCommit(step.msg, account.ID, h.sig) changeCommit, hash := h.changeCommit(step.msg, account.ID, h.sig)
if err := h.repo.VerifyChangeCommit(hash); err != nil { if err := h.repo.VerifyChangeCommit(TrunkRefName, hash); err != nil {
t.Fatalf("could not verify hash %v: %v", hash, err) t.Fatalf("could not verify hash %v: %v", hash, err)
} }
@ -119,8 +118,8 @@ func TestConfigChange(t *testing.T) {
_, hash := h.changeCommit("commit configuration", h.cfg.Accounts[0].ID, h.sig) _, hash := h.changeCommit("commit configuration", h.cfg.Accounts[0].ID, h.sig)
hashes = append(hashes, hash) hashes = append(hashes, hash)
// create a new account and add it to the configuration. It should not be // create a new account and add it to the configuration. That commit should
// able to actually make that commit though. // not be verifiable, though
newSig, newPubKeyBody := sigcred.SignifierPGPTmp(h.rand) newSig, newPubKeyBody := sigcred.SignifierPGPTmp(h.rand)
h.cfg.Accounts = append(h.cfg.Accounts, Account{ h.cfg.Accounts = append(h.cfg.Accounts, Account{
ID: "toot", ID: "toot",
@ -128,21 +127,23 @@ func TestConfigChange(t *testing.T) {
Body: string(newPubKeyBody), Body: string(newPubKeyBody),
}}}, }}},
}) })
h.cfg.AccessControls[0].Condition.Signature.AccountIDs = []string{"root", "toot"} h.cfg.AccessControls[0].ChangeAccessControls[0].Condition.Signature.AccountIDs = []string{"root", "toot"}
h.cfg.AccessControls[0].Condition.Signature.Count = "1" h.cfg.AccessControls[0].ChangeAccessControls[0].Condition.Signature.Count = "1"
cfgBody, err := yaml.Marshal(h.cfg) cfgBody, err := yaml.Marshal(h.cfg)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
h.stage(map[string]string{ConfigPath: string(cfgBody)}) h.stage(map[string]string{ConfigPath: string(cfgBody)})
_, badHash := h.changeCommit("add toot user", h.cfg.Accounts[1].ID, newSig)
_, err = h.repo.NewChangeCommit("add toot user", h.cfg.Accounts[1].ID, newSig) if err := h.repo.VerifyChangeCommit(TrunkRefName, badHash); err == nil {
if aclErr := (accessctl.ErrConditionSignatureUnsatisfied{}); !errors.As(err, &aclErr) { t.Fatal("toot user shouldn't be able to add itself to config")
t.Fatalf("NewChangeCommit should have returned an ErrConditionSignatureUnsatisfied, but returned %v", err)
} }
h.reset(hash, git.HardReset)
// now add with the root user, this should work. // now add with the root user, this should work.
h.stage(map[string]string{ConfigPath: string(cfgBody)})
_, hash = h.changeCommit("add toot user", h.cfg.Accounts[0].ID, h.sig) _, hash = h.changeCommit("add toot user", h.cfg.Accounts[0].ID, h.sig)
hashes = append(hashes, hash) hashes = append(hashes, hash)
@ -152,7 +153,7 @@ func TestConfigChange(t *testing.T) {
hashes = append(hashes, hash) hashes = append(hashes, hash)
for i, hash := range hashes { for i, hash := range hashes {
if err := h.repo.VerifyChangeCommit(hash); err != nil { if err := h.repo.VerifyChangeCommit(TrunkRefName, hash); err != nil {
t.Fatalf("commit %d (%v) should have been verified but wasn't: %v", i, hash, err) t.Fatalf("commit %d (%v) should have been verified but wasn't: %v", i, hash, err)
} }
} }

View File

@ -21,7 +21,7 @@ type Account struct {
// used to marshal/unmarshal the yaml file. // used to marshal/unmarshal the yaml file.
type Config struct { type Config struct {
Accounts []Account `yaml:"accounts"` Accounts []Account `yaml:"accounts"`
AccessControls []accessctl.AccessControl `yaml:"access_controls"` AccessControls []accessctl.BranchAccessControl `yaml:"access_controls"`
} }
func (r *Repo) loadConfig(fs fs.FS) (Config, error) { func (r *Repo) loadConfig(fs fs.FS) (Config, error) {

53
repo.go
View File

@ -12,6 +12,7 @@ import (
"gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/object" "gopkg.in/src-d/go-git.v4/plumbing/object"
"gopkg.in/src-d/go-git.v4/storage"
"gopkg.in/src-d/go-git.v4/storage/memory" "gopkg.in/src-d/go-git.v4/storage/memory"
) )
@ -25,8 +26,11 @@ var (
// ConfigPath defines the expected path to the Repo's configuration file. // ConfigPath defines the expected path to the Repo's configuration file.
ConfigPath = filepath.Join(DehubDir, "config.yml") ConfigPath = filepath.Join(DehubDir, "config.yml")
// Trunk defines the reference name of the trunk branch. // Trunk defines the name of the trunk branch.
Trunk = plumbing.ReferenceName("refs/heads/trunk") Trunk = "trunk"
// TrunkRefName defines the reference name of the trunk branch.
TrunkRefName = plumbing.NewBranchReferenceName(Trunk)
) )
type repoOpts struct { type repoOpts struct {
@ -48,6 +52,7 @@ func OpenBare(bare bool) OpenOption {
// Repo is an object which allows accessing and modifying the dehub repo. // Repo is an object which allows accessing and modifying the dehub repo.
type Repo struct { type Repo struct {
GitRepo *git.Repository GitRepo *git.Repository
Storer storage.Storer
} }
// OpenRepo opens the dehub repo in the given directory and returns the object // OpenRepo opens the dehub repo in the given directory and returns the object
@ -69,6 +74,7 @@ func OpenRepo(path string, options ...OpenOption) (*Repo, error) {
if r.GitRepo, err = git.PlainOpenWithOptions(path, openOpts); err != nil { if r.GitRepo, err = git.PlainOpenWithOptions(path, openOpts); err != nil {
return nil, fmt.Errorf("could not open git repo: %w", err) return nil, fmt.Errorf("could not open git repo: %w", err)
} }
return &r, nil return &r, nil
} }
@ -79,7 +85,19 @@ func InitMemRepo() *Repo {
panic(err) panic(err)
} }
return &Repo{GitRepo: r} repo := &Repo{GitRepo: r}
if err := repo.init(); err != nil {
panic(err)
}
return repo
}
func (r *Repo) init() error {
h := plumbing.NewSymbolicReference(plumbing.HEAD, TrunkRefName)
if err := r.GitRepo.Storer.SetReference(h); err != nil {
return fmt.Errorf("could not set HEAD to %q: %w", TrunkRefName, err)
}
return nil
} }
func (r *Repo) billyFilesystem() (billy.Filesystem, error) { func (r *Repo) billyFilesystem() (billy.Filesystem, error) {
@ -90,6 +108,35 @@ func (r *Repo) billyFilesystem() (billy.Filesystem, error) {
return w.Filesystem, nil return w.Filesystem, nil
} }
// CheckedOutBranch returns the name of the currently checked out branch.
func (r *Repo) CheckedOutBranch() (plumbing.ReferenceName, error) {
// Head() can't be used for this, because it doesn't handle the case of a
// newly initialized repo very well.
ogRef, err := r.GitRepo.Storer.Reference(plumbing.HEAD)
if err != nil {
return "", fmt.Errorf("couldn't de-reference HEAD (is it a bare repo?): %w", err)
}
ref := ogRef
for {
if ref.Type() != plumbing.SymbolicReference {
break
}
target := ref.Target()
if target.IsBranch() {
return target, nil
}
ref, err = r.GitRepo.Storer.Reference(target)
if err != nil {
break
}
}
return "", fmt.Errorf("could not de-reference HEAD to a branch: %w", err)
}
func (r *Repo) head() (*object.Commit, *object.Tree, error) { func (r *Repo) head() (*object.Commit, *object.Tree, error) {
head, err := r.GitRepo.Head() head, err := r.GitRepo.Head()
if err != nil { if err != nil {

View File

@ -10,6 +10,7 @@ import (
"runtime/debug" "runtime/debug"
"testing" "testing"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
) )
@ -34,9 +35,12 @@ func newHarness(t *testing.T) *harness {
Path: pubKeyPath, Path: pubKeyPath,
}}}, }}},
}}, }},
AccessControls: []accessctl.AccessControl{ AccessControls: []accessctl.BranchAccessControl{
{ {
Pattern: "**", BranchPattern: "**",
ChangeAccessControls: []accessctl.ChangeAccessControl{
{
FilePathPattern: "**",
Condition: accessctl.Condition{ Condition: accessctl.Condition{
Signature: &accessctl.ConditionSignature{ Signature: &accessctl.ConditionSignature{
AccountIDs: []string{"root"}, AccountIDs: []string{"root"},
@ -45,6 +49,8 @@ func newHarness(t *testing.T) *harness {
}, },
}, },
}, },
},
},
} }
cfgBody, err := yaml.Marshal(cfg) cfgBody, err := yaml.Marshal(cfg)
if err != nil { if err != nil {
@ -115,6 +121,21 @@ func (h *harness) changeCommit(msg, accountID string, sig sigcred.SignifierInter
return tc, hash return tc, hash
} }
func (h *harness) reset(to plumbing.Hash, mode git.ResetMode) {
w, err := h.repo.GitRepo.Worktree()
if err != nil {
h.t.Fatal(err)
}
err = w.Reset(&git.ResetOptions{
Commit: to,
Mode: mode,
})
if err != nil {
h.t.Fatal(err)
}
}
func TestHasStagedChanges(t *testing.T) { func TestHasStagedChanges(t *testing.T) {
harness := newHarness(t) harness := newHarness(t)
assertHasStaged := func(expHasStaged bool) { assertHasStaged := func(expHasStaged bool) {
@ -141,3 +162,57 @@ func TestHasStagedChanges(t *testing.T) {
harness.changeCommit("second commit", "root", harness.sig) harness.changeCommit("second commit", "root", harness.sig)
assertHasStaged(false) assertHasStaged(false)
} }
// TestOldConfig tests that having an older, now malformed, Config doesn't mess
// with the current parsing, as long as the default access controls still work.
func TestOldConfig(t *testing.T) {
harness := newHarness(t)
// overwrite the currently staged config file with an older form
harness.stage(map[string]string{ConfigPath: `
---
accounts:
- id: root
signifiers:
- type: pgp_public_key_file
path: ".dehub/root.asc"
access_controls:
- pattern: "**"
condition:
type: signature
account_ids:
- root
count: 0
`})
_, hash0 := harness.changeCommit("first commit, this is going great", "root", harness.sig)
// even though that access_controls doesn't actually require any signatures,
// it should be used because it's not well formed.
harness.stage(map[string]string{"foo": "no rules!"})
_, hash1 := harness.changeCommit("ain't no laws", "toot", nil)
// verifying the first should work, but not the second.
if err := harness.repo.VerifyChangeCommit(TrunkRefName, hash0); err != nil {
t.Fatalf("first commit %q should be verifiable, but got: %v", hash0, err)
} else if err := harness.repo.VerifyChangeCommit(TrunkRefName, hash1); err == nil {
t.Fatalf("second commit %q should not have been verified", hash1)
}
// reset back to hash0
harness.reset(hash0, git.HardReset)
// make a commit fixing the config. everything should still be fine.
harness.stage(map[string]string{ConfigPath: `
---
accounts:
- id: root
signifiers:
- type: pgp_public_key_file
path: ".dehub/root.asc"
`})
_, hash2 := harness.changeCommit("Fix the config!", "root", harness.sig)
if err := harness.repo.VerifyChangeCommit(TrunkRefName, hash2); err != nil {
t.Fatalf("config fix commit %q should be verifiable, but got: %v", hash2, err)
}
}