grim/hgkeeper

ed523f1c967e
Add an admin repos command line argument
package access
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"sync"
"github.com/go-yaml/yaml"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
)
const (
// AccessFile is the base name of an access control file.
AccessFile = "access.yml"
// KeysDir is the base name of directory holding public keys
KeysDir = "keys"
)
const (
// Public is a reserved hgkeeper name and is not valid in the patterns
// or keys of an access file.
Public = "public"
)
var publicBytes = []byte(Public)
// set once at Init, after that it may stay read-only
var (
accessFile string
keysDir string
)
type (
groups map[string][]string
permissions [numPerms][]string
)
// Access represents a parsed access file.
type Access struct {
// keysMu controls access to the patterns map
keysMu sync.RWMutex
// keys holds all users indexed by key fingerprint
keys map[string]string
// usersMu controls access to the patterns map
usersMu sync.RWMutex
// users holds its patterns permissions indexed
// by user name
users map[string]map[string]Perm
}
// parse calls inderectly UnmarshalYAML, it's caller's duty to control the access to a.
func (a *Access) parse(r io.Reader) error {
return yaml.NewDecoder(r).Decode(a)
}
// UnmarshalYAML unmarshals access yaml into an internal access
// representation.
// It's caller's duty to control the access to a.
func (a *Access) UnmarshalYAML(f func(interface{}) error) error {
type acl struct {
Init []string `yaml:"init"`
Read []string `yaml:"read"`
Write []string `yaml:"write"`
}
type file struct {
Global acl `yaml:"global"`
Groups groups `yaml:"groups"`
Patterns map[string]acl `yaml:"patterns"`
}
var dummy file
err := f(&dummy)
if err != nil {
return err
}
users := permissions{dummy.Global.Init, dummy.Global.Read, dummy.Global.Write}
globals := parseGlobals(dummy.Groups, users)
for pattern, v := range dummy.Patterns {
// validate the pattern
_, err := filepath.Match(pattern, "")
if err != nil {
log.Errorf("malformed pattern %q: %v", pattern, err)
continue
}
perms := permissions{v.Init, v.Read, v.Write}
a.addPattern(pattern, perms, globals, dummy.Groups)
}
return nil
}
func (a *Access) addToUsers(pattern string, p Perm, users ...string) {
for _, user := range users {
if _, found := a.users[user]; found {
perm := a.users[user][pattern]
perm.set(p)
a.users[user][pattern] = perm
continue
}
// if user not seen, parse key and append to
// keys map
if err := a.addToKeys(user); err != nil {
log.Errorf("addToUsers: %v", err)
continue
}
var perm Perm
perm.set(p)
a.users[user] = map[string]Perm{}
a.users[user][pattern] = perm
}
}
func (a *Access) addPattern(pattern string, perms, globals permissions, g groups) {
for p, yamlPerm := range perms {
// casting p is not required as of current Go tip (1.13)
// see: http://golang.org/issues/19113
perm := Perm(1 << uint32(p))
if len(yamlPerm) == 0 {
// use global fallback
a.addToUsers(pattern, perm, globals[p]...)
continue
}
for _, userOrGroup := range yamlPerm {
if isPublic([]byte(userOrGroup)) {
continue
}
if _, ok := g[userOrGroup]; ok {
a.addToUsers(pattern, perm, g[userOrGroup]...)
continue
}
a.addToUsers(pattern, perm, userOrGroup)
}
}
}
func (a *Access) addToKeys(user string) error {
keyfile := filepath.Join(keysDir, user)
f, err := ioutil.ReadFile(keyfile)
if err != nil {
return fmt.Errorf("failed to read key file %q: %v", keyfile, err)
}
// if the file is empty, we have nothing to do
if len(f) == 0 {
return fmt.Errorf("key file %q is empty", keyfile)
}
nKeys := 0
// loop while we have data in f. f is updated by ssh.ParseAuthorizedKeys
// while reading keys.
for len(f) > 0 {
var pub ssh.PublicKey
pub, _, _, f, err = ssh.ParseAuthorizedKey(f)
if err != nil {
log.Errorf("failed parsing key in %q: %v", keyfile, err)
continue
}
fp := ssh.FingerprintSHA256(pub)
log.Debugf("found key for %q: %s", user, fp)
a.keys[fp] = user
nKeys++
}
log.Infof("found %d keys for %s", nKeys, user)
return nil
}
func parseGlobals(g groups, users permissions) (globals permissions) {
for p, userOrGroup := range users {
// casting p is not required as of current Go tip (1.13)
// see: http://golang.org/issues/19113
var perm Perm
perm.set(Perm(1 << uint32(p)))
for _, name := range userOrGroup {
if isPublic([]byte(name)) {
continue
}
if _, found := g[name]; found {
globals[p] = append(globals[p], g[name]...)
continue
}
globals[p] = append(globals[p], name)
}
}
return globals
}
// Can reports whenever if key (fingerprint) is has access p to path
func (a *Access) Can(key, path string, p Perm) bool {
a.keysMu.RLock()
defer a.keysMu.RUnlock()
user, found := a.keys[key]
if !found {
return false
}
a.usersMu.RLock()
defer a.usersMu.RUnlock()
patterns := a.users[user]
for pattern, perm := range patterns {
// ignoring error because pattern was validated before
// its insertion in map
if ok, _ := filepath.Match(pattern, path); !ok {
continue
}
if perm.can(p) {
return true
}
}
return false
}
// Reset discards all access state and rebuild its state
// from disk
func (a *Access) Reset() error {
a.usersMu.Lock()
a.keysMu.Lock()
defer a.keysMu.Unlock()
defer a.usersMu.Unlock()
for i := range a.users {
delete(a.users, i)
}
for i := range a.keys {
delete(a.keys, i)
}
f, err := os.Open(accessFile)
if err != nil {
return err
}
defer f.Close()
return a.parse(f)
}
// New initializes access strucuture, if any error is
// returned it will be I/O errors reading or parsing
// the access file.
func New(reposPath, adminRepo string) (*Access, error) {
accessFile = filepath.Join(reposPath, adminRepo, AccessFile)
keysDir = filepath.Join(reposPath, adminRepo, KeysDir)
log.Errorf("accessFile: %q", accessFile)
log.Errorf("keysDir: %q", keysDir)
a := &Access{
users: map[string]map[string]Perm{},
keys: map[string]string{},
}
return a, a.Reset()
}
// isPublic checks whenever u is or not the reserved
// hgkeeper public group
func isPublic(u []byte) bool {
// Check for length to be fast. Safe because "public" is ASCII.
return len(u) == len(publicBytes) && bytes.EqualFold(u, publicBytes)
}
// CheckPermission checks if we're supposed to allow the given ssh key. If the
// key is not found error is returned. If it is found, the username it belongs
// to is returned.
func (a *Access) CheckPermission(key ssh.PublicKey) (string, error) {
a.keysMu.RLock()
defer a.keysMu.RUnlock()
fp := ssh.FingerprintSHA256(key)
u, ok := a.keys[fp]
if !ok {
return "", fmt.Errorf("access: check permission: key %q permission denied", fp)
}
return u, nil
}
// GetPermissions will look up the given username and find the permissions that
// the user has on the given path.
func (a *Access) GetPermissions(username, path string) (read bool, write bool, init bool) {
a.usersMu.RLock()
defer a.usersMu.RUnlock()
patterns, ok := a.users[username]
if !ok {
return
}
for pattern, perm := range patterns {
// ignoring error because pattern was validated before
// its insertion in map
if ok, _ = filepath.Match(pattern, path); !ok {
continue
}
if perm.can(Read) {
read = true
}
if perm.can(Write) {
write = true
}
if perm.can(Init) {
init = true
}
return
}
return
}