76309b51cb
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
262 lines
7.5 KiB
Go
262 lines
7.5 KiB
Go
package dehub
|
|
|
|
import (
|
|
"bytes"
|
|
"dehub/accessctl"
|
|
"dehub/fs"
|
|
"dehub/sigcred"
|
|
"dehub/yamlutil"
|
|
"encoding"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/src-d/go-git.v4"
|
|
"gopkg.in/src-d/go-git.v4/plumbing"
|
|
"gopkg.in/src-d/go-git.v4/plumbing/object"
|
|
yaml "gopkg.in/yaml.v2"
|
|
)
|
|
|
|
// ChangeCommit describes the structure of a change commit message.
|
|
type ChangeCommit struct {
|
|
Message string `yaml:"message"`
|
|
ChangeHash yamlutil.Blob `yaml:"change_hash"`
|
|
Credentials []sigcred.Credential `yaml:"credentials"`
|
|
}
|
|
|
|
type ccYAML struct {
|
|
Val ChangeCommit `yaml:",inline"`
|
|
}
|
|
|
|
func msgHead(msg string) string {
|
|
i := strings.Index(msg, "\n")
|
|
if i > 0 {
|
|
return msg[:i]
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// MarshalText implements the encoding.TextMarshaler interface by returning the
|
|
// form the ChangeCommit object takes in the git commit message.
|
|
func (cc ChangeCommit) MarshalText() ([]byte, error) {
|
|
changeCommitEncoded, err := yaml.Marshal(ccYAML{cc})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode ChangeCommit message: %w", err)
|
|
}
|
|
|
|
fullMsg := msgHead(cc.Message) + "\n\n" + string(changeCommitEncoded)
|
|
return []byte(fullMsg), nil
|
|
}
|
|
|
|
// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a
|
|
// ChangeCommit object which has been encoded into a git commit message.
|
|
func (cc *ChangeCommit) UnmarshalText(msg []byte) error {
|
|
i := bytes.Index(msg, []byte("\n"))
|
|
if i < 0 {
|
|
return fmt.Errorf("commit message %q is malformed", msg)
|
|
}
|
|
msgHead, msg := msg[:i], msg[i:]
|
|
|
|
var ccy ccYAML
|
|
if err := yaml.Unmarshal(msg, &ccy); err != nil {
|
|
return fmt.Errorf("could not unmarshal ChangeCommit message: %w", err)
|
|
}
|
|
|
|
*cc = ccy.Val
|
|
if !strings.HasPrefix(cc.Message, string(msgHead)) {
|
|
return errors.New("encoded ChangeCommit is malformed, it might not be an encoded ChangeCommit")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Commit uses the given TextMarshaler to create a git commit object (with the
|
|
// specified accountID as the author) and commits it to the current HEAD,
|
|
// returning the hash of the commit.
|
|
func (r *Repo) Commit(m encoding.TextMarshaler, accountID string) (plumbing.Hash, error) {
|
|
msgB, err := m.MarshalText()
|
|
if err != nil {
|
|
return plumbing.ZeroHash, fmt.Errorf("error marshaling %T to string: %v", m, err)
|
|
}
|
|
|
|
w, err := r.GitRepo.Worktree()
|
|
if err != nil {
|
|
return plumbing.ZeroHash, fmt.Errorf("could not get git worktree: %w", err)
|
|
}
|
|
return w.Commit(string(msgB), &git.CommitOptions{
|
|
Author: &object.Signature{
|
|
Name: accountID,
|
|
When: time.Now(),
|
|
},
|
|
})
|
|
}
|
|
|
|
// HasStagedChanges returns true if there are file changes which have been
|
|
// staged (e.g. via "git add").
|
|
func (r *Repo) HasStagedChanges() (bool, error) {
|
|
w, err := r.GitRepo.Worktree()
|
|
if err != nil {
|
|
return false, fmt.Errorf("error retrieving worktree: %w", err)
|
|
}
|
|
|
|
status, err := w.Status()
|
|
if err != nil {
|
|
return false, fmt.Errorf("error retrieving worktree status: %w", err)
|
|
}
|
|
|
|
var any bool
|
|
for _, fileStatus := range status {
|
|
if fileStatus.Staging != git.Unmodified {
|
|
any = true
|
|
break
|
|
}
|
|
}
|
|
return any, nil
|
|
}
|
|
|
|
// NewChangeCommit constructs a ChangeCommit. If sig is given then it is used to
|
|
// create a Credential for the ChangeCommit.
|
|
func (r *Repo) NewChangeCommit(msg, accountID string, sig sigcred.SignifierInterface) (ChangeCommit, error) {
|
|
_, headTree, err := r.head()
|
|
if errors.Is(err, plumbing.ErrReferenceNotFound) {
|
|
headTree = &object.Tree{}
|
|
} else if err != nil {
|
|
return ChangeCommit{}, err
|
|
}
|
|
|
|
_, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo)
|
|
if err != nil {
|
|
return ChangeCommit{}, err
|
|
}
|
|
|
|
changeHash := genChangeHash(nil, msg, headTree, stagedTree)
|
|
|
|
var creds []sigcred.Credential
|
|
if sig != nil {
|
|
// 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()
|
|
if err != nil {
|
|
return ChangeCommit{}, err
|
|
}
|
|
|
|
cred, err := sig.Sign(sigFS, changeHash)
|
|
if err != nil {
|
|
return ChangeCommit{}, fmt.Errorf("failed to sign commit hash: %w", err)
|
|
}
|
|
cred.AccountID = accountID
|
|
creds = append(creds, cred)
|
|
}
|
|
|
|
return ChangeCommit{
|
|
Message: msg,
|
|
ChangeHash: changeHash,
|
|
Credentials: creds,
|
|
}, nil
|
|
}
|
|
|
|
func (r *Repo) assertAccessControls(
|
|
accessCtls []accessctl.BranchAccessControl, creds []sigcred.Credential,
|
|
branch plumbing.ReferenceName, from, to *object.Tree,
|
|
) error {
|
|
filesChanged, err := calcDiff(from, to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pathsChanged := make([]string, len(filesChanged))
|
|
for i := range filesChanged {
|
|
pathsChanged[i] = filesChanged[i].path
|
|
}
|
|
|
|
matchRes, err := accessctl.Match(accessCtls, accessctl.MatchInteractions{
|
|
Branch: branch.Short(),
|
|
FilePathsChanged: pathsChanged,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("could not determine applicable access controls: %w", err)
|
|
}
|
|
|
|
for _, matchedAC := range matchRes.ChangeAccessControls {
|
|
ac := matchedAC.ChangeAccessControl
|
|
condInt, err := ac.Condition.Interface()
|
|
if err != nil {
|
|
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 {
|
|
return fmt.Errorf("access control of file path pattern %q not satisfied: %w\nFiles matched:\n%s",
|
|
ac.FilePathPattern, err, strings.Join(matchedAC.FilePaths, "\n"))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifyChangeCommit verifies that the change commit at the given hash, which
|
|
// is presumably on the given branch, is gucci.
|
|
func (r *Repo) VerifyChangeCommit(branch plumbing.ReferenceName, h plumbing.Hash) error {
|
|
commit, err := r.GitRepo.CommitObject(h)
|
|
if err != nil {
|
|
return fmt.Errorf("could not retrieve commit object: %w", err)
|
|
}
|
|
|
|
commitTree, err := r.GitRepo.TreeObject(commit.TreeHash)
|
|
if err != nil {
|
|
return fmt.Errorf("could not retrieve tree object: %w", err)
|
|
}
|
|
|
|
var changeCommit ChangeCommit
|
|
if err := changeCommit.UnmarshalText([]byte(commit.Message)); err != nil {
|
|
return err
|
|
}
|
|
|
|
sigTree := commitTree // only for root commit
|
|
parentTree := &object.Tree{}
|
|
if commit.NumParents() > 0 {
|
|
parent, err := commit.Parent(0)
|
|
if err != nil {
|
|
return fmt.Errorf("could not retrieve parent of commit: %w", err)
|
|
} else if parentTree, err = r.GitRepo.TreeObject(parent.TreeHash); err != nil {
|
|
return fmt.Errorf("could not retrieve tree object of parent %q: %w", parent.Hash, err)
|
|
}
|
|
sigTree = parentTree
|
|
}
|
|
sigFS := fs.FromTree(sigTree)
|
|
|
|
cfg, err := r.loadConfig(sigFS)
|
|
if err != nil {
|
|
return fmt.Errorf("error loading config: %w", err)
|
|
}
|
|
|
|
err = r.assertAccessControls(
|
|
cfg.AccessControls, changeCommit.Credentials,
|
|
branch, parentTree, commitTree,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to satisfy all access controls: %w", err)
|
|
}
|
|
|
|
expectedChangeHash := genChangeHash(nil, changeCommit.Message, parentTree, commitTree)
|
|
if !bytes.Equal(changeCommit.ChangeHash, expectedChangeHash) {
|
|
return fmt.Errorf("malformed change_hash in commit body, is %s but should be %s",
|
|
base64.StdEncoding.EncodeToString(expectedChangeHash),
|
|
base64.StdEncoding.EncodeToString(changeCommit.ChangeHash))
|
|
}
|
|
|
|
for _, cred := range changeCommit.Credentials {
|
|
sig, err := r.signifierForCredential(sigFS, cred)
|
|
if err != nil {
|
|
return fmt.Errorf("error finding signifier for credential %+v: %w", cred, err)
|
|
} else if err := sig.Verify(sigFS, expectedChangeHash, cred); err != nil {
|
|
return fmt.Errorf("error verifying credential %+v: %w", cred, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|