Initial version
This is a Go program which can synchronize Graylog 4 roles and access privileges to dashboards and streams from a LDAP directory, based on a YAML configuration file that maps LDAP groups to Graylog privileges. The code is rather ugly, some features are half-baked (LDAP TLS support, impossible to disable HTTP TLS checks, bad error handling...) and some documentation needs to be added but it's a start.
This commit is contained in:
commit
91be691ea4
4 changed files with 467 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
go.sum
|
||||
graylog-groups.yml
|
||||
glgroups
|
10
go.mod
Normal file
10
go.mod
Normal file
|
@ -0,0 +1,10 @@
|
|||
module glgroups
|
||||
|
||||
go 1.15
|
||||
|
||||
require (
|
||||
github.com/bitfield/script v0.18.0
|
||||
github.com/go-ldap/ldap v3.0.3+incompatible
|
||||
gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect
|
||||
gopkg.in/yaml.v2 v2.2.2
|
||||
)
|
35
graylog-groups.yml.example
Normal file
35
graylog-groups.yml.example
Normal file
|
@ -0,0 +1,35 @@
|
|||
ldap:
|
||||
host: ldap.example.org
|
||||
port: 636
|
||||
tls: yes # or no / starttls
|
||||
cachain: /path/to/ca/chain.pem
|
||||
#bind_user:
|
||||
#bind_password:
|
||||
member_fields:
|
||||
- member
|
||||
- uniqueMember
|
||||
- memberUid
|
||||
graylog:
|
||||
api_base: https://graylog.example.org/api
|
||||
username: admin
|
||||
password: drowssap
|
||||
delete_accounts: false
|
||||
mapping:
|
||||
cn=g1,ou=groups,dc=example,dc=org:
|
||||
roles:
|
||||
- Reader
|
||||
privileges:
|
||||
- type: dashboard
|
||||
id: 12345
|
||||
level: read
|
||||
- type: stream
|
||||
id: 12345
|
||||
level: read
|
||||
cn=g2,ou=groups,dc=example,dc=org:
|
||||
roles:
|
||||
- Event Definition Creator
|
||||
- Event Notification Creator
|
||||
privileges:
|
||||
- type: dashboard
|
||||
id: 12345
|
||||
level: write
|
419
main.go
Normal file
419
main.go
Normal file
|
@ -0,0 +1,419 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/bitfield/script"
|
||||
"github.com/go-ldap/ldap"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type (
|
||||
/* *
|
||||
* CONFIGURATION DATA *
|
||||
* */
|
||||
|
||||
// LDAP server configuration
|
||||
LdapConfig struct {
|
||||
Host string
|
||||
Port uint16
|
||||
Tls string
|
||||
CaChain string
|
||||
BindUser string `yaml:"bind_user"`
|
||||
BindPassword string `yaml:"bind_password"`
|
||||
MemberFields []string `yaml:"member_fields"`
|
||||
}
|
||||
|
||||
// Graylog server configuration
|
||||
GraylogConfig struct {
|
||||
ApiBase string `yaml:"api_base"`
|
||||
Username string
|
||||
Password string
|
||||
DeleteAccounts bool `yaml:"delete_accounts"`
|
||||
}
|
||||
|
||||
// A Graylog object on which privileges are defined
|
||||
GraylogObject struct {
|
||||
Type string
|
||||
Id string
|
||||
Level string
|
||||
}
|
||||
|
||||
// A mapping from a LDAP group to a set of privileges
|
||||
GroupPrivileges struct {
|
||||
Roles []string
|
||||
Privileges []GraylogObject
|
||||
}
|
||||
|
||||
// All group mappings
|
||||
GroupMapping map[string]GroupPrivileges
|
||||
|
||||
// The whole configuration
|
||||
Configuration struct {
|
||||
Ldap LdapConfig
|
||||
Graylog GraylogConfig
|
||||
Mapping GroupMapping
|
||||
}
|
||||
|
||||
/* *
|
||||
* SERVER DATA *
|
||||
* */
|
||||
|
||||
// A Graylog user
|
||||
GraylogUser struct {
|
||||
Username string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// The response obtained when querying the Graylog server for a list of users.
|
||||
GlUsers struct {
|
||||
Users []struct {
|
||||
GraylogUser
|
||||
External bool
|
||||
}
|
||||
}
|
||||
|
||||
// LDAP group members
|
||||
GroupMembers map[string][]string
|
||||
)
|
||||
|
||||
var (
|
||||
// Privilege levels
|
||||
privLevels = map[string]int{
|
||||
"read": 0,
|
||||
"write": 1,
|
||||
}
|
||||
// Privilege level string representation
|
||||
privStr = []string{"read", "write"}
|
||||
// Graylog items on which privileges may be set
|
||||
graylogItems = map[string]bool{
|
||||
"dashboard": true,
|
||||
"stream": true,
|
||||
}
|
||||
// Grayog privilege string templates
|
||||
graylogPriv = map[string][]string{
|
||||
"dashboard:read": {"dashboards:read:%s", "view:read:%s"},
|
||||
"dashboard:write": {"dashboards:read:%s", "dashboards:edit:%s", "view:read:%s", "view:edit:%s"},
|
||||
"stream:read": {"streams:read:%s"},
|
||||
"stream:write": {"streams:read:%s", "streams:edit:%s", "streams:changestate:%s"},
|
||||
}
|
||||
)
|
||||
|
||||
// Load and check the configuration file
|
||||
func loadConfiguration() (configuration Configuration) {
|
||||
var cfgFile string
|
||||
if len(os.Args) < 2 {
|
||||
cfgFile = "graylog-groups.yml"
|
||||
} else {
|
||||
cfgFile = os.Args[1]
|
||||
}
|
||||
cfgData, err := script.File(cfgFile).Bytes()
|
||||
if err != nil {
|
||||
log.Fatalf("could not load configuration: %v", err)
|
||||
}
|
||||
|
||||
configuration = Configuration{
|
||||
Ldap: LdapConfig{
|
||||
Port: 389,
|
||||
Tls: "no",
|
||||
},
|
||||
}
|
||||
err = yaml.Unmarshal(cfgData, &configuration)
|
||||
if err != nil {
|
||||
log.Fatalf("could not parse configuration: %v", err)
|
||||
}
|
||||
|
||||
for _, info := range configuration.Mapping {
|
||||
for _, priv := range info.Privileges {
|
||||
if !graylogItems[priv.Type] {
|
||||
log.Fatalf("invalid Graylog item %s", priv.Type)
|
||||
}
|
||||
if _, ok := privLevels[priv.Level]; !ok {
|
||||
log.Fatalf("invalid privilege level %s", priv.Level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Execute a Graylog API request, returning the status code and the body
|
||||
func executeApiCall(cfg GraylogConfig, method string, path string, data io.Reader) (status int, body []byte) {
|
||||
client := &http.Client{}
|
||||
request, err := http.NewRequest(method, fmt.Sprintf("%s/%s", cfg.ApiBase, path), data)
|
||||
if err != nil {
|
||||
log.Fatalf("could not create HTTP request: %v", err)
|
||||
}
|
||||
request.SetBasicAuth(cfg.Username, cfg.Password)
|
||||
if data != nil {
|
||||
request.Header.Add("Content-Type", "application/json")
|
||||
}
|
||||
request.Header.Add("X-Requested-By", "graylog-groups")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
log.Fatalf("could not execute %s %s request on Graylog at %s: %v", method, path, cfg.ApiBase, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
status = response.StatusCode
|
||||
body, err = ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
log.Fatalf("could not read Graylog response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get the list of Graylog users that have been imported from LDAP
|
||||
func getGraylogUsers(configuration GraylogConfig) (users []GraylogUser) {
|
||||
status, body := executeApiCall(configuration, "GET", "users", nil)
|
||||
if status != 200 {
|
||||
log.Fatalf("could not read users: status code %v", status)
|
||||
}
|
||||
|
||||
data := GlUsers{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
log.Fatalf("could not parse Graylog's user response: %v", err)
|
||||
}
|
||||
|
||||
users = make([]GraylogUser, 0)
|
||||
for _, item := range data.Users {
|
||||
if item.External {
|
||||
users = append(users, item.GraylogUser)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Extract an username from something that may be an username or a DN.
|
||||
func usernameFromMember(member string) string {
|
||||
eqPos := strings.Index(member, "=")
|
||||
if eqPos == -1 {
|
||||
return member
|
||||
}
|
||||
commaPos := strings.Index(member, ",")
|
||||
if commaPos == -1 {
|
||||
return member[eqPos+1:]
|
||||
}
|
||||
if eqPos > commaPos {
|
||||
log.Fatalf("couldn't extract user name from %s", member)
|
||||
}
|
||||
return member[eqPos+1 : commaPos]
|
||||
}
|
||||
|
||||
// Read the list of members from a LDAP group
|
||||
func getGroupMembers(group string, conn *ldap.Conn, fields []string) (members []string) {
|
||||
req := ldap.NewSearchRequest(group, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, "(objectClass=*)", fields, nil)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
log.Fatalf("LDAP search for %s: %v", group, err)
|
||||
}
|
||||
|
||||
for _, entry := range res.Entries {
|
||||
for _, attr := range fields {
|
||||
values := entry.GetAttributeValues(attr)
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
members = make([]string, len(values))
|
||||
for i, value := range values {
|
||||
members[i] = usernameFromMember(value)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Read the list of group members from the LDAP server for all groups in the mapping section.
|
||||
func readLdapGroups(configuration Configuration) (groups GroupMembers) {
|
||||
var scheme string
|
||||
if configuration.Ldap.Tls == "yes" {
|
||||
scheme = "ldaps"
|
||||
} else {
|
||||
scheme = "ldap"
|
||||
}
|
||||
url := fmt.Sprintf("%s://%s:%d", scheme, configuration.Ldap.Host, configuration.Ldap.Port)
|
||||
|
||||
conn, err := ldap.DialURL(url)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to LDAP server %s: %v", configuration.Ldap.Host, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if configuration.Ldap.Tls == "starttls" {
|
||||
tlsConfig := tls.Config{}
|
||||
// FIXME missing support for CA chain
|
||||
if err := conn.StartTLS(&tlsConfig); err != nil {
|
||||
log.Fatalf("LDAP server %s, StartTLS failed: %v", configuration.Ldap.Host, err)
|
||||
}
|
||||
}
|
||||
|
||||
if configuration.Ldap.BindUser != "" {
|
||||
err := conn.Bind(configuration.Ldap.BindUser, configuration.Ldap.BindPassword)
|
||||
if err != nil {
|
||||
log.Fatalf("LDAP server %s, could not bind: %v", configuration.Ldap.Host, err)
|
||||
}
|
||||
}
|
||||
|
||||
groups = make(GroupMembers)
|
||||
for group := range configuration.Mapping {
|
||||
groups[group] = getGroupMembers(group, conn, configuration.Ldap.MemberFields)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// List groups an user is a member of.
|
||||
func getUserGroups(user string, membership GroupMembers) (groups []string) {
|
||||
groups = make([]string, 0)
|
||||
for group, members := range membership {
|
||||
for _, member := range members {
|
||||
if member == user {
|
||||
groups = append(groups, group)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Compute roles that should apply to an user
|
||||
func computeRoles(mapping GroupMapping, membership []string) (roles []string) {
|
||||
rset := make(map[string]bool)
|
||||
for _, group := range membership {
|
||||
for _, role := range mapping[group].Roles {
|
||||
rset[role] = true
|
||||
}
|
||||
}
|
||||
|
||||
roles = make([]string, len(rset))
|
||||
i := 0
|
||||
for group := range rset {
|
||||
roles[i] = group
|
||||
i++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Compute privileges on Graylog objects that should be granted to an user
|
||||
func computePrivileges(mapping GroupMapping, membership []string) (privileges []string) {
|
||||
type privInfo struct {
|
||||
otp, oid string
|
||||
priv int
|
||||
}
|
||||
rset := make(map[string]privInfo)
|
||||
for _, group := range membership {
|
||||
for _, priv := range mapping[group].Privileges {
|
||||
key := fmt.Sprintf("%s:%s", priv.Type, priv.Id)
|
||||
record, ok := rset[key]
|
||||
level := privLevels[priv.Level]
|
||||
if ok && level <= record.priv {
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
record.otp = priv.Type
|
||||
record.oid = priv.Id
|
||||
}
|
||||
record.priv = level
|
||||
rset[key] = record
|
||||
}
|
||||
}
|
||||
|
||||
privileges = make([]string, 0)
|
||||
for _, record := range rset {
|
||||
key := fmt.Sprintf("%s:%s", record.otp, privStr[record.priv])
|
||||
for _, p := range graylogPriv[key] {
|
||||
pval := fmt.Sprintf(p, record.oid)
|
||||
privileges = append(privileges, pval)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete a Graylog user account
|
||||
func deleteAccount(cfg GraylogConfig, user string) {
|
||||
log.Printf("DELETING ACCOUNT %s", user)
|
||||
code, body := executeApiCall(cfg, "DELETE", fmt.Sprintf("/users/%s", user), nil)
|
||||
if code != 204 {
|
||||
log.Fatalf("could not delete user %s: code %d, body '%s'", user, code, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the strings that are in a but not in b.
|
||||
func getDifference(a []string, b []string) (diff []string) {
|
||||
diff = make([]string, 0)
|
||||
for _, sa := range a {
|
||||
found := false
|
||||
for _, sb := range b {
|
||||
if sa == sb {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
diff = append(diff, sa)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Set an account's roles and grant it access to Graylog objects
|
||||
func setUserPrivileges(cfg GraylogConfig, user GraylogUser, roles []string, privileges []string) {
|
||||
type perms struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
p := perms{Permissions: privileges}
|
||||
data, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
log.Fatalf("unable to generate permissions JSON for %s: %v", user, err)
|
||||
}
|
||||
|
||||
code, body := executeApiCall(cfg, "PUT", fmt.Sprintf("users/%s/permissions", user.Username), bytes.NewBuffer(data))
|
||||
if code != 204 {
|
||||
log.Fatalf("could not set permissions for %s: code %d, body '%s'", user.Username, code, string(body))
|
||||
}
|
||||
|
||||
placeholder := bytes.NewBuffer([]byte("{}"))
|
||||
for _, role := range getDifference(roles, user.Roles) {
|
||||
ep := fmt.Sprintf("roles/%s/members/%s", role, user.Username)
|
||||
code, body := executeApiCall(cfg, "PUT", ep, placeholder)
|
||||
if code != 204 {
|
||||
log.Fatalf("could not add role %s to %s: code %d, body '%s'", role, user.Username, code, string(body))
|
||||
}
|
||||
}
|
||||
for _, role := range getDifference(user.Roles, roles) {
|
||||
ep := fmt.Sprintf("roles/%s/members/%s", role, user.Username)
|
||||
code, body := executeApiCall(cfg, "DELETE", ep, nil)
|
||||
if code != 204 {
|
||||
log.Fatalf("could not remove role %s from %s: code %d, body '%s'", role, user.Username, code, string(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply privilege mappings to the external Graylog users
|
||||
func applyMapping(cfg Configuration, users []GraylogUser, groups GroupMembers) {
|
||||
for _, user := range users {
|
||||
membership := getUserGroups(user.Username, groups)
|
||||
roles := computeRoles(cfg.Mapping, membership)
|
||||
privileges := computePrivileges(cfg.Mapping, membership)
|
||||
if cfg.Graylog.DeleteAccounts && len(roles) == 0 && len(privileges) == 0 {
|
||||
deleteAccount(cfg.Graylog, user.Username)
|
||||
} else {
|
||||
setUserPrivileges(cfg.Graylog, user, roles, privileges)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
configuration := loadConfiguration()
|
||||
glUsers := getGraylogUsers(configuration.Graylog)
|
||||
groups := readLdapGroups(configuration)
|
||||
applyMapping(configuration, glUsers, groups)
|
||||
}
|
Loading…
Reference in a new issue