diff --git a/server/datastore/datastore_hosts_test.go b/server/datastore/datastore_hosts_test.go index 920a9843a9..45d4a1be2d 100644 --- a/server/datastore/datastore_hosts_test.go +++ b/server/datastore/datastore_hosts_test.go @@ -328,12 +328,9 @@ func testSearchHosts(t *testing.T, ds kolide.Datastore) { hits, err = ds.SearchHosts("99.100.101") require.Nil(t, err) assert.Equal(t, 2, len(hits)) - - hits, err = ds.SearchHosts("99.100.101", h3.ID) require.Nil(t, err) assert.Equal(t, 1, len(hits)) - } func testSearchHostsLimit(t *testing.T, ds kolide.Datastore) { diff --git a/server/datastore/datastore_options_test.go b/server/datastore/datastore_options_test.go new file mode 100644 index 0000000000..f20dc0a22f --- /dev/null +++ b/server/datastore/datastore_options_test.go @@ -0,0 +1,58 @@ +package datastore + +import ( + "reflect" + "testing" + + "github.com/kolide/kolide-ose/server/datastore/internal/appstate" + "github.com/kolide/kolide-ose/server/kolide" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testOptions(t *testing.T, ds kolide.Datastore) { + // were options pre-loaded? + opts, err := ds.ListOptions() + require.Nil(t, err) + assert.Len(t, opts, len(appstate.Options)) + + opt, err := ds.OptionByName("aws_access_key_id") + require.Nil(t, err) + require.NotNil(t, opt) + opt2, err := ds.Option(opt.ID) + require.Nil(t, err) + require.NotNil(t, opt2) + assert.True(t, reflect.DeepEqual(opt, opt2)) + + opt.SetValue("somekey") + err = ds.SaveOptions([]kolide.Option{*opt}) + require.Nil(t, err) + opt, err = ds.Option(opt.ID) + require.Nil(t, err) + assert.Equal(t, "somekey", opt.GetValue()) + + // can't change a read only option + opt, err = ds.OptionByName("disable_distributed") + require.Nil(t, err) + opt.SetValue(true) + err = ds.SaveOptions([]kolide.Option{*opt}) + require.NotNil(t, err) + + opt, _ = ds.OptionByName("aws_profile_name") + assert.False(t, opt.OptionSet()) + opt.SetValue("zip") + opt2, _ = ds.OptionByName("disable_distributed") + assert.Equal(t, false, opt2.GetValue()) + opt2.SetValue(true) + modList := []kolide.Option{*opt, *opt2} + // The aws access key option can be saved but because the disable_events can't + // be we want to verify that the whole transaction is rolled back + err = ds.SaveOptions(modList) + assert.NotNil(t, err) + opt, _ = ds.OptionByName("aws_profile_name") + assert.False(t, opt.OptionSet()) + opt2, err = ds.OptionByName("disable_distributed") + require.Nil(t, err) + assert.Equal(t, false, opt2.GetValue()) + +} diff --git a/server/datastore/datastore_test.go b/server/datastore/datastore_test.go index a8b4ebbd38..d67dec3421 100644 --- a/server/datastore/datastore_test.go +++ b/server/datastore/datastore_test.go @@ -55,5 +55,6 @@ var testFunctions = [...]func(*testing.T, kolide.Datastore){ testDeleteScheduledQuery, testListScheduledQueriesInPack, testSaveScheduledQuery, + testOptions, testNewScheduledQuery, } diff --git a/server/datastore/inmem/app.go b/server/datastore/inmem/app.go index 452f425c36..8830263748 100644 --- a/server/datastore/inmem/app.go +++ b/server/datastore/inmem/app.go @@ -7,7 +7,7 @@ func (d *Datastore) NewAppConfig(info *kolide.AppConfig) (*kolide.AppConfig, err defer d.mtx.Unlock() info.ID = 1 - d.orginfo = info + d.appConfig = info return info, nil } @@ -15,8 +15,8 @@ func (d *Datastore) AppConfig() (*kolide.AppConfig, error) { d.mtx.Lock() defer d.mtx.Unlock() - if d.orginfo != nil { - return d.orginfo, nil + if d.appConfig != nil { + return d.appConfig, nil } return nil, notFound("AppConfig") @@ -26,6 +26,6 @@ func (d *Datastore) SaveAppConfig(info *kolide.AppConfig) error { d.mtx.Lock() defer d.mtx.Unlock() - d.orginfo = info + d.appConfig = info return nil } diff --git a/server/datastore/inmem/inmem.go b/server/datastore/inmem/inmem.go index 2ca9179aea..432964cc9a 100644 --- a/server/datastore/inmem/inmem.go +++ b/server/datastore/inmem/inmem.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kolide/kolide-ose/server/config" + "github.com/kolide/kolide-ose/server/datastore/internal/appstate" "github.com/kolide/kolide-ose/server/kolide" "github.com/patrickmn/sortutil" ) @@ -31,9 +32,9 @@ type Datastore struct { distributedQueryExecutions map[uint]kolide.DistributedQueryExecution distributedQueryCampaigns map[uint]kolide.DistributedQueryCampaign distributedQueryCampaignTargets map[uint]kolide.DistributedQueryCampaignTarget - - orginfo *kolide.AppConfig - config *config.KolideConfig + options map[uint]*kolide.Option + appConfig *kolide.AppConfig + config *config.KolideConfig } func New(config config.KolideConfig) (*Datastore, error) { @@ -68,32 +69,44 @@ func sortResults(slice interface{}, opt kolide.ListOptions, fields map[string]st return nil } -func (orm *Datastore) Migrate() error { - orm.mtx.Lock() - defer orm.mtx.Unlock() - orm.nextIDs = make(map[interface{}]uint) - orm.users = make(map[uint]*kolide.User) - orm.sessions = make(map[uint]*kolide.Session) - orm.passwordResets = make(map[uint]*kolide.PasswordResetRequest) - orm.invites = make(map[uint]*kolide.Invite) - orm.labels = make(map[uint]*kolide.Label) - orm.labelQueryExecutions = make(map[uint]*kolide.LabelQueryExecution) - orm.queries = make(map[uint]*kolide.Query) - orm.packs = make(map[uint]*kolide.Pack) - orm.hosts = make(map[uint]*kolide.Host) - orm.scheduledQueries = make(map[uint]*kolide.ScheduledQuery) - orm.packTargets = make(map[uint]*kolide.PackTarget) - orm.distributedQueryExecutions = make(map[uint]kolide.DistributedQueryExecution) - orm.distributedQueryCampaigns = make(map[uint]kolide.DistributedQueryCampaign) - orm.distributedQueryCampaignTargets = make(map[uint]kolide.DistributedQueryCampaignTarget) - orm.orginfo = &kolide.AppConfig{ +func (d *Datastore) Migrate() error { + d.mtx.Lock() + defer d.mtx.Unlock() + d.nextIDs = make(map[interface{}]uint) + d.users = make(map[uint]*kolide.User) + d.sessions = make(map[uint]*kolide.Session) + d.passwordResets = make(map[uint]*kolide.PasswordResetRequest) + d.invites = make(map[uint]*kolide.Invite) + d.labels = make(map[uint]*kolide.Label) + d.labelQueryExecutions = make(map[uint]*kolide.LabelQueryExecution) + d.queries = make(map[uint]*kolide.Query) + d.packs = make(map[uint]*kolide.Pack) + d.hosts = make(map[uint]*kolide.Host) + d.scheduledQueries = make(map[uint]*kolide.ScheduledQuery) + d.packTargets = make(map[uint]*kolide.PackTarget) + d.distributedQueryExecutions = make(map[uint]kolide.DistributedQueryExecution) + d.distributedQueryCampaigns = make(map[uint]kolide.DistributedQueryCampaign) + d.distributedQueryCampaignTargets = make(map[uint]kolide.DistributedQueryCampaignTarget) + d.options = make(map[uint]*kolide.Option) + + for _, initData := range appstate.Options { + opt := kolide.Option{ + Name: initData.Name, + Value: kolide.OptionValue{Val: initData.Value}, + Type: initData.Type, + ReadOnly: initData.ReadOnly, + } + opt.ID = d.nextID(opt) + d.options[opt.ID] = &opt + } + + d.appConfig = &kolide.AppConfig{ ID: 1, SMTPEnableTLS: true, SMTPPort: 587, SMTPEnableStartTLS: true, SMTPVerifySSLCerts: true, } - return nil } diff --git a/server/datastore/inmem/options.go b/server/datastore/inmem/options.go new file mode 100644 index 0000000000..f2bfb86c3d --- /dev/null +++ b/server/datastore/inmem/options.go @@ -0,0 +1,74 @@ +package inmem + +import ( + "fmt" + + "github.com/kolide/kolide-ose/server/kolide" + "github.com/patrickmn/sortutil" +) + +func (d *Datastore) OptionByName(name string) (*kolide.Option, error) { + d.mtx.Lock() + defer d.mtx.Unlock() + for _, opt := range d.options { + if opt.Name == name { + result := *opt + return &result, nil + } + } + return nil, notFound("options") +} + +type optPair struct { + newOpt kolide.Option + existingOpt *kolide.Option +} + +func (d *Datastore) SaveOptions(opts []kolide.Option) error { + d.mtx.Lock() + defer d.mtx.Unlock() + var validPairs []optPair + for _, opt := range opts { + if opt.ReadOnly { + return fmt.Errorf("readonly option can't be changed") + } + existing, ok := d.options[opt.ID] + if !ok { + return notFound("option") + } + if existing.Type != opt.Type { + return fmt.Errorf("type mismatch for option") + } + validPairs = append(validPairs, optPair{opt, existing}) + } + // if all the options to be modified pass validation copy values over to + // existing options + if len(validPairs) == len(opts) { + for _, pair := range validPairs { + pair.existingOpt.Value.Val = pair.newOpt.Value.Val + } + } + return nil +} + +func (d *Datastore) Option(id uint) (*kolide.Option, error) { + d.mtx.Lock() + defer d.mtx.Unlock() + saved, ok := d.options[id] + if !ok { + return nil, notFound("Option").WithID(id) + } + result := *saved + return &result, nil +} + +func (d *Datastore) ListOptions() ([]kolide.Option, error) { + d.mtx.Lock() + defer d.mtx.Unlock() + result := []kolide.Option{} + for _, opt := range d.options { + result = append(result, *opt) + } + sortutil.AscByField(result, "Name") + return result, nil +} diff --git a/server/datastore/internal/appstate/options.go b/server/datastore/internal/appstate/options.go new file mode 100644 index 0000000000..9686cf9a65 --- /dev/null +++ b/server/datastore/internal/appstate/options.go @@ -0,0 +1,70 @@ +package appstate + +import "github.com/kolide/kolide-ose/server/kolide" + +var Options = []struct { + Name string + Value interface{} + Type kolide.OptionType + ReadOnly bool +}{ + // These options are read only, attempting to modify one of these will + // raise an error + {"disable_distributed", false, kolide.OptionTypeBool, kolide.ReadOnly}, + {"distributed_plugin", "tls", kolide.OptionTypeString, kolide.ReadOnly}, + {"distributed_tls_read_endpoint", "/api/v1/osquery/distributed/read", kolide.OptionTypeString, kolide.ReadOnly}, + {"distributed_tls_write_endpoint", "/api/v1/osquery/distributed/write", kolide.OptionTypeString, kolide.ReadOnly}, + {"pack_delimiter", "/", kolide.OptionTypeString, kolide.ReadOnly}, + // These options may be modified by an admin user + {"aws_access_key_id", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"aws_firehose_period", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"aws_firehose_stream", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"aws_kinesis_period", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"aws_kinesis_random_partition_key", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"aws_kinesis_stream", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"aws_profile_name", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"aws_region", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"aws_secret_access_key", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"aws_sts_arn_role", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"aws_sts_region", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"aws_sts_session_name", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"aws_sts_timeout", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"buffered_log_max", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"decorations_top_level", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"disable_caching", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"disable_database", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"disable_decorators", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"disable_events", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"disable_kernel", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"disable_logging", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"disable_tables", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"distributed_interval", 10, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"distributed_tls_max_attempts", 3, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"enable_foreign", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"enable_monitor", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"ephemeral", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"events_expiry", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"events_max", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"events_optimize", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"host_identifier", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"logger_event_type", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"logger_mode", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"logger_path", nil, kolide.OptionTypeString, kolide.NotReadOnly}, + {"logger_plugin", "tls", kolide.OptionTypeString, kolide.NotReadOnly}, + {"logger_secondary_status_only", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"logger_syslog_facility", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"logger_tls_compress", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"logger_tls_endpoint", "/api/v1/osquery/log", kolide.OptionTypeString, kolide.NotReadOnly}, + {"logger_tls_max", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"logger_tls_period", 10, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"pack_refresh_interval", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"read_max", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"read_user_max", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"schedule_default_interval", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"schedule_splay_percent", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"schedule_timeout", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"utc", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"value_max", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, + {"verbose", nil, kolide.OptionTypeBool, kolide.NotReadOnly}, + {"worker_threads", nil, kolide.OptionTypeInt, kolide.NotReadOnly}, +} diff --git a/server/datastore/mysql/migrations/20161118212604_CreateTableOptions.go b/server/datastore/mysql/migrations/20161118212604_CreateTableOptions.go index 7e7b5f81a5..0aea7414af 100644 --- a/server/datastore/mysql/migrations/20161118212604_CreateTableOptions.go +++ b/server/datastore/mysql/migrations/20161118212604_CreateTableOptions.go @@ -11,22 +11,25 @@ func init() { } func Up_20161118212604(tx *sql.Tx) error { + _, err := tx.Exec( "CREATE TABLE `options` (" + - "`id` int(10) unsigned NOT NULL AUTO_INCREMENT," + - "`created_at` timestamp DEFAULT CURRENT_TIMESTAMP," + - "`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + - "`key` varchar(255) NOT NULL," + - "`value` varchar(255) NOT NULL," + - "`platform` varchar(255) DEFAULT NULL," + + "`id` INT UNSIGNED NOT NULL AUTO_INCREMENT," + + "`name` VARCHAR(255) NOT NULL," + + "`type` INT UNSIGNED NOT NULL," + + "`value` VARCHAR(255) NOT NULL," + + "`read_only` TINYINT(1) NOT NULL DEFAULT FALSE," + "PRIMARY KEY (`id`)," + - "UNIQUE KEY `idx_option_unique_key` (`key`)" + + "UNIQUE KEY `idx_option_unique_name` (`name`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8;", ) + return err } func Down_20161118212604(tx *sql.Tx) error { + _, err := tx.Exec("DROP TABLE IF EXISTS `options`;") + return err } diff --git a/server/datastore/mysql/migrations/20161223115449_InsertOsqueryOptions.go b/server/datastore/mysql/migrations/20161223115449_InsertOsqueryOptions.go new file mode 100644 index 0000000000..8e37e579c6 --- /dev/null +++ b/server/datastore/mysql/migrations/20161223115449_InsertOsqueryOptions.go @@ -0,0 +1,55 @@ +package migration + +import ( + "database/sql" + + "github.com/kolide/kolide-ose/server/datastore/internal/appstate" + "github.com/kolide/kolide-ose/server/kolide" + "github.com/pressly/goose" +) + +func init() { + goose.AddMigration(Up_20161223115449, Down_20161223115449) +} + +func Up_20161223115449(tx *sql.Tx) error { + sqlStatement := ` + INSERT INTO options ( + name, + type, + value, + read_only + ) VALUES( ?, ?, ?, ?) + ` + + for _, opt := range appstate.Options { + ov := kolide.Option{ + Name: opt.Name, + ReadOnly: opt.ReadOnly, + Type: opt.Type, + Value: kolide.OptionValue{ + Val: opt.Value, + }, + } + _, err := tx.Exec(sqlStatement, ov.Name, ov.Type, ov.Value, ov.ReadOnly) + if err != nil { + return err + } + + } + return nil +} + +func Down_20161223115449(tx *sql.Tx) error { + sqlStatement := ` + DELETE FROM options + WHERE name = ? + ` + for _, opt := range appstate.Options { + _, err := tx.Exec(sqlStatement, opt.Name) + if err != nil { + return err + } + } + return nil +} diff --git a/server/datastore/mysql/options.go b/server/datastore/mysql/options.go new file mode 100644 index 0000000000..b81b49c90a --- /dev/null +++ b/server/datastore/mysql/options.go @@ -0,0 +1,96 @@ +package mysql + +import ( + "database/sql" + + "github.com/kolide/kolide-ose/server/kolide" + "github.com/pkg/errors" +) + +func (d *Datastore) OptionByName(name string) (*kolide.Option, error) { + sqlStatement := ` + SELECT * + FROM options + WHERE name = ? + ` + var option kolide.Option + if err := d.db.Get(&option, sqlStatement, name); err != nil { + if err == sql.ErrNoRows { + return nil, notFound("Option") + } + return nil, errors.Wrap(err, sqlStatement) + } + return &option, nil +} + +func (d *Datastore) SaveOptions(opts []kolide.Option) (err error) { + sqlStatement := ` + UPDATE options + SET value = ? + WHERE id = ? AND type = ? AND NOT read_only + ` + txn, err := d.db.Begin() + if err != nil { + return errors.Wrap(err, "update options begin transaction") + } + var success bool + defer func() { + if success { + if err = txn.Commit(); err == nil { + return + } + } + txn.Rollback() + }() + + for _, opt := range opts { + result, err := txn.Exec(sqlStatement, opt.Value, opt.ID, opt.Type) + if err != nil { + return errors.Wrap(err, "update options") + } + rowsChanged, err := result.RowsAffected() + if err != nil { + return errors.Wrap(err, "option rows affected") + } + if rowsChanged != 1 { + return notFound("Option").WithID(opt.ID) + } + } + // If all the updates succeed, set the success flag, this will cause the + // function we defined in defer to commit the transaction. Otherwise, all + // changes will be rolled back + success = true + return err +} + +func (d *Datastore) Option(id uint) (*kolide.Option, error) { + sqlStatement := ` + SELECT * + FROM options + WHERE id = ? + ` + var opt kolide.Option + if err := d.db.Get(&opt, sqlStatement, id); err != nil { + if err == sql.ErrNoRows { + return nil, notFound("Option").WithID(id) + } + return nil, errors.Wrap(err, "select option by ID") + } + return &opt, nil +} + +func (d *Datastore) ListOptions() ([]kolide.Option, error) { + sqlStatement := ` + SELECT * + FROM options + ORDER BY name ASC + ` + var opts []kolide.Option + if err := d.db.Select(&opts, sqlStatement); err != nil { + if err == sql.ErrNoRows { + return nil, notFound("Option") + } + return nil, errors.Wrap(err, "select from options") + } + return opts, nil +} diff --git a/server/kolide/datastore.go b/server/kolide/datastore.go index d19d8959e1..e16509477b 100644 --- a/server/kolide/datastore.go +++ b/server/kolide/datastore.go @@ -13,6 +13,7 @@ type Datastore interface { AppConfigStore InviteStore ScheduledQueryStore + OptionStore Name() string Drop() error Migrate() error diff --git a/server/kolide/options.go b/server/kolide/options.go new file mode 100644 index 0000000000..66b997bdaa --- /dev/null +++ b/server/kolide/options.go @@ -0,0 +1,153 @@ +package kolide + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "strings" + + "golang.org/x/net/context" +) + +// OptionStore interface describes methods to access datastore +type OptionStore interface { + // SaveOptions transactional write of options to storage. If one or more + // values fails validation none of the writes will succeed. Note only option + // values are written. Other option fields are created in migration and do + // not change. Attempting to write ReadOnly options will cause an error. + SaveOptions(opts []Option) error + // Options returns all options + ListOptions() ([]Option, error) + // Option return an option by ID + Option(id uint) (*Option, error) + // OptionByName returns an option uniquely identified by name + OptionByName(name string) (*Option, error) +} + +// OptionService interface describes methods that operate on osquery options +type OptionService interface { + // GetOptions retrieves all options + GetOptions(ctx context.Context) ([]Option, error) + // ModifyOptions will change values of the options in OptionRequest. Note + // passing ReadOnly options will cause an error. + ModifyOptions(ctx context.Context, req OptionRequest) ([]Option, error) +} + +const ( + ReadOnly = true + NotReadOnly = !ReadOnly +) + +// OptionType defines the type of the value assigned to an option +type OptionType int + +const ( + OptionTypeString OptionType = iota + OptionTypeInt + OptionTypeBool +) + +// String values that map from JSON to OptionType +const ( + optionTypeString = "string" + optionTypeInt = "int" + optionTypeBool = "bool" +) + +// MarshalJSON marshals option type to strings +func (ot OptionType) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, ot)), nil +} + +// UnmarshalJSON converts json to OptionType +func (ot *OptionType) UnmarshalJSON(b []byte) error { + switch typ := string(b); strings.Trim(typ, `"`) { + case optionTypeString: + *ot = OptionTypeString + case optionTypeBool: + *ot = OptionTypeBool + case optionTypeInt: + *ot = OptionTypeInt + default: + return fmt.Errorf("unsupported option type '%s'", typ) + } + return nil +} + +// String is used to marshal OptionType to human readable strings used in JSON payloads +func (ot OptionType) String() string { + switch ot { + case OptionTypeString: + return optionTypeString + case OptionTypeInt: + return optionTypeInt + case OptionTypeBool: + return optionTypeBool + default: + panic("stringer not implemented for OptionType") + } +} + +// OptionValue supports Valuer and Scan interfaces for reading and writing +// to the database, and also JSON marshaling +type OptionValue struct { + Val interface{} +} + +// Value is called by the DB driver. Note that we store data as JSON +// types, so we can use the JSON marshaller to assign the appropriate +// type when we fetch it from the db +func (ov OptionValue) Value() (dv driver.Value, err error) { + return json.Marshal(ov.Val) +} + +// Scan takes db string and turns it into an option type +func (ov *OptionValue) Scan(src interface{}) error { + return json.Unmarshal(src.([]byte), &ov.Val) +} + +// MarshalJSON implements the json.Marshaler interface +func (ov OptionValue) MarshalJSON() ([]byte, error) { + return json.Marshal(ov.Val) +} + +// UnmarshalJSON implements the json.Unmarshaler interface +func (ov *OptionValue) UnmarshalJSON(b []byte) error { + return json.Unmarshal(b, &ov.Val) +} + +// Option represents a possible osquery confguration option +// See https://osquery.readthedocs.io/en/stable/deployment/configuration/ +type Option struct { + // ID unique identifier for option assigned by the dbms + ID uint `json:"id"` + // Name of the option which must be unique + Name string `json:"name"` + // Type of value for the option + Type OptionType `json:"type"` + // Value of the option which may be nil, bool, int, or string. + Value OptionValue `json:"value"` + // ReadOnly if true, this option is required for Kolide to function + // properly and cannot be modified by the end user + ReadOnly bool `json:"read_only" db:"read_only"` +} + +// SetValue sets the value associated with the option +func (opt *Option) SetValue(v interface{}) { + opt.Value.Val = v +} + +// OptionSet returns true if the option has a value assigned to it +func (opt *Option) OptionSet() bool { + return opt.Value.Val != nil +} + +// GetValue returns the value associated with the option +func (opt Option) GetValue() interface{} { + return opt.Value.Val +} + +// OptionRequest contains options that are passed to modify options requests. +type OptionRequest struct { + Options []Option `json:"options"` +} diff --git a/server/kolide/options_test.go b/server/kolide/options_test.go new file mode 100644 index 0000000000..5869cc8f0a --- /dev/null +++ b/server/kolide/options_test.go @@ -0,0 +1,47 @@ +package kolide + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOptionMarshaller(t *testing.T) { + tests := []struct { + value interface{} + typ OptionType + expected interface{} + }{ + {23, OptionTypeInt, float64(23)}, + {true, OptionTypeBool, true}, + {"foobar", OptionTypeString, "foobar"}, + } + + for _, test := range tests { + optIn := &Option{1, "foo", test.typ, OptionValue{test.value}, true} + buff, err := json.Marshal(optIn) + require.Nil(t, err) + optOut := &Option{} + err = json.Unmarshal(buff, optOut) + require.Nil(t, err) + assert.Equal(t, optIn.ID, optOut.ID) + assert.Equal(t, optIn.Name, optOut.Name) + assert.Equal(t, optIn.ReadOnly, optOut.ReadOnly) + assert.Equal(t, optIn.Type, optOut.Type) + assert.Equal(t, test.expected, optOut.Value.Val) + + } + + // test nil + optIn := &Option{1, "bar", OptionTypeString, OptionValue{nil}, true} + buff, err := json.Marshal(optIn) + require.Nil(t, err) + optOut := &Option{} + err = json.Unmarshal(buff, optOut) + require.Nil(t, err) + assert.True(t, reflect.DeepEqual(optIn, optOut)) + +} diff --git a/server/kolide/queries.go b/server/kolide/queries.go index d4622a47ac..9404cfa81b 100644 --- a/server/kolide/queries.go +++ b/server/kolide/queries.go @@ -64,15 +64,6 @@ type Query struct { Packs []Pack `json:"packs" db:"-"` } -type Option struct { - ID uint - CreatedAt time.Time - UpdatedAt time.Time - Key string - Value string - Platform string -} - type DecoratorType int const ( diff --git a/server/kolide/service.go b/server/kolide/service.go index 55abc6f320..7d2b16fd49 100644 --- a/server/kolide/service.go +++ b/server/kolide/service.go @@ -14,4 +14,5 @@ type Service interface { InviteService TargetService ScheduledQueryService + OptionService } diff --git a/server/service/endpoint_options.go b/server/service/endpoint_options.go new file mode 100644 index 0000000000..63197b444f --- /dev/null +++ b/server/service/endpoint_options.go @@ -0,0 +1,35 @@ +package service + +import ( + "github.com/go-kit/kit/endpoint" + "github.com/kolide/kolide-ose/server/kolide" + "golang.org/x/net/context" +) + +type optionsResponse struct { + Options []kolide.Option `json:"options,omitempty"` + Err error `json:"error,omitempty"` +} + +func (or optionsResponse) error() error { return or.Err } + +func makeGetOptionsEndpoint(svc kolide.Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + options, err := svc.GetOptions(ctx) + if err != nil { + return optionsResponse{Err: err}, nil + } + return optionsResponse{Options: options}, nil + } +} + +func makeModifyOptionsEndpoint(svc kolide.Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + payload := request.(kolide.OptionRequest) + opts, err := svc.ModifyOptions(ctx, payload) + if err != nil { + return optionsResponse{Err: err}, nil + } + return optionsResponse{Options: opts}, nil + } +} diff --git a/server/service/endpoint_options_test.go b/server/service/endpoint_options_test.go new file mode 100644 index 0000000000..399d1db996 --- /dev/null +++ b/server/service/endpoint_options_test.go @@ -0,0 +1,69 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testGetOptions(t *testing.T, r *testResource) { + + req, err := http.NewRequest("GET", r.server.URL+"/api/v1/kolide/options", nil) + require.Nil(t, err) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", r.userToken)) + client := &http.Client{} + resp, err := client.Do(req) + require.Nil(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + var optsResp optionsResponse + err = json.NewDecoder(resp.Body).Decode(&optsResp) + require.Nil(t, err) + require.NotNil(t, optsResp.Options) + assert.Equal(t, "aws_access_key_id", optsResp.Options[0].Name) +} + +func testModifyOptions(t *testing.T, r *testResource) { + inJson := `{"options":[ + {"id":6,"name":"aws_access_key_id","type":"string","value":"foo","read_only":false}, + {"id":7,"name":"aws_firehose_period","type":"int","value":23,"read_only":false}]}` + buff := bytes.NewBufferString(inJson) + req, err := http.NewRequest("PATCH", r.server.URL+"/api/v1/kolide/options", buff) + require.Nil(t, err) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", r.userToken)) + client := &http.Client{} + resp, err := client.Do(req) + require.Nil(t, err) + + var optsResp optionsResponse + err = json.NewDecoder(resp.Body).Decode(&optsResp) + require.Nil(t, err) + require.NotNil(t, optsResp.Options) + require.Len(t, optsResp.Options, 2) + assert.Equal(t, "foo", optsResp.Options[0].GetValue()) + assert.Equal(t, float64(23), optsResp.Options[1].GetValue()) +} + +func testModifyOptionsValidationFail(t *testing.T, r *testResource) { + inJson := `{"options":[ + {"id":6,"name":"aws_access_key_id","type":"string","value":"foo","read_only":false}, + {"id":7,"name":"aws_firehose_period","type":"int","value":"xxs","read_only":false}]}` + buff := bytes.NewBufferString(inJson) + req, err := http.NewRequest("PATCH", r.server.URL+"/api/v1/kolide/options", buff) + require.Nil(t, err) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", r.userToken)) + client := &http.Client{} + resp, err := client.Do(req) + require.Nil(t, err) + assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) + var errStruct mockValidationError + err = json.NewDecoder(resp.Body).Decode(&errStruct) + require.Nil(t, err) + require.Len(t, errStruct.Errors, 1) + assert.Equal(t, "aws_firehose_period", errStruct.Errors[0].Name) + assert.Equal(t, "type mismatch", errStruct.Errors[0].Reason) +} diff --git a/server/service/endpoint_test.go b/server/service/endpoint_test.go index 3c15d773e7..756a4cab9f 100644 --- a/server/service/endpoint_test.go +++ b/server/service/endpoint_test.go @@ -89,6 +89,9 @@ var testFunctions = [...]func(*testing.T, *testResource){ testGetAppConfig, testModifyAppConfig, testModifyAppConfigWithValidationFail, + testGetOptions, + testModifyOptions, + testModifyOptionsValidationFail, } func TestEndpoints(t *testing.T) { diff --git a/server/service/handler.go b/server/service/handler.go index 57714026d7..4d9a649e7a 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -67,6 +67,8 @@ type KolideEndpoints struct { DeleteHost endpoint.Endpoint ListHosts endpoint.Endpoint SearchTargets endpoint.Endpoint + GetOptions endpoint.Endpoint + ModifyOptions endpoint.Endpoint } // MakeKolideServerEndpoints creates the Kolide API endpoints. @@ -127,6 +129,8 @@ func MakeKolideServerEndpoints(svc kolide.Service, jwtKey string) KolideEndpoint GetLabelsForPack: authenticatedUser(jwtKey, svc, makeGetLabelsForPackEndpoint(svc)), DeleteLabelFromPack: authenticatedUser(jwtKey, svc, makeDeleteLabelFromPackEndpoint(svc)), SearchTargets: authenticatedUser(jwtKey, svc, makeSearchTargetsEndpoint(svc)), + GetOptions: authenticatedUser(jwtKey, svc, mustBeAdmin(makeGetOptionsEndpoint(svc))), + ModifyOptions: authenticatedUser(jwtKey, svc, mustBeAdmin(makeModifyOptionsEndpoint(svc))), // Osquery endpoints EnrollAgent: makeEnrollAgentEndpoint(svc), @@ -190,6 +194,8 @@ type kolideHandlers struct { DeleteHost http.Handler ListHosts http.Handler SearchTargets http.Handler + GetOptions http.Handler + ModifyOptions http.Handler } func makeKolideKitHandlers(ctx context.Context, e KolideEndpoints, opts []kithttp.ServerOption) *kolideHandlers { @@ -249,6 +255,8 @@ func makeKolideKitHandlers(ctx context.Context, e KolideEndpoints, opts []kithtt DeleteHost: newServer(e.DeleteHost, decodeDeleteHostRequest), ListHosts: newServer(e.ListHosts, decodeListHostsRequest), SearchTargets: newServer(e.SearchTargets, decodeSearchTargetsRequest), + GetOptions: newServer(e.GetOptions, decodeNoParamsRequest), + ModifyOptions: newServer(e.ModifyOptions, decodeModifyOptionsRequest), } } @@ -343,6 +351,9 @@ func attachKolideAPIRoutes(r *mux.Router, h *kolideHandlers) { r.Handle("/api/v1/kolide/hosts/{id}", h.GetHost).Methods("GET").Name("get_host") r.Handle("/api/v1/kolide/hosts/{id}", h.DeleteHost).Methods("DELETE").Name("delete_host") + r.Handle("/api/v1/kolide/options", h.GetOptions).Methods("GET").Name("get_options") + r.Handle("/api/v1/kolide/options", h.ModifyOptions).Methods("PATCH").Name("modify_options") + r.Handle("/api/v1/kolide/targets", h.SearchTargets).Methods("POST").Name("search_targets") r.Handle("/api/v1/osquery/enroll", h.EnrollAgent).Methods("POST").Name("enroll_agent") diff --git a/server/service/service_options.go b/server/service/service_options.go new file mode 100644 index 0000000000..8eecccae4b --- /dev/null +++ b/server/service/service_options.go @@ -0,0 +1,22 @@ +package service + +import ( + "github.com/kolide/kolide-ose/server/kolide" + "github.com/pkg/errors" + "golang.org/x/net/context" +) + +func (svc service) GetOptions(ctx context.Context) ([]kolide.Option, error) { + opts, err := svc.ds.ListOptions() + if err != nil { + return nil, errors.Wrap(err, "options service") + } + return opts, nil +} + +func (svc service) ModifyOptions(ctx context.Context, req kolide.OptionRequest) ([]kolide.Option, error) { + if err := svc.ds.SaveOptions(req.Options); err != nil { + return nil, errors.Wrap(err, "modify options service") + } + return req.Options, nil +} diff --git a/server/service/transport_options.go b/server/service/transport_options.go new file mode 100644 index 0000000000..d418c33947 --- /dev/null +++ b/server/service/transport_options.go @@ -0,0 +1,17 @@ +package service + +import ( + "encoding/json" + "net/http" + + "github.com/kolide/kolide-ose/server/kolide" + "golang.org/x/net/context" +) + +func decodeModifyOptionsRequest(ctx context.Context, r *http.Request) (interface{}, error) { + var req kolide.OptionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return nil, err + } + return req, nil +} diff --git a/server/service/validation_options.go b/server/service/validation_options.go new file mode 100644 index 0000000000..e81af29233 --- /dev/null +++ b/server/service/validation_options.go @@ -0,0 +1,57 @@ +package service + +import ( + "fmt" + + "github.com/kolide/kolide-ose/server/kolide" + "golang.org/x/net/context" +) + +func (mw validationMiddleware) ModifyOptions(ctx context.Context, req kolide.OptionRequest) ([]kolide.Option, error) { + invalid := &invalidArgumentError{} + for _, opt := range req.Options { + if opt.ReadOnly { + invalid.Append(opt.Name, "readonly option") + continue + } + if err := validateValueMapsToOptionType(opt); err != nil { + invalid.Append(opt.Name, err.Error()) + } + } + if invalid.HasErrors() { + return nil, invalid + } + return mw.Service.ModifyOptions(ctx, req) +} + +var ( + errTypeMismatch = fmt.Errorf("type mismatch") + errInvalidType = fmt.Errorf("invalid option type") +) + +func validateValueMapsToOptionType(opt kolide.Option) error { + if !opt.OptionSet() { + return nil + } + val := opt.GetValue() + switch opt.Type { + case kolide.OptionTypeBool: + _, ok := val.(bool) + if !ok { + return errTypeMismatch + } + case kolide.OptionTypeString: + _, ok := val.(string) + if !ok { + return errTypeMismatch + } + case kolide.OptionTypeInt: + _, ok := val.(float64) // JSON unmarshaler represents all numbers in float64 + if !ok { + return errTypeMismatch + } + default: + return errInvalidType + } + return nil +}