diff --git a/acl.go b/acl.go new file mode 100644 index 0000000..3607d9c --- /dev/null +++ b/acl.go @@ -0,0 +1,131 @@ +package main + +import ( + "fmt" + "path" + "strings" +) + +type Login struct { + user string + groups []string +} + +type ACL []ACLEntry + +type ACLEntry struct { + // The authenticated user (or ANONYMOUS if not authenticated) must match this string + user string + // For each of this groups, the authenticated user must belong to one group that matches + reqGroups []string + // The action requested must match one of these strings + actions []string + // The requested target must match this string. The special word SELF is replaced in the pattern by the user's dn before matching + target string + // All attributes requested must match one of these patterns + attributes []string + // All attributes requested must not match any of these patterns + exclAttributes []string +} + +func splitNoEmpty(s string) []string { + if len(s) == 0 { + return []string{} + } + return strings.Split(s, " ") +} + +func ParseACL(def []string) (ACL, error) { + acl := []ACLEntry{} + for _, item := range def { + parts := strings.Split(item, ":") + if len(parts) != 5 { + return nil, fmt.Errorf("Invalid ACL entry: %s", item) + } + attr, exclAttr := []string{}, []string{} + for _, s := range splitNoEmpty(parts[4]) { + if s[0] == '!' { + exclAttr = append(exclAttr, s) + } else { + attr = append(attr, s) + } + } + item_def := ACLEntry{ + user: parts[0], + reqGroups: splitNoEmpty(parts[1]), + actions: splitNoEmpty(parts[2]), + target: parts[3], + attributes: attr, + exclAttributes: exclAttr, + } + acl = append(acl, item_def) + } + return acl, nil +} + +func (acl ACL) Check(login *Login, action string, target string, attributes []string) bool { + for _, item := range acl { + if item.Check(login, action, target, attributes) { + return true + } + } + return false +} + +func (entry *ACLEntry) Check(login *Login, action string, target string, attributes []string) bool { + if !match(entry.user, login.user) { + return false + } + + for _, grp := range entry.reqGroups { + if !matchAny(grp, login.groups) { + return false + } + } + + rule_target_with_self := strings.ReplaceAll(entry.target, "SELF", login.user) + if !match(rule_target_with_self, target) { + return false + } + + if !anyMatch(entry.actions, action) { + return false + } + + for _, attrib := range attributes { + if !anyMatch(entry.attributes, attrib) { + return false + } + } + + for _, exclAttr := range entry.exclAttributes { + if matchAny(exclAttr, attributes) { + return false + } + } + + return true +} + +func match(pattern string, val string) bool { + rv, err := path.Match(strings.ToLower(pattern), strings.ToLower(val)) + return err == nil && rv +} + +func matchAny(pattern string, vals []string) bool { + for _, val := range vals { + if match(pattern, val) { + return true + } + } + return false +} + +func anyMatch(patterns []string, val string) bool { + for _, pattern := range patterns { + if match(pattern, val) { + return true + } + } + return false +} diff --git a/main.go b/main.go index 4d1f418..7876915 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main // @FIXME: Implement a real permission system: limit read/write scope/attributes, possibly based on group membership // @FIXME: Implement missing search filters (in applyFilter) // @FIXME: Add an initial prefix to the consul key value +// @FIXME: Add TLS connections import ( "encoding/base64" @@ -20,100 +21,9 @@ import ( message "github.com/vjeantet/goldap/message" ) -func dnToConsul(dn string) (string, error) { - if strings.Contains(dn, "/") { - return "", fmt.Errorf("DN %s contains a /", dn) - } - - rdns := strings.Split(dn, ",") - - // Reverse rdns - for i, j := 0, len(rdns)-1; i < j; i, j = i+1, j-1 { - rdns[i], rdns[j] = rdns[j], rdns[i] - } - - return strings.Join(rdns, "/"), nil -} - -func consulToDN(key string) (string, string) { - path := strings.Split(key, "/") - dn := "" - for _, cpath := range path { - if cpath == "" { - continue - } - kv := strings.Split(cpath, "=") - if len(kv) == 2 && kv[0] == "attribute" { - return dn, kv[1] - } - if dn != "" { - dn = "," + dn - } - dn = cpath + dn - } - panic("Consul key " + key + " does not end with attribute=something") -} - -func parseValue(value []byte) ([]string, error) { - val := []string{} - err := json.Unmarshal(value, &val) - if err == nil { - return val, nil - } - - val2 := "" - err = json.Unmarshal(value, &val2) - if err == nil { - return []string{val2}, nil - } - - return nil, fmt.Errorf("Not a string or list of strings: %s", value) -} - -func parseConsulResult(data []*consul.KVPair) (map[string]Entry, error) { - aggregator := map[string]Entry{} - - for _, kv := range data { - log.Printf("(parseConsulResult) %s %s", kv.Key, string(kv.Value)) - dn, attr := consulToDN(kv.Key) - if _, exists := aggregator[dn]; !exists { - aggregator[dn] = Entry{} - } - value, err := parseValue(kv.Value) - if err != nil { - return nil, err - } - aggregator[dn][attr] = value - } - - return aggregator, nil -} - -type DNComponent struct { - Type string - Value string -} - -func parseDN(dn string) ([]DNComponent, error) { - rdns := strings.Split(dn, ",") - - ret := []DNComponent{} - - for _, rdn := range rdns { - splits := strings.Split(rdn, "=") - if len(splits) != 2 { - return nil, fmt.Errorf("Wrong DN component: %s (expected type=value)", rdn) - } - ret = append(ret, DNComponent{ - Type: splits[0], - Value: splits[1], - }) - } - return ret, nil -} - type Config struct { Suffix string + Acl ACL } type Server struct { @@ -122,7 +32,7 @@ type Server struct { } type State struct { - bindDn string + login Login } type Entry map[string][]string @@ -138,9 +48,29 @@ func main() { } kv := client.KV() + aclStr := []string{ + // Anybody (before binding) can bind to an entity under ou=users,dc=gobottin,dc=eu + "ANONYMOUS::bind:*,ou=users,dc=gobottin,dc=eu:", + // Anybody (before binding) can bind to the specific admin entity + "ANONYMOUS::bind:cn=admin,dc=gobottin,dc=eu:", + // Anybody who is logged in can read anything that is not a userpassword attribute + "*,dc=gobottin,dc=eu::read:*:* !userpassword", + // Anybody can read and modify anything from their own entry + "*::read modify:SELF:*", + // The admin can add, modify, delete anything + "cn=admin,dc=gobottin,dc=eu::add modify delete:*:*", + // Members of the admin group can add, modify, delete anything + "*:cn=admin,ou=groups,dc=gobottin,dc=eu:add modify delete:*:*", + } + acl, err := ParseACL(aclStr) + if err != nil { + panic(err) + } + // TODO read config from somewhere config := Config{ Suffix: "dc=gobottin,dc=eu", + Acl: acl, } gobottin := Server{config: config, kv: kv} @@ -152,7 +82,12 @@ func main() { //Create a new LDAP Server ldapserver := ldap.NewServer() ldapserver.NewUserState = func() ldap.UserState { - return &State{} + return &State{ + login: Login{ + user: "ANONYMOUS", + groups: []string{}, + }, + } } routes := ldap.NewRouteMux() @@ -328,11 +263,16 @@ func (server *Server) handleBind(s ldap.UserState, w ldap.ResponseWriter, m *lda } func (server *Server) handleBindInternal(state *State, r *message.BindRequest) (int, error) { + // Check permissions + if !server.config.Acl.Check(&state.login, "bind", string(r.Name()), []string{}) { + return ldap.LDAPResultInsufficientAccessRights, nil + } + + // Try to retrieve password and check for match passwd, err := server.getAttribute(string(r.Name()), "userpassword") if err != nil { return ldap.LDAPResultOperationsError, err } - if passwd == nil { return ldap.LDAPResultNoSuchObject, nil } @@ -340,7 +280,14 @@ func (server *Server) handleBindInternal(state *State, r *message.BindRequest) ( for _, hash := range passwd { valid := SSHAMatches(hash, []byte(r.AuthenticationSimple())) if valid { - state.bindDn = string(r.Name()) + groups, err := server.getAttribute(string(r.Name()), "memberOf") + if err != nil { + return ldap.LDAPResultOperationsError, err + } + state.login = Login{ + user: string(r.Name()), + groups: groups, + } return ldap.LDAPResultSuccess, nil } } @@ -368,7 +315,9 @@ func (server *Server) handleSearchInternal(state *State, w ldap.ResponseWriter, log.Printf("Request Attributes=%s", r.Attributes()) log.Printf("Request TimeLimit=%d", r.TimeLimit().Int()) - // TODO check authorizations + if !server.config.Acl.Check(&state.login, "read", string(r.BaseObject()), []string{}) { + return ldap.LDAPResultInsufficientAccessRights, fmt.Errorf("Please specify a base object on which you have read rights") + } baseObject, err := server.checkSuffix(string(r.BaseObject()), true) if err != nil { @@ -392,7 +341,7 @@ func (server *Server) handleSearchInternal(state *State, w ldap.ResponseWriter, log.Printf("%#v", entries) for dn, entry := range entries { - // TODO filter out if no permission to read this + // Filter out if we don't match requested filter matched, err := applyFilter(entry, r.Filter()) if err != nil { return ldap.LDAPResultUnwillingToPerform, err @@ -401,6 +350,11 @@ func (server *Server) handleSearchInternal(state *State, w ldap.ResponseWriter, continue } + // Filter out if user is not allowed to read this + if !server.config.Acl.Check(&state.login, "read", dn, []string{}) { + continue + } + e := ldap.NewSearchResultEntry(dn) for attr, val := range entry { // If attribute is not in request, exclude it from returned entry @@ -416,6 +370,10 @@ func (server *Server) handleSearchInternal(state *State, w ldap.ResponseWriter, continue } } + // If we are not allowed to read attribute, exclude it from returned entry + if !server.config.Acl.Check(&state.login, "read", dn, []string{attr}) { + continue + } // Send result for _, v := range val { e.AddAttribute(message.AttributeDescription(attr), @@ -507,6 +465,16 @@ func (server *Server) handleAddInternal(state *State, r *message.AddRequest) (in return ldap.LDAPResultInvalidDNSyntax, err } + // Check permissions + attrListStr := []string{} + for _, attribute := range r.Attributes() { + attrListStr = append(attrListStr, string(attribute.Type_())) + } + if !server.config.Acl.Check(&state.login, "add", dn, attrListStr) { + return ldap.LDAPResultInsufficientAccessRights, nil + } + + // Check that object does not already exist exists, err := server.objectExists(dn) if err != nil { return ldap.LDAPResultOperationsError, err @@ -515,8 +483,9 @@ func (server *Server) handleAddInternal(state *State, r *message.AddRequest) (in return ldap.LDAPResultEntryAlreadyExists, nil } - // TODO check permissions + // Add object + // If adding a group, track of who the members will be so that their memberOf field can be updated later var members []string = nil entry := Entry{} @@ -617,8 +586,12 @@ func (server *Server) handleCompareInternal(state *State, r *message.CompareRequ return ldap.LDAPResultInvalidDNSyntax, err } - // TODO check user for permissions to read dn + // Check permissions + if !server.config.Acl.Check(&state.login, dn, "read", []string{attr}) { + return ldap.LDAPResultInsufficientAccessRights, nil + } + // Do query exists, err := server.objectExists(dn) if err != nil { return ldap.LDAPResultOperationsError, err @@ -662,7 +635,10 @@ func (server *Server) handleDeleteInternal(state *State, r *message.DelRequest) return ldap.LDAPResultInvalidDNSyntax, err } - // TODO check user for permissions to write dn + // Check for delete permission + if !server.config.Acl.Check(&state.login, "delete", dn, []string{}) { + return ldap.LDAPResultInsufficientAccessRights, nil + } // Check that this LDAP entry exists and has no children path, err := dnToConsul(dn) @@ -746,7 +722,10 @@ func (server *Server) handleModifyInternal(state *State, r *message.ModifyReques return ldap.LDAPResultInvalidDNSyntax, err } - // TODO check user for permissions to write dn + // First permission check with no particular attributes + if !server.config.Acl.Check(&state.login, "modify", dn, []string{}) { + return ldap.LDAPResultInsufficientAccessRights, nil + } // Retrieve previous values (by the way, check object exists) path, err := dnToConsul(dn) @@ -785,6 +764,11 @@ func (server *Server) handleModifyInternal(state *State, r *message.ModifyReques attr := string(change.Modification().Type_()) values := change.Modification().Vals() + // Check for permission to modify this attribute + if !server.config.Acl.Check(&state.login, "modify", dn, []string{attr}) { + return ldap.LDAPResultInsufficientAccessRights, nil + } + if change.Operation() == ldap.ModifyRequestChangeOperationAdd { newEntry[attr] = prevEntry[attr] for _, val := range values { diff --git a/util.go b/util.go new file mode 100644 index 0000000..30f9681 --- /dev/null +++ b/util.go @@ -0,0 +1,103 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "strings" + + consul "github.com/hashicorp/consul/api" +) + +func dnToConsul(dn string) (string, error) { + if strings.Contains(dn, "/") { + return "", fmt.Errorf("DN %s contains a /", dn) + } + + rdns := strings.Split(dn, ",") + + // Reverse rdns + for i, j := 0, len(rdns)-1; i < j; i, j = i+1, j-1 { + rdns[i], rdns[j] = rdns[j], rdns[i] + } + + return strings.Join(rdns, "/"), nil +} + +func consulToDN(key string) (string, string) { + path := strings.Split(key, "/") + dn := "" + for _, cpath := range path { + if cpath == "" { + continue + } + kv := strings.Split(cpath, "=") + if len(kv) == 2 && kv[0] == "attribute" { + return dn, kv[1] + } + if dn != "" { + dn = "," + dn + } + dn = cpath + dn + } + log.Printf("Consul key %s does not end with attribute=something", key) + panic("TODO don't panic handle this") +} + +func parseValue(value []byte) ([]string, error) { + val := []string{} + err := json.Unmarshal(value, &val) + if err == nil { + return val, nil + } + + val2 := "" + err = json.Unmarshal(value, &val2) + if err == nil { + return []string{val2}, nil + } + + return nil, fmt.Errorf("Not a string or list of strings: %s", value) +} + +func parseConsulResult(data []*consul.KVPair) (map[string]Entry, error) { + aggregator := map[string]Entry{} + + for _, kv := range data { + log.Printf("(parseConsulResult) %s %s", kv.Key, string(kv.Value)) + dn, attr := consulToDN(kv.Key) + if _, exists := aggregator[dn]; !exists { + aggregator[dn] = Entry{} + } + value, err := parseValue(kv.Value) + if err != nil { + return nil, err + } + aggregator[dn][attr] = value + } + + return aggregator, nil +} + +type DNComponent struct { + Type string + Value string +} + +func parseDN(dn string) ([]DNComponent, error) { + rdns := strings.Split(dn, ",") + + ret := []DNComponent{} + + for _, rdn := range rdns { + splits := strings.Split(rdn, "=") + if len(splits) != 2 { + return nil, fmt.Errorf("Wrong DN component: %s (expected type=value)", rdn) + } + ret = append(ret, DNComponent{ + Type: splits[0], + Value: splits[1], + }) + } + return ret, nil +}