grim/hgkeeper

access: Restruct Access internal data struct.
access-control
2019-05-04, Wagner Riffel
f3433d4af7d8
access: Restruct Access internal data struct.
Now data struct is represented with two maps, one for users lookup throught key fingerprint, one for patterns and its permissions indexed by user name.
Globals were dropped from the struct, since it's only needed while parsing, no point to keep it there.
Groups were dropped and has no special meaning for authentication struct anymore, it's just a sugar to slice of users.
package access
import (
"bufio"
"bytes"
"fmt"
"io"
"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
reposDir 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)
}
fmt.Printf("%#v\n", a.keys)
fmt.Printf("%#v\n", a.users)
fmt.Printf("%#v\n", globals)
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] = make(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 {
ks, err := loadKeys(user)
if err != nil {
return fmt.Errorf("addToKeys: %v", err)
}
for _, key := range ks {
a.keys[key] = 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 string) (*Access, error) {
reposDir = reposPath
accessFile = filepath.Join(reposPath, "hgkeeper", AccessFile)
keysDir = filepath.Join(reposPath, "hgkeeper", KeysDir)
a := new(Access)
a.users = make(map[string]map[string]Perm)
a.keys = make(map[string]string)
return a, a.Reset()
}
// loadKeys reads file trying to parse it as an authorized key
// format, returning the finger prints of all keys found
func loadKeys(file string) ([]string, error) {
f, err := os.Open(filepath.Join(keysDir, file))
if err != nil {
return nil, fmt.Errorf("loadKeys %q: %v", file, err)
}
defer f.Close()
keys := make([]string, 0, 20)
// according to sshd(8) each line of the file contains one key,
// ignoring empty and lines starting with #
bio := bufio.NewScanner(f)
for line := 1; bio.Scan(); line++ {
key := bio.Text()
if key == "" || key[0] == '#' {
continue
}
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
if err != nil {
log.Errorf("loadKeys %q:%d: %v", file, line, err)
continue
}
keys = append(keys, ssh.FingerprintSHA256(pub))
}
return keys, nil
}
// 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)
}