Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow usage of text file for users' data storage instead of LDAP #78

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ The role of the hologram server must have assume role permissions. See permissi

For different projects it is recommended that you create IAM roles for each and have your developers assume these roles for testing the software. Hologram supports a command `hologram use <rolename>` which will fetch temporary credentials for this role instead of the default developer one until it is reset or another role is assumed.

You will need to modify the Trusted Entities for each of these roles that you create so that the IAM instance profile you created for the Hologram Server can access them. The hologram user must have permission to assume that role.
You will need to modify the Trusted Entities for each of these roles that you create so that the IAM instance profile you created for the Hologram Server can access them. The hologram user must have permission to assume that role.

```json
{
Expand All @@ -158,7 +158,7 @@ The user must have permission to iam:GetUser on itself(resource "arn:aws:iam::AC

### LDAP Based Roles

Hologram supports assigning roles based on a user's LDAP group. Roles can be turned on by setting the `enableLDAPRoles` key to `true` in `config/server.json`.
Hologram supports assigning roles based on a user's LDAP group. Roles can be turned on by setting the `enableServerRoles` key to `true` in `config/server.json`.

An LDAP group attribute will have to be chosen for user roles. By default `businessCategory` is chosen for this role since it is part of the core LDAP schema. The attribute used can be modified by editing the `roleAttribute` key in `config/server.json`. The value of this attribute should be the name of the group's role in AWS.

Expand Down
28 changes: 19 additions & 9 deletions cmd/hologram-server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,32 @@ type LDAP struct {
DN string `json:"dn"`
Password string `json:"password"`
} `json:"bind"`
UserAttr string `json:"userattr"`
BaseDN string `json:"basedn"`
Host string `json:"host"`
InsecureLDAP bool `json:"insecureldap"`
UserAttr string `json:"userattr"`
BaseDN string `json:"basedn"`
Host string `json:"host"`
EnableLDAPRoles bool `json:"enableldaproles"`
InsecureLDAP bool `json:"insecureldap"`
RoleAttribute string `json:"roleattr"`
DefaultRoleAttr string `json:"defaultroleattr"`
}

type KeysFile struct {
FilePath string `json:"filepath"`
UserAttr string `json:"userattr"`
RoleAttr string `json:"roleattr"`
DefaultRoleAttr string `json:"defaultroleattr"`
}

type Config struct {
LDAP LDAP `json:"ldap"`
AWS struct {
UserStorage string `json:"userstorage"`
LDAP LDAP `json:"ldap"`
KeysFile KeysFile `json:"keysfile"`
AWS struct {
Account string `json:"account"`
DefaultRole string `json:"defaultrole"`
} `json:"aws"`
Stats string `json:"stats"`
Listen string `json:"listen"`
CacheTimeout int `json:"cachetimeout"`
EnableServerRoles bool `json:"enableserverroles"`
Stats string `json:"stats"`
Listen string `json:"listen"`
CacheTimeout int `json:"cachetimeout"`
}
86 changes: 62 additions & 24 deletions cmd/hologram-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import (
"github.com/peterbourgon/g2s"
)

const (
LDAPUserStorage = "ldap"
FileUserStorage = "file"
)

func ConnectLDAP(conf LDAP) (*ldap.Conn, error) {
var ldapServer *ldap.Conn
var err error
Expand Down Expand Up @@ -72,14 +77,17 @@ func main() {
ldapBindPassword = flag.String("ldapBindPassword", "", "LDAP password for bind.")
statsdHost = flag.String("statsHost", "", "Address to send statsd metrics to.")
iamAccount = flag.String("iamaccount", "", "AWS Account ID for generating IAM Role ARNs")
enableLDAPRoles = flag.Bool("ldaproles", false, "Enable role support using LDAP directory.")
roleAttribute = flag.String("roleattribute", "", "Group attribute to get role from.")
defaultRoleAttr = flag.String("defaultroleattr", "", "User attribute to check to determine a user's default role.")
defaultRole = flag.String("role", "", "AWS role to assume by default.")
configFile = flag.String("conf", "/etc/hologram/server.json", "Config file to load.")
cacheTimeout = flag.Int("cachetime", 3600, "Time in seconds after which to refresh LDAP user cache.")
debugMode = flag.Bool("debug", false, "Enable debug mode.")
config Config
// Still here for backwards compatibility
enableLDAPRoles = flag.Bool("ldaproles", false, "Enable role support using LDAP directory (DEPRECATED: Use enableServerRoles instead).")
enableServerRoles = flag.Bool("serverRoles", false, "Enable role support using server directory.")
roleAttribute = flag.String("roleattribute", "", "Group attribute to get role from.")
defaultRoleAttr = flag.String("defaultroleattr", "", "User attribute to check to determine a user's default role.")
defaultRole = flag.String("role", "", "AWS role to assume by default.")
userStorage = flag.String("userStorage", LDAPUserStorage, "User storage type (ldap, file)")
configFile = flag.String("conf", "/etc/hologram/server.json", "Config file to load.")
cacheTimeout = flag.Int("cachetime", 3600, "Time in seconds after which to refresh LDAP user cache.")
debugMode = flag.Bool("debug", false, "Enable debug mode.")
config Config
)

flag.Parse()
Expand All @@ -105,6 +113,16 @@ func main() {
}

// Merge in command flag options.
if config.UserStorage == "" {
config.UserStorage = *userStorage
}

// Validating user storage value
if config.UserStorage != LDAPUserStorage && config.UserStorage != FileUserStorage {
log.Errorf("Invalid user storage value: %s. Possible values (%s, %s)", config.UserStorage, LDAPUserStorage, FileUserStorage)
os.Exit(1)
}

if *ldapAddress != "" {
config.LDAP.Host = *ldapAddress
}
Expand Down Expand Up @@ -137,10 +155,14 @@ func main() {
config.AWS.DefaultRole = *defaultRole
}

if *enableLDAPRoles {
if *enableServerRoles || *enableLDAPRoles {
config.LDAP.EnableLDAPRoles = true
}

if config.LDAP.EnableLDAPRoles || *enableLDAPRoles {
config.EnableServerRoles = true
}

if *defaultRoleAttr != "" {
config.LDAP.DefaultRoleAttr = *defaultRoleAttr
}
Expand Down Expand Up @@ -177,21 +199,35 @@ func main() {
stsConnection := sts.New(session.New(&aws.Config{}))
credentialsService := server.NewDirectSessionTokenService(config.AWS.Account, stsConnection)

open := func() (server.LDAPImplementation, error) { return ConnectLDAP(config.LDAP) }
ldapServer, err := server.NewPersistentLDAP(open)
if err != nil {
log.Errorf("Fatal error, exiting: %s", err.Error())
os.Exit(1)
}
var (
userCache server.UserCache
userStorageImpl server.UserStorage
)

ldapCache, err := server.NewLDAPUserCache(ldapServer, stats, config.LDAP.UserAttr, config.LDAP.BaseDN, config.LDAP.EnableLDAPRoles, config.LDAP.RoleAttribute, config.AWS.DefaultRole, config.LDAP.DefaultRoleAttr)
if err != nil {
log.Errorf("Top-level error in LDAPUserCache layer: %s", err.Error())
os.Exit(1)
if config.UserStorage == LDAPUserStorage {
open := func() (server.LDAPImplementation, error) { return ConnectLDAP(config.LDAP) }
userStorageImpl, err := server.NewPersistentLDAP(open)
if err != nil {
log.Errorf("Fatal error, exiting: %s", err.Error())
os.Exit(1)
}

userCache, err = server.NewLDAPUserCache(userStorageImpl, stats, config.LDAP.UserAttr, config.LDAP.BaseDN, config.EnableServerRoles, config.LDAP.RoleAttribute, config.AWS.DefaultRole, config.LDAP.DefaultRoleAttr)
if err != nil {
log.Errorf("Top-level error in LDAPUserCache layer: %s", err.Error())
os.Exit(1)
}
} else if config.UserStorage == FileUserStorage {
open := func() ([]byte, error) { return ioutil.ReadFile(config.KeysFile.FilePath) }
dump := func(data []byte) error {
return ioutil.WriteFile(config.KeysFile.FilePath, data, os.FileMode(500))
}
userStorageImpl := server.NewPersistentKeysFile(open, dump, config.KeysFile.UserAttr, config.KeysFile.RoleAttr)
userCache, _ = server.NewKeysFileUserCache(userStorageImpl, stats, config.EnableServerRoles, config.KeysFile.UserAttr, config.KeysFile.RoleAttr, config.AWS.DefaultRole, config.KeysFile.DefaultRoleAttr)
}

serverHandler := server.New(ldapCache, credentialsService, config.AWS.DefaultRole, stats, ldapServer, config.LDAP.UserAttr, config.LDAP.BaseDN, config.LDAP.EnableLDAPRoles, config.LDAP.DefaultRoleAttr)
server, err := remote.NewServer(config.Listen, serverHandler.HandleConnection)
serverHandler := server.New(userCache, credentialsService, config.AWS.DefaultRole, stats, userStorageImpl, config.EnableServerRoles)
server, _ := remote.NewServer(config.Listen, serverHandler.HandleConnection)

// Wait for a signal from the OS to shutdown.
terminate := make(chan os.Signal)
Expand Down Expand Up @@ -227,10 +263,12 @@ WaitForTermination:
log.DebugMode(false)
case <-reloadCacheSigHup:
log.Info("Force-reloading user cache.")
ldapCache.Update()
err := userCache.Update()
log.Errorf("Error while updating cache: %s", err.Error())
case <-cacheTimeoutTicker.C:
log.Info("Cache timeout. Reloading user cache.")
ldapCache.Update()
err := userCache.Update()
log.Errorf("Error while updating cache: %s", err.Error())

}
}

Expand Down
2 changes: 1 addition & 1 deletion protocol/hologram.proto
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ message ServerRequest {
SSHChallengeResponse challengeResponse = 5;
MFATokenResponse tokenResponse = 6;
GetUserCredentials getUserCredentials = 7;
AddSSHKey addSSHkey = 8;
AddSSHKey addSSHkey = 8;
}
}

Expand Down
6 changes: 3 additions & 3 deletions server/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ credentials to calling processes. No caching is done of these
results other than that which the CredentialService does itself.
*/
type CredentialService interface {
AssumeRole(user *User, role string, enableLDAPRoles bool) (*sts.Credentials, error)
AssumeRole(user *User, role string, enableServerRoles bool) (*sts.Credentials, error)
}

/*
Expand Down Expand Up @@ -77,12 +77,12 @@ func (s *directSessionTokenService) buildARN(role string) string {
return arn
}

func (s *directSessionTokenService) AssumeRole(user *User, role string, enableLDAPRoles bool) (*sts.Credentials, error) {
func (s *directSessionTokenService) AssumeRole(user *User, role string, enableServerRoles bool) (*sts.Credentials, error) {
var arn string = s.buildARN(role)

log.Debug("Checking ARN %s against user %s (with access %s)", arn, user.Username, user.ARNs)

if enableLDAPRoles {
if enableServerRoles {
found := false
for _, a := range user.ARNs {
a = s.buildARN(a)
Expand Down
98 changes: 98 additions & 0 deletions server/persistent_keys_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package server

import (
"encoding/json"
"errors"
"fmt"
)

type KeysMap map[string]map[string]interface{}

type persistentKeysFile struct {
// Function that return the contents of the file
open func() ([]byte, error)
// Function to dump contents to the file
dump func([]byte) error

userAttr string
roleAttr string
// Map from public ssh keys to a list of roles
keys KeysMap
}

func (pkf *persistentKeysFile) Load() error {
fileContent, err := pkf.open()
if err != nil {
return err
}

var keys KeysMap

if err := json.Unmarshal(fileContent, &keys); err != nil {
return err
}

pkf.keys = keys

return nil
}

func (pkf *persistentKeysFile) Keys() (KeysMap, error) {
if pkf.keys == nil {
err := pkf.Load()
if err != nil {
return nil, err
}
}
return pkf.keys, nil
}

func (pkf *persistentKeysFile) Search(username string) (map[string]interface{}, error) {
if pkf.keys == nil {
err := pkf.Load()
if err != nil {
return nil, err
}
}

data := map[string]interface{}{
"username": username,
"password": "",
}

sshPublicKeys := []string{}

found := false
for key, userData := range pkf.keys {
u, _ := userData[pkf.userAttr]
user, _ := u.(string)
password, _ := userData["password"]
passwordHash, _ := password.(string)
if user == username {
sshPublicKeys = append(sshPublicKeys, key)
data["password"] = passwordHash
found = true
}
}
if found {
data["sshPublicKeys"] = sshPublicKeys
return data, nil
}

return nil, errors.New(fmt.Sprintf("User %s not found!", username))
}

func (pkf *persistentKeysFile) SearchUser(userData map[string]string) (map[string]interface{}, error) {
return pkf.Search(userData["username"])
}

func (pkf *persistentKeysFile) Modify(username, sshPublicKey string) error {
pkf.keys[sshPublicKey] = map[string]interface{}{"username": username}

keysBytes, _ := json.Marshal(pkf.keys)
return pkf.dump(keysBytes) // Dump contents of keys
}

func NewPersistentKeysFile(open func() ([]byte, error), dump func([]byte) error, userAttr, roleAttr string) KeysFile {
return &persistentKeysFile{open: open, dump: dump, userAttr: userAttr, roleAttr: roleAttr}
}
58 changes: 58 additions & 0 deletions server/persistent_keys_file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package server_test

import (
"sort"
"testing"

"github.com/AdRoll/hologram/server"
. "github.com/smartystreets/goconvey/convey"
)

func TestPersistentKeysFile(t *testing.T) {
data := `{
"KEY1": {"username": "user1", "password": "pass1", "roles": ["role1", "role11"]},
"KEY2": {"username": "user2", "password": "pass2", "roles": ["role2", "role22"]},
"KEY3": {"username": "user1", "password": "pass1", "roles": ["role111", "role1111"]}
}`

open := func() ([]byte, error) {
return []byte(data), nil
}

dump := func([]byte) error {
return nil
}

Convey("Given data from keys file", t, func() {
Convey("Content from file should be loaded correctly", func() {
pkf := server.NewPersistentKeysFile(open, dump, "username", "roles")
err := pkf.Load()
So(err, ShouldBeNil)
})

Convey("An existing key in file should be found", func() {
pkf := server.NewPersistentKeysFile(open, dump, "username", "roles")

keys := []string{"KEY3", "KEY1"}
sort.Strings(keys)
expected := map[string]interface{}{
"username": "user1",
"sshPublicKeys": keys,
"password": "pass1",
}
actual, err := pkf.Search("user1")
actualKeys := actual["sshPublicKeys"]
sort.Strings(actualKeys.([]string))
So(err, ShouldBeNil)
So(actual, ShouldResemble, expected)
})

Convey("An non existing key in file shouldn't be found", func() {
pkf := server.NewPersistentKeysFile(open, dump, "username", "roles")

user, err := pkf.Search("missing user")
So(err, ShouldNotBeNil)
So(user, ShouldBeNil)
})
})
}
Loading