diff --git a/cmd/client/api/api.go b/cmd/client/api/api.go index 1cf3359..264aee6 100644 --- a/cmd/client/api/api.go +++ b/cmd/client/api/api.go @@ -65,4 +65,16 @@ const ( API_SETTINGS = "/api/settings" API_SETTINGS_SYSTEM = "/api/settings/system" + + API_USER_LIST = "/api/user" + API_USER_DELETE = "/api/user" + API_USER_PROFILE = "/api/user/profile" + + API_POLICY_LIST = "/api/policy" + API_POLICY_DELETE = "/api/policy" + + API_SERVICE_ACCOUNT_LIST = "/api/serviceaccount" + API_SERVICE_ACCOUNT_CREATE = "/api/serviceaccount/create" + API_SERVICE_ACCOUNT_UPDATE = "/api/serviceaccount/update" + API_SERVICE_ACCOUNT_DELETE = "/api/serviceaccount" ) diff --git a/cmd/client/api/delete.go b/cmd/client/api/delete.go index 8758575..9c4421b 100644 --- a/cmd/client/api/delete.go +++ b/cmd/client/api/delete.go @@ -1,6 +1,9 @@ package api -import "net/http" +import ( + "fmt" + "net/http" +) func (c *Client) DeleteGateway(items ...string) error { _, err := c.executeJson(API_GATEWAY_DELETE, http.MethodDelete, nil, nil, items, http.StatusOK) @@ -66,3 +69,27 @@ func (c *Client) DeleteBackup(items ...string) error { _, err := c.executeJson(API_BACKUP_DELETE, http.MethodDelete, nil, nil, items, http.StatusOK) return err } + +func (c *Client) DeleteUser(items ...string) error { + profile, err := c.GetProfile() + if err != nil { + return err + } + for _, id := range items { + if id == profile.ID { + return fmt.Errorf("cannot delete the current user %s", profile.Username) + } + } + _, err = c.executeJson(API_USER_DELETE, http.MethodDelete, nil, nil, items, http.StatusOK) + return err +} + +func (c *Client) DeletePolicy(items ...string) error { + _, err := c.executeJson(API_POLICY_DELETE, http.MethodDelete, nil, nil, items, http.StatusOK) + return err +} + +func (c *Client) DeleteServiceAccount(items ...string) error { + _, err := c.executeJson(API_SERVICE_ACCOUNT_DELETE, http.MethodDelete, nil, nil, items, http.StatusOK) + return err +} diff --git a/cmd/client/api/get.go b/cmd/client/api/get.go index 933fc7b..e820d67 100644 --- a/cmd/client/api/get.go +++ b/cmd/client/api/get.go @@ -72,3 +72,15 @@ func (c *Client) ListForwardPayload(queryParams map[string]interface{}) (*storag func (c *Client) ListBackup(queryParams map[string]interface{}) (*storageTY.Result, error) { return c.listResource(API_BACKUP_LIST, queryParams) } + +func (c *Client) ListUser(queryParams map[string]interface{}) (*storageTY.Result, error) { + return c.listResource(API_USER_LIST, queryParams) +} + +func (c *Client) ListPolicy(queryParams map[string]interface{}) (*storageTY.Result, error) { + return c.listResource(API_POLICY_LIST, queryParams) +} + +func (c *Client) ListServiceAccount(queryParams map[string]interface{}) (*storageTY.Result, error) { + return c.listResource(API_SERVICE_ACCOUNT_LIST, queryParams) +} diff --git a/cmd/client/api/login.go b/cmd/client/api/login.go index a025ca7..68d54a9 100644 --- a/cmd/client/api/login.go +++ b/cmd/client/api/login.go @@ -9,10 +9,10 @@ import ( func (c *Client) Login(username, password, token, expiresIn string) (*handlerTY.JwtTokenResponse, error) { req := &handlerTY.UserLogin{ - Username: username, - Password: password, - SvcToken: token, - ExpiresIn: expiresIn, + Username: username, + Password: password, + ServiceAccountToken: token, + ExpiresIn: expiresIn, } res, err := c.executeJson(API_LOGIN, http.MethodPost, nil, nil, req, http.StatusOK) if err != nil { diff --git a/cmd/client/api/resource.go b/cmd/client/api/resource.go index acc1130..7b7aad3 100644 --- a/cmd/client/api/resource.go +++ b/cmd/client/api/resource.go @@ -217,18 +217,29 @@ func (c *Client) getByID(api, id string, dest interface{}) (bool, error) { } func (c *Client) findResource(api string, filters []storageTY.Filter, dest interface{}) (bool, error) { + items, err := c.findResources(api, filters, 1) + if err != nil || len(items) == 0 { + return false, err + } + if err := utils.MapToStruct(utils.TagNameJSON, items[0], dest); err != nil { + return false, err + } + return true, nil +} + +func (c *Client) findResources(api string, filters []storageTY.Filter, limit uint64) ([]map[string]interface{}, error) { if len(filters) == 0 { - return false, nil + return nil, nil } - queryParams, err := listQueryParams(filters, 1) + queryParams, err := listQueryParams(filters, limit) if err != nil { - return false, err + return nil, err } result, err := c.listResource(api, queryParams) if err != nil { - return false, err + return nil, err } - return decodeFirst(result, dest) + return decodeItems(result) } func listQueryParams(filters []storageTY.Filter, limit uint64) (map[string]interface{}, error) { @@ -243,25 +254,23 @@ func listQueryParams(filters []storageTY.Filter, limit uint64) (map[string]inter }, nil } -func decodeFirst(result *storageTY.Result, dest interface{}) (bool, error) { +func decodeItems(result *storageTY.Result) ([]map[string]interface{}, error) { if result == nil || result.Data == nil { - return false, nil - } - items, ok := result.Data.([]interface{}) - if !ok { - return false, fmt.Errorf("invalid response type:%T", result.Data) - } - if len(items) == 0 { - return false, nil + return nil, nil } - data, ok := items[0].(map[string]interface{}) + raw, ok := result.Data.([]interface{}) if !ok { - return false, fmt.Errorf("invalid item type:%T", items[0]) + return nil, fmt.Errorf("invalid response type:%T", result.Data) } - if err := utils.MapToStruct(utils.TagNameJSON, data, dest); err != nil { - return false, err + items := make([]map[string]interface{}, 0, len(raw)) + for _, item := range raw { + data, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid item type:%T", item) + } + items = append(items, data) } - return true, nil + return items, nil } func idFilters(id string) []storageTY.Filter { diff --git a/cmd/client/api/service_account.go b/cmd/client/api/service_account.go new file mode 100644 index 0000000..030458d --- /dev/null +++ b/cmd/client/api/service_account.go @@ -0,0 +1,131 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "github.com/mycontroller-org/server/v2/pkg/json" + "github.com/mycontroller-org/server/v2/pkg/types" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" + "github.com/mycontroller-org/server/v2/pkg/utils" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" +) + +func (c *Client) CreateServiceAccount(account *svcAccountTY.ServiceAccount) (*svcAccountTY.CreateAccountResponse, error) { + res, err := c.executeJson(API_SERVICE_ACCOUNT_CREATE, http.MethodPost, nil, nil, account, http.StatusOK) + if err != nil { + return nil, err + } + created := &svcAccountTY.CreateAccountResponse{} + if err := json.Unmarshal(res.Body, created); err != nil { + return nil, err + } + return created, nil +} + +func (c *Client) UpdateServiceAccount(account *svcAccountTY.ServiceAccount) error { + _, err := c.executeJson(API_SERVICE_ACCOUNT_UPDATE, http.MethodPost, nil, nil, account, http.StatusOK) + return err +} + +func (c *Client) FindServiceAccount(id, name, userRef string) (*svcAccountTY.ServiceAccount, error) { + if id != "" { + item := &svcAccountTY.ServiceAccount{} + found, err := c.findResource(API_SERVICE_ACCOUNT_LIST, idFilters(id), item) + if err != nil { + return nil, err + } + if found { + if userRef != "" && !serviceAccountMatchesUser(item, c.resolveUserRef(userRef), userRef) { + return nil, nil + } + return item, nil + } + } + if name == "" { + return nil, nil + } + items, err := c.FindServiceAccounts(name, userRef) + if err != nil { + return nil, err + } + switch len(items) { + case 0: + return nil, nil + case 1: + return &items[0], nil + default: + users := make([]string, 0, len(items)) + for _, item := range items { + label := item.Username + if label == "" { + label = item.UserID + } + users = append(users, label) + } + return nil, fmt.Errorf("multiple service accounts named %s (users: %s); specify --user", name, strings.Join(users, ", ")) + } +} + +func (c *Client) FindServiceAccounts(name, userRef string) ([]svcAccountTY.ServiceAccount, error) { + filters := []storageTY.Filter{equalFilter(types.KeyName, name)} + if userID := c.resolveUserRef(userRef); userID != "" { + filters = append(filters, equalFilter(types.KeyUserID, userID)) + } + raw, err := c.findResources(API_SERVICE_ACCOUNT_LIST, filters, 1000) + if err != nil { + return nil, err + } + items := make([]svcAccountTY.ServiceAccount, 0, len(raw)) + for _, data := range raw { + item := svcAccountTY.ServiceAccount{} + if err := utils.MapToStruct(utils.TagNameJSON, data, &item); err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + +func serviceAccountMatchesUser(item *svcAccountTY.ServiceAccount, userID, userRef string) bool { + if item == nil { + return false + } + if userID != "" && (item.UserID == userID || strings.EqualFold(item.Username, userRef)) { + return true + } + return strings.EqualFold(item.Username, userRef) || item.UserID == userRef +} + +func (c *Client) resolveUserRef(userRef string) string { + userRef = strings.TrimSpace(userRef) + if userRef == "" { + return "" + } + user, err := c.FindUser(userRef, userRef) + if err != nil || user == nil { + return userRef + } + return user.ID +} + +func (c *Client) ResolveServiceAccountIDs(selectors []string, userRef string) ([]string, error) { + ids := make([]string, 0, len(selectors)) + missing := make([]string, 0) + for _, selector := range selectors { + item, err := c.FindServiceAccount(selector, selector, userRef) + if err != nil { + return ids, err + } + if item == nil { + missing = append(missing, selector) + continue + } + ids = append(ids, item.ID) + } + if len(missing) > 0 { + return ids, fmt.Errorf("service-account(s) not present: %s", strings.Join(missing, ", ")) + } + return ids, nil +} diff --git a/cmd/client/api/user_policy.go b/cmd/client/api/user_policy.go new file mode 100644 index 0000000..fedb61a --- /dev/null +++ b/cmd/client/api/user_policy.go @@ -0,0 +1,151 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "github.com/mycontroller-org/server/v2/pkg/json" + "github.com/mycontroller-org/server/v2/pkg/types" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" +) + +func (c *Client) GetProfile() (*userTY.User, error) { + res, err := c.executeJson(API_USER_PROFILE, http.MethodGet, nil, nil, nil, http.StatusOK) + if err != nil { + return nil, err + } + item := &userTY.User{} + if err := json.Unmarshal(res.Body, item); err != nil { + return nil, err + } + return item, nil +} + +func (c *Client) SaveUser(update *userTY.UserAdminUpdate) error { + return c.saveResource(API_USER_LIST, update) +} + +func (c *Client) SavePolicy(policy *policyTY.Policy) error { + return c.saveResource(API_POLICY_LIST, policy) +} + +func (c *Client) FindUser(id, username string) (*userTY.User, error) { + item := &userTY.User{} + found, err := c.findResource(API_USER_LIST, idFilters(id), item) + if err != nil { + return nil, err + } + if found { + return item, nil + } + if username == "" { + return nil, nil + } + item = &userTY.User{} + found, err = c.findResource(API_USER_LIST, []storageTY.Filter{ + equalFilter(types.KeyUsername, username), + }, item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) FindPolicy(id string) (*policyTY.Policy, error) { + item := &policyTY.Policy{} + found, err := c.findResource(API_POLICY_LIST, idFilters(id), item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +func (c *Client) EnableUser(selectors ...string) error { + return c.setUsersDisabled(selectors, false) +} + +func (c *Client) DisableUser(selectors ...string) error { + return c.setUsersDisabled(selectors, true) +} + +func (c *Client) setUsersDisabled(selectors []string, disabled bool) error { + var current *userTY.User + if disabled { + profile, err := c.GetProfile() + if err != nil { + return err + } + current = profile + } + var firstErr error + updated := 0 + for _, selector := range selectors { + user, err := c.FindUser(selector, selector) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if user == nil { + if firstErr == nil { + firstErr = fmt.Errorf("user %s is not present", selector) + } else { + firstErr = fmt.Errorf("%w; user %s is not present", firstErr, selector) + } + continue + } + if disabled && current != nil && (user.ID == current.ID || strings.EqualFold(user.Username, current.Username)) { + err := fmt.Errorf("cannot disable the current user %s", user.Username) + if firstErr == nil { + firstErr = err + } else { + firstErr = fmt.Errorf("%w; %s", firstErr, err) + } + continue + } + flag := disabled + if err := c.SaveUser(&userTY.UserAdminUpdate{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + FullName: user.FullName, + Disabled: &flag, + Policies: user.Policies, + Labels: user.Labels, + }); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + updated++ + } + if updated == 0 && firstErr != nil { + return firstErr + } + return firstErr +} + +func (c *Client) ResolveUserIDs(selectors []string) ([]string, error) { + ids := make([]string, 0, len(selectors)) + missing := make([]string, 0) + for _, selector := range selectors { + item, err := c.FindUser(selector, selector) + if err != nil { + return ids, err + } + if item == nil { + missing = append(missing, selector) + continue + } + ids = append(ids, item.ID) + } + if len(missing) > 0 { + return ids, fmt.Errorf("user(s) not present: %s", strings.Join(missing, ", ")) + } + return ids, nil +} diff --git a/cmd/client/command/add/cmd.go b/cmd/client/command/add/cmd.go new file mode 100644 index 0000000..6384b27 --- /dev/null +++ b/cmd/client/command/add/cmd.go @@ -0,0 +1,24 @@ +package add + +import ( + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.Cmd.AddCommand(addCmd) + addCmd.AddCommand(serviceAccountAddCmd) + addCmd.AddCommand(userAddCmd) +} + +var addCmd = &cobra.Command{ + Use: "add", + Aliases: []string{"create"}, + Short: "Adds resources", + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, +} diff --git a/cmd/client/command/add/service_account_cmd.go b/cmd/client/command/add/service_account_cmd.go new file mode 100644 index 0000000..f65826b --- /dev/null +++ b/cmd/client/command/add/service_account_cmd.go @@ -0,0 +1,126 @@ +package add + +import ( + "fmt" + "strings" + + "github.com/mycontroller-org/server/v2/cmd/client/command/common" + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + dateTimeTY "github.com/mycontroller-org/server/v2/pkg/types/cusom_datetime" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" + "github.com/spf13/cobra" +) + +var ( + saUser string + saDescription string + saNeverExpire bool + saExpiresOn string + saEffect string + saActions []string + saResources []string +) + +var serviceAccountAddCmd = &cobra.Command{ + Use: "service-account ", + Aliases: []string{"service-accounts", "sa"}, + Short: "Adds a service account and prints the token once", + Example: ` myc add service-account ci-bot + myc add sa mobile --user alice --description "phone login" + myc add sa ci-bot --action get --action list --resource "node:*" + myc add sa limited --effect Deny --action "*" --resource settings + myc add sa temp --expires-on 2027-12-31`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + if err := addServiceAccount(args[0], args[1]); err != nil { + return fmt.Errorf("error:%s", err) + } + return nil + }, +} + +func init() { + serviceAccountAddCmd.Flags().StringVarP(&saUser, "user", "u", "", "username or user id (defaults to the logged-in user)") + serviceAccountAddCmd.Flags().StringVarP(&saDescription, "description", "d", "", "description") + serviceAccountAddCmd.Flags().BoolVar(&saNeverExpire, "never-expire", true, "token never expires") + serviceAccountAddCmd.Flags().StringVar(&saExpiresOn, "expires-on", "", "expiry date (YYYY-MM-DD); turns off never-expire") + serviceAccountAddCmd.Flags().StringVar(&saEffect, "effect", policyTY.EffectAllow, "statement effect: Allow or Deny") + serviceAccountAddCmd.Flags().StringArrayVar(&saActions, "action", nil, "statement action (repeatable; requires --resource)") + serviceAccountAddCmd.Flags().StringArrayVar(&saResources, "resource", nil, "statement resource (repeatable; requires --action)") +} + +func addServiceAccount(alias, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("name is required") + } + client := rootCmd.MustClient(alias) + + userRef := strings.TrimSpace(saUser) + if userRef == "" { + profile, err := client.GetProfile() + if err != nil { + return err + } + userRef = profile.ID + } + existing, err := client.FindServiceAccounts(name, userRef) + if err != nil { + return err + } + if len(existing) > 0 { + return fmt.Errorf("service-account %s is already present", name) + } + + account := &svcAccountTY.ServiceAccount{ + Name: name, + Username: strings.TrimSpace(saUser), + Description: saDescription, + NeverExpire: saNeverExpire, + } + if saExpiresOn != "" { + expires := dateTimeTY.CustomDate{} + if err := expires.Unmarshal(saExpiresOn); err != nil { + return fmt.Errorf("expires-on must be YYYY-MM-DD: %w", err) + } + account.NeverExpire = false + account.ExpiresOn = expires + } else if !account.NeverExpire { + return fmt.Errorf("--expires-on is required when never-expire is false") + } + + statements, provided, err := common.ParseOptionalStatement(saEffect, saActions, saResources) + if err != nil { + return err + } + if provided { + account.Statements = statements + } + + created, err := client.CreateServiceAccount(account) + if err != nil { + return err + } + if created == nil || created.Token == "" { + return fmt.Errorf("service account created, but token was not returned") + } + + out := rootCmd.IOStreams.Out + _, _ = fmt.Fprintf(out, "service-account: %s\n", tableName(account)) + _, _ = fmt.Fprintln(out, "Save this token now. It will not be shown again.") + _, _ = fmt.Fprintln(out, created.Token) + return nil +} + +func tableName(account *svcAccountTY.ServiceAccount) string { + if account.Username != "" { + return account.Username + "." + account.Name + } + return account.Name +} diff --git a/cmd/client/command/add/user_cmd.go b/cmd/client/command/add/user_cmd.go new file mode 100644 index 0000000..9d29f7f --- /dev/null +++ b/cmd/client/command/add/user_cmd.go @@ -0,0 +1,85 @@ +package add + +import ( + "fmt" + "strings" + + "github.com/mycontroller-org/server/v2/cmd/client/command/common" + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" + "github.com/spf13/cobra" +) + +var ( + userPassword string + userEmail string + userFullName string + userPolicies []string +) + +var userAddCmd = &cobra.Command{ + Use: "user ", + Aliases: []string{"users"}, + Short: "Adds a user", + Example: ` myc add user alice --password secret + myc add user alice --email alice@example.com --full-name Alice --policy readonly + myc add user alice`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + if err := addUser(args[0], args[1]); err != nil { + return fmt.Errorf("error:%s", err) + } + return nil + }, +} + +func init() { + userAddCmd.Flags().StringVarP(&userPassword, "password", "p", "", "password (prompted if omitted)") + userAddCmd.Flags().StringVar(&userEmail, "email", "", "email") + userAddCmd.Flags().StringVar(&userFullName, "full-name", "", "full name") + userAddCmd.Flags().StringArrayVar(&userPolicies, "policy", nil, "policy id to attach (repeatable)") +} + +func addUser(alias, username string) error { + username = strings.TrimSpace(username) + if username == "" { + return fmt.Errorf("username is required") + } + client := rootCmd.MustClient(alias) + + existing, err := client.FindUser("", username) + if err != nil { + return err + } + if existing != nil { + return fmt.Errorf("user %s is already present", username) + } + + password := userPassword + if strings.TrimSpace(password) == "" { + password, err = common.PromptPassword() + if err != nil { + return err + } + } + if strings.TrimSpace(password) == "" { + return fmt.Errorf("password is required") + } + + if err := client.SaveUser(&userTY.UserAdminUpdate{ + Username: username, + Password: password, + Email: userEmail, + FullName: userFullName, + Policies: userPolicies, + }); err != nil { + return err + } + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "user: %s\n", username) + return nil +} diff --git a/cmd/client/command/alias/cmd.go b/cmd/client/command/alias/cmd.go index b48e969..c5b91e9 100644 --- a/cmd/client/command/alias/cmd.go +++ b/cmd/client/command/alias/cmd.go @@ -19,9 +19,11 @@ import ( var aliasNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) var reservedAliasNames = map[string]struct{}{ - "alias": {}, "apply": {}, "action": {}, "completion": {}, "delete": {}, + "alias": {}, "add": {}, "create": {}, "apply": {}, "action": {}, "completion": {}, "delete": {}, "disable": {}, "enable": {}, "get": {}, "help": {}, "reboot": {}, - "reload": {}, "server": {}, "set": {}, "upload": {}, "myc": {}, + "reload": {}, "server": {}, "set": {}, "update": {}, "upload": {}, "user": {}, "policy": {}, + "service-account": {}, "service-accounts": {}, "sa": {}, + "myc": {}, } var ( @@ -40,7 +42,7 @@ func init() { aliasSetCmd.Flags().StringVarP(&aliasUsername, "username", "u", "", "username to login") aliasSetCmd.Flags().StringVarP(&aliasPassword, "password", "p", "", "password to login") - aliasSetCmd.Flags().StringVarP(&aliasToken, "token", "t", "", "service token to login") + aliasSetCmd.Flags().StringVarP(&aliasToken, "token", "t", "", "service account token to login") aliasSetCmd.Flags().StringVar(&aliasExpiresIn, "expires-in", "720h", "session expires in") aliasSetCmd.Flags().BoolVar(&aliasInsecure, "insecure", false, "skip TLS certificate verification") } diff --git a/cmd/client/command/alias/cmd_test.go b/cmd/client/command/alias/cmd_test.go index eb8b084..4dfe961 100644 --- a/cmd/client/command/alias/cmd_test.go +++ b/cmd/client/command/alias/cmd_test.go @@ -17,4 +17,7 @@ func TestValidateAliasName(t *testing.T) { require.Error(t, validateAliasName("get")) require.Error(t, validateAliasName("GET")) assert.Contains(t, validateAliasName("apply").Error(), "reserved") + assert.Contains(t, validateAliasName("add").Error(), "reserved") + assert.Contains(t, validateAliasName("create").Error(), "reserved") + assert.Contains(t, validateAliasName("update").Error(), "reserved") } diff --git a/cmd/client/command/apply/apply.go b/cmd/client/command/apply/apply.go index 7256865..64d5f22 100644 --- a/cmd/client/command/apply/apply.go +++ b/cmd/client/command/apply/apply.go @@ -10,7 +10,10 @@ import ( fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" "github.com/mycontroller-org/server/v2/pkg/utils" gwTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" "github.com/olekukonko/tablewriter" @@ -35,21 +38,35 @@ type ResourceClient interface { FindField(id, gatewayID, nodeID, sourceID, fieldID string) (idFound string, err error) FindFirmware(id string) (idFound string, err error) FindDataRepository(id string) (idFound string, err error) + FindUser(id, username string) (idFound string, err error) + FindPolicy(id string) (idFound string, err error) + FindServiceAccount(id, name, userRef string) (idFound string, err error) SaveGateway(resource Resource) error SaveNode(resource Resource) error SaveSource(resource Resource) error SaveField(resource Resource) error SaveFirmware(resource Resource) error SaveDataRepository(resource Resource) error + SaveUser(resource Resource) error + SavePolicy(resource Resource) error + SaveServiceAccount(resource Resource) (token string, err error) DeleteGateway(ids ...string) error DeleteNode(ids ...string) error DeleteSource(ids ...string) error DeleteField(ids ...string) error DeleteFirmware(ids ...string) error DeleteDataRepository(ids ...string) error + DeleteUser(ids ...string) error + DeletePolicy(ids ...string) error + DeleteServiceAccount(ids ...string) error GetExisting(resource Resource) ([]byte, error) } +type createdToken struct { + resource string + token string +} + type plannedAction struct { Resource Resource Action string @@ -90,6 +107,7 @@ func Apply(client ResourceClient, resources []Resource, replace, dryRun bool, ou } rows := make([]applyRow, 0, len(plans)) + tokens := make([]createdToken, 0) var applyErr error executed := newPendingParents() for _, plan := range plans { @@ -97,8 +115,10 @@ func Apply(client ResourceClient, resources []Resource, replace, dryRun bool, ou if plan.Err == nil && plan.Action != actionNotAvailable && !dryRun { if err := checkExecutedParent(plan.Resource, executed); err != nil { execErr = err - } else if err := executePlan(client, plan); err != nil { + } else if token, err := executePlan(client, plan); err != nil { execErr = err + } else if token != "" { + tokens = append(tokens, createdToken{resource: plan.Resource.TableResource(), token: token}) } switch plan.Action { case actionAdd, actionMerge, actionReplace: @@ -118,6 +138,7 @@ func Apply(client ResourceClient, resources []Resource, replace, dryRun bool, ou } printApplyTable(out, rows) + printCreatedTokens(out, tokens) if planFailed || applyErr != nil { return ErrApplyFailed @@ -180,6 +201,17 @@ func printApplyTable(out io.Writer, rows []applyRow) { table.Render() } +func printCreatedTokens(out io.Writer, tokens []createdToken) { + if len(tokens) == 0 { + return + } + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintln(out, "Save these tokens now. They will not be shown again.") + for _, item := range tokens { + _, _ = fmt.Fprintf(out, "\n %s\n %s\n", item.resource, item.token) + } +} + func planResource(client ResourceClient, resource Resource, replace bool, pending *pendingParents) plannedAction { existingID, err := findExisting(client, resource) if err != nil { @@ -412,6 +444,15 @@ func decodeMerged(resource *Resource, merged []byte) error { case KindDataRepository: resource.DataRepository = &dataRepoTY.Config{} return json.Unmarshal(merged, resource.DataRepository) + case KindUser: + resource.User = &userTY.User{} + return json.Unmarshal(merged, resource.User) + case KindPolicy: + resource.Policy = &policyTY.Policy{} + return json.Unmarshal(merged, resource.Policy) + case KindServiceAccount: + resource.ServiceAccount = &svcAccountTY.ServiceAccount{} + return json.Unmarshal(merged, resource.ServiceAccount) default: return fmt.Errorf("unsupported kind %q", resource.Kind) } @@ -423,31 +464,32 @@ func assignSaveID(resource *Resource) { if resource.ID() != "" { return } - if resource.Kind == KindField || resource.Kind == KindGateway || resource.Kind == KindFirmware || resource.Kind == KindDataRepository { + if resource.Kind == KindField || resource.Kind == KindGateway || resource.Kind == KindFirmware || resource.Kind == KindDataRepository || resource.Kind == KindUser || resource.Kind == KindServiceAccount { return } resource.SetID(utils.RandUUID()) } -func executePlan(client ResourceClient, plan plannedAction) error { +func executePlan(client ResourceClient, plan plannedAction) (string, error) { switch plan.Action { case actionAdd, actionMerge: return saveResource(client, plan.Resource) case actionReplace: if err := deleteResource(client, plan.Resource, plan.ExistingID); err != nil { - return fmt.Errorf("replace delete failed: %w", err) + return "", fmt.Errorf("replace delete failed: %w", err) } - if err := saveResource(client, plan.Resource); err != nil { + token, err := saveResource(client, plan.Resource) + if err != nil { if plan.Resource.Kind == KindFirmware { - return fmt.Errorf("deleted existing resource, but recreate failed: %w (upload the firmware binary again)", err) + return "", fmt.Errorf("deleted existing resource, but recreate failed: %w (upload the firmware binary again)", err) } - return fmt.Errorf("deleted existing resource, but recreate failed: %w", err) + return "", fmt.Errorf("deleted existing resource, but recreate failed: %w", err) } - return nil + return token, nil case actionDelete: - return deleteResource(client, plan.Resource, plan.ExistingID) + return "", deleteResource(client, plan.Resource, plan.ExistingID) default: - return fmt.Errorf("unknown action %q", plan.Action) + return "", fmt.Errorf("unknown action %q", plan.Action) } } @@ -461,6 +503,12 @@ func findExisting(client ResourceClient, resource Resource) (string, error) { return client.FindFirmware(id) case KindDataRepository: return client.FindDataRepository(id) + case KindUser: + return client.FindUser(id, gatewayID) + case KindPolicy: + return client.FindPolicy(id) + case KindServiceAccount: + return client.FindServiceAccount(id, nodeID, gatewayID) case KindNode: return client.FindNode(id, gatewayID, nodeID) case KindSource: @@ -472,23 +520,31 @@ func findExisting(client ResourceClient, resource Resource) (string, error) { } } -func saveResource(client ResourceClient, resource Resource) error { +func saveResource(client ResourceClient, resource Resource) (string, error) { + var err error switch resource.Kind { case KindGateway: - return client.SaveGateway(resource) + err = client.SaveGateway(resource) case KindFirmware: - return client.SaveFirmware(resource) + err = client.SaveFirmware(resource) case KindDataRepository: - return client.SaveDataRepository(resource) + err = client.SaveDataRepository(resource) + case KindUser: + err = client.SaveUser(resource) + case KindPolicy: + err = client.SavePolicy(resource) + case KindServiceAccount: + return client.SaveServiceAccount(resource) case KindNode: - return client.SaveNode(resource) + err = client.SaveNode(resource) case KindSource: - return client.SaveSource(resource) + err = client.SaveSource(resource) case KindField: - return client.SaveField(resource) + err = client.SaveField(resource) default: - return fmt.Errorf("unsupported kind %q", resource.Kind) + return "", fmt.Errorf("unsupported kind %q", resource.Kind) } + return "", err } func deleteResource(client ResourceClient, resource Resource, existingID string) error { @@ -506,6 +562,12 @@ func deleteResource(client ResourceClient, resource Resource, existingID string) return client.DeleteFirmware(id) case KindDataRepository: return client.DeleteDataRepository(id) + case KindUser: + return client.DeleteUser(id) + case KindPolicy: + return client.DeletePolicy(id) + case KindServiceAccount: + return client.DeleteServiceAccount(id) case KindNode: return client.DeleteNode(id) case KindSource: diff --git a/cmd/client/command/apply/apply_test.go b/cmd/client/command/apply/apply_test.go index 7e0bb46..845ab44 100644 --- a/cmd/client/command/apply/apply_test.go +++ b/cmd/client/command/apply/apply_test.go @@ -10,6 +10,7 @@ import ( fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" gwTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" "github.com/stretchr/testify/assert" @@ -63,6 +64,30 @@ func (f *fakeClient) FindFirmware(id string) (string, error) { func (f *fakeClient) FindDataRepository(id string) (string, error) { return f.find(KindDataRepository, id, "", "", "", "") } +func (f *fakeClient) FindUser(id, username string) (string, error) { + if id != "" { + if found, err := f.find(KindUser, id, "", "", "", ""); err != nil || found != "" { + return found, err + } + } + return f.find(KindUser, "", username, "", "", "") +} +func (f *fakeClient) FindPolicy(id string) (string, error) { + return f.find(KindPolicy, id, "", "", "", "") +} +func (f *fakeClient) FindServiceAccount(id, name, userRef string) (string, error) { + if id != "" { + if found, err := f.find(KindServiceAccount, id, "", "", "", ""); err != nil || found != "" { + return found, err + } + } + if userRef != "" { + if found, err := f.find(KindServiceAccount, "", userRef, name, "", ""); err != nil || found != "" { + return found, err + } + } + return f.find(KindServiceAccount, "", name, "", "", "") +} func (f *fakeClient) SaveGateway(resource Resource) error { if f.saveErr != nil { return f.saveErr @@ -105,6 +130,30 @@ func (f *fakeClient) SaveDataRepository(resource Resource) error { f.saved = append(f.saved, resource) return nil } +func (f *fakeClient) SaveUser(resource Resource) error { + if f.saveErr != nil { + return f.saveErr + } + f.saved = append(f.saved, resource) + return nil +} +func (f *fakeClient) SavePolicy(resource Resource) error { + if f.saveErr != nil { + return f.saveErr + } + f.saved = append(f.saved, resource) + return nil +} +func (f *fakeClient) SaveServiceAccount(resource Resource) (string, error) { + if f.saveErr != nil { + return "", f.saveErr + } + f.saved = append(f.saved, resource) + if resource.Operation == OperationMerge { + return "", nil + } + return "tok-once", nil +} func (f *fakeClient) DeleteGateway(ids ...string) error { f.deleted = append(f.deleted, ids...) return nil @@ -129,6 +178,18 @@ func (f *fakeClient) DeleteDataRepository(ids ...string) error { f.deleted = append(f.deleted, ids...) return nil } +func (f *fakeClient) DeleteUser(ids ...string) error { + f.deleted = append(f.deleted, ids...) + return nil +} +func (f *fakeClient) DeletePolicy(ids ...string) error { + f.deleted = append(f.deleted, ids...) + return nil +} +func (f *fakeClient) DeleteServiceAccount(ids ...string) error { + f.deleted = append(f.deleted, ids...) + return nil +} func (f *fakeClient) GetExisting(resource Resource) ([]byte, error) { id := resource.ID() if f.existingJSON != nil { @@ -672,6 +733,75 @@ func TestApplyReplaceFirmwareKeepsID(t *testing.T) { assertApplyRow(t, out.String(), "firmware: stm32-app", "add", "replaced") } +func testServiceAccount(operation, name, id string) Resource { + return Resource{ + Kind: KindServiceAccount, + Operation: operation, + ServiceAccount: &svcAccountTY.ServiceAccount{ + ID: id, + Name: name, + Description: "ci", + NeverExpire: true, + }, + Payload: map[string]interface{}{ + "name": name, + "description": "ci", + "neverExpire": true, + }, + } +} + +func TestApplyAddServiceAccountPrintsToken(t *testing.T) { + client := &fakeClient{} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testServiceAccount(OperationAdd, "ci-bot", "")}, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 1) + assert.Empty(t, client.saved[0].ServiceAccount.ID) + assertApplyRow(t, out.String(), "service-account: ci-bot", "add", "ok") + assert.Contains(t, out.String(), "Save these tokens now. They will not be shown again.") + assert.Contains(t, out.String(), "tok-once") +} + +func TestApplyMergeServiceAccountDoesNotPrintToken(t *testing.T) { + client := &fakeClient{existing: map[string]string{ + "service-account/ci-bot///": "sa-id", + }} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testServiceAccount(OperationMerge, "ci-bot", "")}, false, false, out, errOut) + require.NoError(t, err) + require.Len(t, client.saved, 1) + assert.Equal(t, "sa-id", client.saved[0].ServiceAccount.ID) + assertApplyRow(t, out.String(), "service-account: ci-bot", "merge", "ok") + assert.NotContains(t, out.String(), "tok-once") + assert.NotContains(t, out.String(), "Save these tokens now") +} + +func TestApplyReplaceServiceAccountKeepsIDAndPrintsToken(t *testing.T) { + client := &fakeClient{existing: map[string]string{ + "service-account/ci-bot///": "sa-id", + }} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testServiceAccount(OperationAdd, "ci-bot", "")}, true, false, out, errOut) + require.NoError(t, err) + assert.Equal(t, []string{"sa-id"}, client.deleted) + require.Len(t, client.saved, 1) + assert.Equal(t, "sa-id", client.saved[0].ServiceAccount.ID) + assertApplyRow(t, out.String(), "service-account: ci-bot", "add", "replaced") + assert.Contains(t, out.String(), "tok-once") +} + +func TestApplyDeleteServiceAccount(t *testing.T) { + client := &fakeClient{existing: map[string]string{ + "service-account/ci-bot///": "sa-id", + }} + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + err := Apply(client, []Resource{testServiceAccount(OperationDelete, "ci-bot", "")}, false, false, out, errOut) + require.NoError(t, err) + assert.Equal(t, []string{"sa-id"}, client.deleted) + assertApplyRow(t, out.String(), "service-account: ci-bot", "delete", "ok") +} + func TestApplyUpdateFailsWhenParentMissing(t *testing.T) { client := &fakeClient{existing: map[string]string{ "node/gw1/n1//": "node-id", diff --git a/cmd/client/command/apply/client.go b/cmd/client/command/apply/client.go index bca623d..f94e99c 100644 --- a/cmd/client/command/apply/client.go +++ b/cmd/client/command/apply/client.go @@ -5,6 +5,7 @@ import ( "github.com/mycontroller-org/server/v2/cmd/client/api" "github.com/mycontroller-org/server/v2/pkg/json" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" ) type apiResourceClient struct { @@ -63,6 +64,30 @@ func (c *apiResourceClient) FindDataRepository(id string) (string, error) { return item.ID, nil } +func (c *apiResourceClient) FindUser(id, username string) (string, error) { + item, err := c.client.FindUser(id, username) + if err != nil || item == nil { + return "", err + } + return item.ID, nil +} + +func (c *apiResourceClient) FindPolicy(id string) (string, error) { + item, err := c.client.FindPolicy(id) + if err != nil || item == nil { + return "", err + } + return item.ID, nil +} + +func (c *apiResourceClient) FindServiceAccount(id, name, userRef string) (string, error) { + item, err := c.client.FindServiceAccount(id, name, userRef) + if err != nil || item == nil { + return "", err + } + return item.ID, nil +} + func (c *apiResourceClient) SaveGateway(resource Resource) error { if resource.Gateway == nil { return nil @@ -105,6 +130,45 @@ func (c *apiResourceClient) SaveDataRepository(resource Resource) error { return c.client.SaveDataRepository(resource.DataRepository) } +func (c *apiResourceClient) SaveUser(resource Resource) error { + if resource.User == nil { + return nil + } + user := resource.User + disabled := user.Disabled + return c.client.SaveUser(&userTY.UserAdminUpdate{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + FullName: user.FullName, + Disabled: &disabled, + Policies: user.Policies, + Password: user.Password, + Labels: user.Labels, + }) +} + +func (c *apiResourceClient) SavePolicy(resource Resource) error { + if resource.Policy == nil { + return nil + } + return c.client.SavePolicy(resource.Policy) +} + +func (c *apiResourceClient) SaveServiceAccount(resource Resource) (string, error) { + if resource.ServiceAccount == nil { + return "", nil + } + if resource.Operation == OperationMerge { + return "", c.client.UpdateServiceAccount(resource.ServiceAccount) + } + created, err := c.client.CreateServiceAccount(resource.ServiceAccount) + if err != nil || created == nil { + return "", err + } + return created.Token, nil +} + func (c *apiResourceClient) DeleteGateway(ids ...string) error { return c.client.DeleteGateway(ids...) } @@ -129,6 +193,18 @@ func (c *apiResourceClient) DeleteDataRepository(ids ...string) error { return c.client.DeleteDataRepository(ids...) } +func (c *apiResourceClient) DeleteUser(ids ...string) error { + return c.client.DeleteUser(ids...) +} + +func (c *apiResourceClient) DeletePolicy(ids ...string) error { + return c.client.DeletePolicy(ids...) +} + +func (c *apiResourceClient) DeleteServiceAccount(ids ...string) error { + return c.client.DeleteServiceAccount(ids...) +} + func (c *apiResourceClient) GetExisting(resource Resource) ([]byte, error) { var item interface{} var err error @@ -148,6 +224,14 @@ func (c *apiResourceClient) GetExisting(resource Resource) ([]byte, error) { item, err = c.client.FindFirmware(resource.ID()) case KindDataRepository: item, err = c.client.FindDataRepository(resource.ID()) + case KindUser: + username, _, _, _ := resource.NaturalKeys() + item, err = c.client.FindUser(resource.ID(), username) + case KindPolicy: + item, err = c.client.FindPolicy(resource.ID()) + case KindServiceAccount: + userRef, name, _, _ := resource.NaturalKeys() + item, err = c.client.FindServiceAccount(resource.ID(), name, userRef) default: return nil, fmt.Errorf("unsupported kind %q", resource.Kind) } diff --git a/cmd/client/command/apply/cmd.go b/cmd/client/command/apply/cmd.go index 060a708..d5a87c6 100644 --- a/cmd/client/command/apply/cmd.go +++ b/cmd/client/command/apply/cmd.go @@ -29,10 +29,11 @@ var applyCmd = &cobra.Command{ Short: "Add, merge, or delete resources from a YAML or JSON file", SilenceUsage: true, SilenceErrors: true, - Long: `Apply gateways, nodes, sources, fields, firmware, and data repositories from a YAML or JSON file. + Long: `Apply gateways, nodes, sources, fields, firmware, data repositories, users, policies, and service accounts from a YAML or JSON file. -Each resource must include kind (gateway, node, source, field, firmware, data-repository) and operation (add, merge, delete). +Each resource must include kind (gateway, node, source, field, firmware, data-repository, user, policy, service-account) and operation (add, merge, delete). Firmware binary files are not part of apply; upload them with myc upload firmware. +Adding a service account prints the token once; save it immediately. Add fails when the resource already exists, unless --replace is set or the resource has replace: true. With replace, the existing resource is deleted and recreated with the same id diff --git a/cmd/client/command/apply/parse.go b/cmd/client/command/apply/parse.go index e1a4dbd..0e30ea7 100644 --- a/cmd/client/command/apply/parse.go +++ b/cmd/client/command/apply/parse.go @@ -11,7 +11,10 @@ import ( fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" firmwareTY "github.com/mycontroller-org/server/v2/pkg/types/firmware" nodeTY "github.com/mycontroller-org/server/v2/pkg/types/node" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" "github.com/mycontroller-org/server/v2/pkg/utils" gwTY "github.com/mycontroller-org/server/v2/plugin/gateway/types" "gopkg.in/yaml.v3" @@ -24,6 +27,9 @@ const ( KindField = "field" KindFirmware = "firmware" KindDataRepository = "data-repository" + KindUser = "user" + KindPolicy = "policy" + KindServiceAccount = "service-account" OperationAdd = "add" OperationMerge = "merge" @@ -43,6 +49,9 @@ type Resource struct { Field *fieldTY.Field Firmware *firmwareTY.Firmware DataRepository *dataRepoTY.Config + User *userTY.User + Policy *policyTY.Policy + ServiceAccount *svcAccountTY.ServiceAccount Payload map[string]interface{} } @@ -72,6 +81,18 @@ func (r Resource) ID() string { if r.DataRepository != nil { return r.DataRepository.ID } + case KindUser: + if r.User != nil { + return r.User.ID + } + case KindPolicy: + if r.Policy != nil { + return r.Policy.ID + } + case KindServiceAccount: + if r.ServiceAccount != nil { + return r.ServiceAccount.ID + } } return "" } @@ -102,6 +123,18 @@ func (r Resource) SetID(id string) { if r.DataRepository != nil { r.DataRepository.ID = id } + case KindUser: + if r.User != nil { + r.User.ID = id + } + case KindPolicy: + if r.Policy != nil { + r.Policy.ID = id + } + case KindServiceAccount: + if r.ServiceAccount != nil { + r.ServiceAccount.ID = id + } } } @@ -131,6 +164,22 @@ func (r Resource) NaturalKeys() (gatewayID, nodeID, sourceID, fieldID string) { if r.DataRepository != nil { return r.DataRepository.ID, "", "", "" } + case KindUser: + if r.User != nil { + return r.User.Username, "", "", "" + } + case KindPolicy: + if r.Policy != nil { + return r.Policy.ID, "", "", "" + } + case KindServiceAccount: + if r.ServiceAccount != nil { + userRef := r.ServiceAccount.Username + if userRef == "" { + userRef = r.ServiceAccount.UserID + } + return userRef, r.ServiceAccount.Name, "", "" + } } return "", "", "", "" } @@ -212,6 +261,24 @@ func (r Resource) Validate() error { if !hasID { return fmt.Errorf("data-repository requires id") } + case KindUser: + if r.Operation == OperationAdd && (gatewayID == "" || (r.User != nil && r.User.Password == "")) { + return fmt.Errorf("add user requires username and password") + } + if r.Operation != OperationAdd && !hasID && gatewayID == "" { + return fmt.Errorf("user requires id or username") + } + case KindPolicy: + if r.Operation != OperationDelete && !hasID && r.Operation != OperationAdd { + return fmt.Errorf("policy requires id") + } + case KindServiceAccount: + if r.Operation != OperationDelete && nodeID == "" { + return fmt.Errorf("service-account requires name") + } + if r.Operation == OperationDelete && !hasID && nodeID == "" { + return fmt.Errorf("delete service-account requires id or name") + } case KindNode: if r.Operation != OperationDelete && (gatewayID == "" || nodeID == "") { return fmt.Errorf("node requires gatewayId and nodeId") @@ -455,6 +522,24 @@ func resourceFromMap(doc map[string]interface{}, index int, source string) (Reso return Resource{}, fmt.Errorf("invalid data-repository: %w", err) } resource.DataRepository = item + case KindUser: + user := &userTY.User{} + if err := utils.MapToStruct(utils.TagNameJSON, payload, user); err != nil { + return Resource{}, fmt.Errorf("invalid user: %w", err) + } + resource.User = user + case KindPolicy: + policy := &policyTY.Policy{} + if err := utils.MapToStruct(utils.TagNameJSON, payload, policy); err != nil { + return Resource{}, fmt.Errorf("invalid policy: %w", err) + } + resource.Policy = policy + case KindServiceAccount: + account := &svcAccountTY.ServiceAccount{} + if err := utils.MapToStruct(utils.TagNameJSON, payload, account); err != nil { + return Resource{}, fmt.Errorf("invalid service-account: %w", err) + } + resource.ServiceAccount = account } if err := resource.Validate(); err != nil { @@ -484,10 +569,16 @@ func normalizeKind(kind string) (string, error) { return KindFirmware, nil case KindDataRepository, "datarepository", "data-repo", "data-repositories", "datarepo": return KindDataRepository, nil + case KindUser, "users": + return KindUser, nil + case KindPolicy, "policies": + return KindPolicy, nil + case KindServiceAccount, "service-accounts", "sa": + return KindServiceAccount, nil case "": return "", fmt.Errorf("kind is required") default: - return "", fmt.Errorf("unsupported kind %q (supported: gateway, node, source, field, firmware, data-repository)", kind) + return "", fmt.Errorf("unsupported kind %q (supported: gateway, node, source, field, firmware, data-repository, user, policy, service-account)", kind) } } diff --git a/cmd/client/command/apply/parse_test.go b/cmd/client/command/apply/parse_test.go index f294e36..18de9bf 100644 --- a/cmd/client/command/apply/parse_test.go +++ b/cmd/client/command/apply/parse_test.go @@ -60,6 +60,81 @@ data: assert.Equal(t, false, resources[1].DataRepository.Data["disabled"]) } +func TestParseResourcesUserAndPolicy(t *testing.T) { + data := []byte(` +kind: user +operation: add +username: alice +password: secret +email: alice@example.com +policies: + - admin +--- +kind: policy +operation: add +id: sensors-read +description: read sensors +statements: + - effect: Allow + actions: ["get", "list"] + resources: ["node:*", "field:*"] +`) + resources, err := ParseResources(data, "acl.yaml") + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, KindUser, resources[0].Kind) + assert.Equal(t, "alice", resources[0].User.Username) + assert.Equal(t, "secret", resources[0].User.Password) + assert.Equal(t, []string{"admin"}, resources[0].User.Policies) + assert.Equal(t, KindPolicy, resources[1].Kind) + assert.Equal(t, "sensors-read", resources[1].Policy.ID) + require.Len(t, resources[1].Policy.Statements, 1) + assert.Equal(t, "Allow", resources[1].Policy.Statements[0].Effect) +} + +func TestParseResourcesServiceAccount(t *testing.T) { + data := []byte(` +kind: sa +operation: add +name: ci-bot +description: CI automation +neverExpire: true +statements: + - effect: Allow + actions: ["get", "list"] + resources: ["node:*"] +`) + resources, err := ParseResources(data, "sa.yaml") + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, KindServiceAccount, resources[0].Kind) + require.NotNil(t, resources[0].ServiceAccount) + assert.Equal(t, "ci-bot", resources[0].ServiceAccount.Name) + assert.Equal(t, "CI automation", resources[0].ServiceAccount.Description) + assert.True(t, resources[0].ServiceAccount.NeverExpire) + require.Len(t, resources[0].ServiceAccount.Statements, 1) + assert.Equal(t, "Allow", resources[0].ServiceAccount.Statements[0].Effect) + assert.Equal(t, []string{"get", "list"}, resources[0].ServiceAccount.Statements[0].Actions) + assert.Equal(t, []string{"node:*"}, resources[0].ServiceAccount.Statements[0].Resources) + assert.Equal(t, "service-account: ci-bot", resources[0].TableResource()) +} + +func TestParseResourcesServiceAccountForUser(t *testing.T) { + data := []byte(` +kind: service-account +operation: add +name: mobile +username: alice +neverExpire: true +`) + resources, err := ParseResources(data, "sa.yaml") + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, "alice", resources[0].ServiceAccount.Username) + assert.Equal(t, "mobile", resources[0].ServiceAccount.Name) + assert.Equal(t, "service-account: alice.mobile", resources[0].TableResource()) +} + func TestParseResourcesYAMLSingle(t *testing.T) { data := []byte(` kind: node @@ -201,6 +276,11 @@ func TestParseResourcesValidation(t *testing.T) { input: "kind: data-repo\noperation: add\ndescription: repo\n", wantErr: "data-repository requires id", }, + { + name: "service-account missing name", + input: "kind: service-account\noperation: add\ndescription: ci\n", + wantErr: "service-account requires name", + }, { name: "unsupported operation", input: "kind: node\noperation: patch\ngatewayId: gw1\nnodeId: n1\n", diff --git a/cmd/client/command/common/statement.go b/cmd/client/command/common/statement.go new file mode 100644 index 0000000..f02cb5d --- /dev/null +++ b/cmd/client/command/common/statement.go @@ -0,0 +1,56 @@ +package common + +import ( + "fmt" + "os" + "strings" + + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + "golang.org/x/term" +) + +// ParseOptionalStatement returns one statement when actions or resources are set. +// Both must be provided together. If neither is set, provided is false. +func ParseOptionalStatement(effect string, actions, resources []string) (statements []policyTY.Statement, provided bool, err error) { + if len(actions) == 0 && len(resources) == 0 { + return nil, false, nil + } + if len(actions) == 0 || len(resources) == 0 { + return nil, false, fmt.Errorf("--action and --resource must be used together") + } + normalized, err := ParseEffect(effect) + if err != nil { + return nil, false, err + } + return []policyTY.Statement{{ + Effect: normalized, + Actions: actions, + Resources: resources, + }}, true, nil +} + +func ParseEffect(effect string) (string, error) { + effect = strings.TrimSpace(effect) + if effect == "" { + return policyTY.EffectAllow, nil + } + switch strings.ToLower(effect) { + case "allow": + return policyTY.EffectAllow, nil + case "deny": + return policyTY.EffectDeny, nil + default: + return "", fmt.Errorf("effect must be Allow or Deny") + } +} + +func PromptPassword() (string, error) { + _, _ = fmt.Fprint(rootCmd.IOStreams.Out, "Password: ") + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + _, _ = fmt.Fprintln(rootCmd.IOStreams.Out) + if err != nil { + return "", err + } + return string(pw), nil +} diff --git a/cmd/client/command/common/statement_test.go b/cmd/client/command/common/statement_test.go new file mode 100644 index 0000000..ab09139 --- /dev/null +++ b/cmd/client/command/common/statement_test.go @@ -0,0 +1,29 @@ +package common + +import ( + "testing" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseOptionalStatement(t *testing.T) { + _, provided, err := ParseOptionalStatement("", nil, nil) + require.NoError(t, err) + assert.False(t, provided) + + _, _, err = ParseOptionalStatement("", []string{"get"}, nil) + require.Error(t, err) + + _, _, err = ParseOptionalStatement("", nil, []string{"node:*"}) + require.Error(t, err) + + sts, provided, err := ParseOptionalStatement("deny", []string{"*"}, []string{"settings"}) + require.NoError(t, err) + require.True(t, provided) + require.Len(t, sts, 1) + assert.Equal(t, policyTY.EffectDeny, sts[0].Effect) + assert.Equal(t, []string{"*"}, sts[0].Actions) + assert.Equal(t, []string{"settings"}, sts[0].Resources) +} diff --git a/cmd/client/command/delete/delete_cmd.go b/cmd/client/command/delete/delete_cmd.go index 7a5dffe..14f1bb0 100644 --- a/cmd/client/command/delete/delete_cmd.go +++ b/cmd/client/command/delete/delete_cmd.go @@ -19,6 +19,10 @@ func init() { deleteCmd.AddCommand(handlerDeleteCmd) deleteCmd.AddCommand(forwardPayloadDeleteCmd) deleteCmd.AddCommand(backupDeleteCmd) + deleteCmd.AddCommand(userDeleteCmd) + deleteCmd.AddCommand(policyDeleteCmd) + deleteCmd.AddCommand(serviceAccountDeleteCmd) + serviceAccountDeleteCmd.Flags().StringVarP(&saDeleteUser, "user", "u", "", "username or user id when the account name is not unique") } var gwDeleteCmd = &cobra.Command{ @@ -215,3 +219,59 @@ var backupDeleteCmd = &cobra.Command{ printStatus(err) }, } + +var userDeleteCmd = &cobra.Command{ + Use: "user [...]", + Aliases: []string{"users"}, + Short: "Deletes the given users", + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + client, selectors := rootCmd.TakeAlias(args) + ids, err := client.ResolveUserIDs(selectors) + if err != nil { + printStatus(err) + return + } + printStatus(client.DeleteUser(ids...)) + }, +} + +var policyDeleteCmd = &cobra.Command{ + Use: "policy [...]", + Aliases: []string{"policies"}, + Short: "Deletes the given policies", + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + client, ids := rootCmd.TakeAlias(args) + printStatus(client.DeletePolicy(ids...)) + }, +} + +var saDeleteUser string + +var serviceAccountDeleteCmd = &cobra.Command{ + Use: "service-account [...]", + Aliases: []string{"service-accounts", "sa"}, + Short: "Deletes the given service accounts", + Example: ` myc delete service-account ci-bot + myc delete sa ci-bot --user alice`, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + client, selectors := rootCmd.TakeAlias(args) + ids, err := client.ResolveServiceAccountIDs(selectors, saDeleteUser) + if err != nil { + printStatus(err) + return + } + printStatus(client.DeleteServiceAccount(ids...)) + }, +} diff --git a/cmd/client/command/disable/disable_cmd.go b/cmd/client/command/disable/disable_cmd.go index 32bbf60..180b4d6 100644 --- a/cmd/client/command/disable/disable_cmd.go +++ b/cmd/client/command/disable/disable_cmd.go @@ -12,6 +12,7 @@ func init() { disableCmd.AddCommand(taskDisableCmd) disableCmd.AddCommand(scheduleDisableCmd) disableCmd.AddCommand(handlerDisableCmd) + disableCmd.AddCommand(userDisableCmd) } var gatewayDisableCmd = &cobra.Command{ @@ -103,3 +104,19 @@ var handlerDisableCmd = &cobra.Command{ printStatus(err) }, } + +var userDisableCmd = &cobra.Command{ + Use: "user [...]", + Aliases: []string{"users"}, + Short: "Disables the given users", + Example: ` myc disable user alice + myc disable user alice bob`, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + client, selectors := rootCmd.TakeAlias(args) + printStatus(client.DisableUser(selectors...)) + }, +} diff --git a/cmd/client/command/enable/enable_cmd.go b/cmd/client/command/enable/enable_cmd.go index 5d3baee..a2ec7a6 100644 --- a/cmd/client/command/enable/enable_cmd.go +++ b/cmd/client/command/enable/enable_cmd.go @@ -12,6 +12,7 @@ func init() { enableCmd.AddCommand(taskEnableCmd) enableCmd.AddCommand(scheduleEnableCmd) enableCmd.AddCommand(handlerEnableCmd) + enableCmd.AddCommand(userEnableCmd) } var gatewayEnableCmd = &cobra.Command{ @@ -103,3 +104,19 @@ var handlerEnableCmd = &cobra.Command{ printStatus(err) }, } + +var userEnableCmd = &cobra.Command{ + Use: "user [...]", + Aliases: []string{"users"}, + Short: "Enables the given users", + Example: ` myc enable user alice + myc enable user alice bob`, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + client, selectors := rootCmd.TakeAlias(args) + printStatus(client.EnableUser(selectors...)) + }, +} diff --git a/cmd/client/command/get/get_cmd.go b/cmd/client/command/get/get_cmd.go index cdf0855..75432a7 100644 --- a/cmd/client/command/get/get_cmd.go +++ b/cmd/client/command/get/get_cmd.go @@ -34,6 +34,9 @@ func init() { getCmd.AddCommand(handlerGetCmd) getCmd.AddCommand(forwardPayloadGetCmd) getCmd.AddCommand(backupGetCmd) + getCmd.AddCommand(userGetCmd) + getCmd.AddCommand(policyGetCmd) + getCmd.AddCommand(serviceAccountGetCmd) } var gwGetCmd = &cobra.Command{ diff --git a/cmd/client/command/get/service_account_cmd.go b/cmd/client/command/get/service_account_cmd.go new file mode 100644 index 0000000..8875972 --- /dev/null +++ b/cmd/client/command/get/service_account_cmd.go @@ -0,0 +1,84 @@ +package get + +import ( + "fmt" + + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" + "github.com/mycontroller-org/server/v2/pkg/utils/printer" + "github.com/spf13/cobra" +) + +var saGetUser string + +var serviceAccountGetCmd = &cobra.Command{ + Use: "service-account []", + Aliases: []string{"service-accounts", "sa"}, + Short: "Print service accounts", + Example: ` myc get service-account + myc get service-account ci-bot + myc get sa ci-bot --user alice`, + Args: cobra.RangeArgs(1, 2), + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + Run: func(cmd *cobra.Command, args []string) { + client := rootCmd.MustClient(args[0]) + headers := []printer.Header{ + {Title: "id", IsWide: true}, + {Title: "username"}, + {Title: "name"}, + {Title: "description"}, + {Title: "never expire", ValuePath: "neverExpire"}, + {Title: "expires on", ValueFunc: formatServiceAccountExpiresOn}, + {Title: "statements", ValueFunc: formatServiceAccountStatements}, + {Title: "created on", ValuePath: "createdOn", DisplayStyle: printer.DisplayStyleRelativeTime}, + } + if len(args) == 1 { + executeGetCmd(headers, client.ListServiceAccount, svcAccountTY.ServiceAccount{}) + return + } + account, err := client.FindServiceAccount(args[1], args[1], saGetUser) + if err != nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) + return + } + if account == nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:service-account %s is not present\n", args[1]) + return + } + account.Token.Token = "" + printOne(headers, account) + }, +} + +func init() { + serviceAccountGetCmd.Flags().StringVarP(&saGetUser, "user", "u", "", "username or user id when the account name is not unique") +} + +func serviceAccountFromItem(item interface{}) *svcAccountTY.ServiceAccount { + switch typed := item.(type) { + case *svcAccountTY.ServiceAccount: + return typed + case svcAccountTY.ServiceAccount: + return &typed + default: + return nil + } +} + +func formatServiceAccountExpiresOn(item interface{}) string { + account := serviceAccountFromItem(item) + if account == nil || account.NeverExpire || account.ExpiresOn.IsZero() { + return "-" + } + return printer.FormatTimeValue(account.ExpiresOn.Time, printer.DisplayStyleRelativeTime) +} + +func formatServiceAccountStatements(item interface{}) string { + account := serviceAccountFromItem(item) + if account == nil { + return "-" + } + return formatStatements(account.Statements) +} diff --git a/cmd/client/command/get/statements_test.go b/cmd/client/command/get/statements_test.go new file mode 100644 index 0000000..6968367 --- /dev/null +++ b/cmd/client/command/get/statements_test.go @@ -0,0 +1,22 @@ +package get + +import ( + "testing" + + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + "github.com/stretchr/testify/assert" +) + +func TestFormatStatements(t *testing.T) { + assert.Equal(t, "-", formatStatements(nil)) + assert.Equal(t, "-", formatStatements([]policyTY.Statement{})) + assert.Equal(t, "Allow get,list node:*", formatStatements([]policyTY.Statement{{ + Effect: policyTY.EffectAllow, + Actions: []string{"get", "list"}, + Resources: []string{"node:*"}, + }})) + assert.Equal(t, "Allow get node:*; Deny * settings", formatStatements([]policyTY.Statement{ + {Effect: policyTY.EffectAllow, Actions: []string{"get"}, Resources: []string{"node:*"}}, + {Effect: policyTY.EffectDeny, Actions: []string{"*"}, Resources: []string{"settings"}}, + })) +} diff --git a/cmd/client/command/get/user_policy_cmd.go b/cmd/client/command/get/user_policy_cmd.go new file mode 100644 index 0000000..54a42d2 --- /dev/null +++ b/cmd/client/command/get/user_policy_cmd.go @@ -0,0 +1,192 @@ +package get + +import ( + "fmt" + "strings" + + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" + "github.com/mycontroller-org/server/v2/pkg/utils/printer" + "github.com/spf13/cobra" +) + +var userGetCmd = &cobra.Command{ + Use: "user [ [policies]]", + Aliases: []string{"users"}, + Short: "Print users, a user, or a user's linked policies", + Example: ` myc get user + myc get user alice + myc get user alice policies`, + Args: cobra.RangeArgs(1, 3), + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + Run: func(cmd *cobra.Command, args []string) { + client := rootCmd.MustClient(args[0]) + if len(args) == 1 { + headers := []printer.Header{ + {Title: "id", IsWide: true}, + {Title: "username"}, + {Title: "email"}, + {Title: "full name", ValuePath: "fullName"}, + {Title: "disabled"}, + {Title: "policies", ValueFunc: formatUserPolicies}, + } + executeGetCmd(headers, client.ListUser, userTY.User{}) + return + } + user, err := client.FindUser(args[1], args[1]) + if err != nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) + return + } + if user == nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:user %s is not present\n", args[1]) + return + } + if len(args) == 3 { + if strings.ToLower(args[2]) != "policies" { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error: unknown argument %q (use policies)\n", args[2]) + return + } + printUserPolicies(client, user) + return + } + headers := []printer.Header{ + {Title: "id"}, + {Title: "username"}, + {Title: "email"}, + {Title: "full name", ValuePath: "fullName"}, + {Title: "disabled"}, + {Title: "policies", ValueFunc: formatUserPolicies}, + } + printOne(headers, user) + }, +} + +var policyGetCmd = &cobra.Command{ + Use: "policy []", + Aliases: []string{"policies"}, + Short: "Print policies", + Example: ` myc get policy + myc get policy admin`, + Args: cobra.RangeArgs(1, 2), + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + Run: func(cmd *cobra.Command, args []string) { + client := rootCmd.MustClient(args[0]) + if len(args) == 1 { + headers := []printer.Header{ + {Title: "id"}, + {Title: "description"}, + {Title: "system"}, + {Title: "statements", ValueFunc: formatPolicyStatements}, + } + executeGetCmd(headers, client.ListPolicy, policyTY.Policy{}) + return + } + policy, err := client.FindPolicy(args[1]) + if err != nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) + return + } + if policy == nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:policy %s is not present\n", args[1]) + return + } + headers := []printer.Header{ + {Title: "id"}, + {Title: "description"}, + {Title: "system"}, + {Title: "statements", ValueFunc: formatPolicyStatements}, + } + printOne(headers, policy) + }, +} + +func printOne(headers []printer.Header, item interface{}) { + switch rootCmd.OutputFormat { + case printer.OutputYAML, printer.OutputJSON: + printer.Print(rootCmd.IOStreams.Out, headers, item, rootCmd.HideHeader, rootCmd.OutputFormat, rootCmd.Pretty) + default: + printer.Print(rootCmd.IOStreams.Out, headers, []interface{}{item}, rootCmd.HideHeader, rootCmd.OutputFormat, rootCmd.Pretty) + } +} + +func printUserPolicies(client interface { + FindPolicy(id string) (*policyTY.Policy, error) +}, user *userTY.User) { + if len(user.Policies) == 0 { + _, _ = fmt.Fprintln(rootCmd.IOStreams.Out, "No policies attached") + return + } + rows := make([]interface{}, 0, len(user.Policies)) + for _, id := range user.Policies { + policy, err := client.FindPolicy(id) + if err != nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) + continue + } + if policy == nil { + rows = append(rows, &policyTY.Policy{ID: id, Description: "(not present)"}) + continue + } + rows = append(rows, policy) + } + headers := []printer.Header{ + {Title: "id"}, + {Title: "description"}, + {Title: "system"}, + {Title: "statements", ValueFunc: formatPolicyStatements}, + } + printer.Print(rootCmd.IOStreams.Out, headers, rows, rootCmd.HideHeader, rootCmd.OutputFormat, rootCmd.Pretty) +} + +func formatUserPolicies(item interface{}) string { + switch user := item.(type) { + case *userTY.User: + return strings.Join(user.Policies, ",") + case userTY.User: + return strings.Join(user.Policies, ",") + default: + return "" + } +} + +func formatPolicyStatements(item interface{}) string { + var statements []policyTY.Statement + switch policy := item.(type) { + case *policyTY.Policy: + statements = policy.Statements + case policyTY.Policy: + statements = policy.Statements + default: + return "" + } + return formatStatements(statements) +} + +func formatStatements(statements []policyTY.Statement) string { + if len(statements) == 0 { + return "-" + } + parts := make([]string, 0, len(statements)) + for _, st := range statements { + effect := st.Effect + if effect == "" { + effect = policyTY.EffectAllow + } + actions := strings.Join(st.Actions, ",") + if actions == "" { + actions = "-" + } + resources := strings.Join(st.Resources, ",") + if resources == "" { + resources = "-" + } + parts = append(parts, fmt.Sprintf("%s %s %s", effect, actions, resources)) + } + return strings.Join(parts, "; ") +} diff --git a/cmd/client/command/update/cmd.go b/cmd/client/command/update/cmd.go new file mode 100644 index 0000000..13f2131 --- /dev/null +++ b/cmd/client/command/update/cmd.go @@ -0,0 +1,23 @@ +package update + +import ( + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.Cmd.AddCommand(updateCmd) + updateCmd.AddCommand(userUpdateCmd) + updateCmd.AddCommand(serviceAccountUpdateCmd) +} + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Updates users and service accounts", + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, +} diff --git a/cmd/client/command/update/service_account_cmd.go b/cmd/client/command/update/service_account_cmd.go new file mode 100644 index 0000000..e77e8c0 --- /dev/null +++ b/cmd/client/command/update/service_account_cmd.go @@ -0,0 +1,132 @@ +package update + +import ( + "fmt" + "strings" + + "github.com/mycontroller-org/server/v2/cmd/client/command/common" + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + dateTimeTY "github.com/mycontroller-org/server/v2/pkg/types/cusom_datetime" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + "github.com/spf13/cobra" +) + +var ( + saUser string + saName string + saDescription string + saNeverExpire bool + saExpiresOn string + saEffect string + saActions []string + saResources []string + saClearStatements bool +) + +var serviceAccountUpdateCmd = &cobra.Command{ + Use: "service-account ", + Aliases: []string{"service-accounts", "sa"}, + Short: "Updates a service account without rotating the token", + Example: ` myc update sa ci-bot --description "CI" + myc update sa ci-bot --user alice --name ci-bot-2 + myc update sa ci-bot --never-expire + myc update sa ci-bot --expires-on 2027-12-31 + myc update sa ci-bot --action get --resource "node:*" + myc update sa ci-bot --clear-statements`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + if err := updateServiceAccount(cmd, args[0], args[1]); err != nil { + return fmt.Errorf("error:%s", err) + } + return nil + }, +} + +func init() { + serviceAccountUpdateCmd.Flags().StringVarP(&saUser, "user", "u", "", "username or user id when the account name is not unique") + serviceAccountUpdateCmd.Flags().StringVar(&saName, "name", "", "new name") + serviceAccountUpdateCmd.Flags().StringVarP(&saDescription, "description", "d", "", "description") + serviceAccountUpdateCmd.Flags().BoolVar(&saNeverExpire, "never-expire", true, "token never expires") + serviceAccountUpdateCmd.Flags().StringVar(&saExpiresOn, "expires-on", "", "expiry date (YYYY-MM-DD); turns off never-expire") + serviceAccountUpdateCmd.Flags().StringVar(&saEffect, "effect", policyTY.EffectAllow, "statement effect: Allow or Deny") + serviceAccountUpdateCmd.Flags().StringArrayVar(&saActions, "action", nil, "replace statements with this action (repeatable; requires --resource)") + serviceAccountUpdateCmd.Flags().StringArrayVar(&saResources, "resource", nil, "replace statements with this resource (repeatable; requires --action)") + serviceAccountUpdateCmd.Flags().BoolVar(&saClearStatements, "clear-statements", false, "remove statements (same access as the owner)") +} + +func updateServiceAccount(cmd *cobra.Command, alias, selector string) error { + selector = strings.TrimSpace(selector) + if selector == "" { + return fmt.Errorf("name or id is required") + } + client := rootCmd.MustClient(alias) + account, err := client.FindServiceAccount(selector, selector, saUser) + if err != nil { + return err + } + if account == nil { + return fmt.Errorf("service-account %s is not present", selector) + } + + changed := false + if cmd.Flags().Changed("name") { + name := strings.TrimSpace(saName) + if name == "" { + return fmt.Errorf("name cannot be empty") + } + account.Name = name + changed = true + } + if cmd.Flags().Changed("description") { + account.Description = saDescription + changed = true + } + if cmd.Flags().Changed("expires-on") { + expires := dateTimeTY.CustomDate{} + if err := expires.Unmarshal(saExpiresOn); err != nil { + return fmt.Errorf("expires-on must be YYYY-MM-DD: %w", err) + } + account.NeverExpire = false + account.ExpiresOn = expires + changed = true + } + if cmd.Flags().Changed("never-expire") { + account.NeverExpire = saNeverExpire + if saNeverExpire { + account.ExpiresOn = dateTimeTY.CustomDate{} + } else if !cmd.Flags().Changed("expires-on") && account.ExpiresOn.IsZero() { + return fmt.Errorf("--expires-on is required when never-expire is false") + } + changed = true + } + if saClearStatements { + account.Statements = []policyTY.Statement{} + changed = true + } else { + statements, provided, err := common.ParseOptionalStatement(saEffect, saActions, saResources) + if err != nil { + return err + } + if provided { + account.Statements = statements + changed = true + } + } + if !changed { + return fmt.Errorf("no fields to update") + } + if err := client.UpdateServiceAccount(account); err != nil { + return err + } + label := account.Name + if account.Username != "" { + label = account.Username + "." + account.Name + } + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "service-account: %s\n", label) + return nil +} diff --git a/cmd/client/command/update/user_cmd.go b/cmd/client/command/update/user_cmd.go new file mode 100644 index 0000000..3ee75e1 --- /dev/null +++ b/cmd/client/command/update/user_cmd.go @@ -0,0 +1,113 @@ +package update + +import ( + "fmt" + "strings" + + rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" + "github.com/spf13/cobra" +) + +var ( + userPassword string + userEmail string + userFullName string + userNewUsername string + userPolicies []string + userClearPolicies bool +) + +var userUpdateCmd = &cobra.Command{ + Use: "user ", + Aliases: []string{"users"}, + Short: "Updates a user", + Example: ` myc update user alice --email alice@example.com + myc update user alice --full-name Alice --policy readonly --policy admin + myc update user alice --password newsecret + myc update user alice --username alice2`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + SilenceErrors: true, + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + if err := updateUser(cmd, args[0], args[1]); err != nil { + return fmt.Errorf("error:%s", err) + } + return nil + }, +} + +func init() { + userUpdateCmd.Flags().StringVar(&userNewUsername, "username", "", "new username") + userUpdateCmd.Flags().StringVarP(&userPassword, "password", "p", "", "new password") + userUpdateCmd.Flags().StringVar(&userEmail, "email", "", "email") + userUpdateCmd.Flags().StringVar(&userFullName, "full-name", "", "full name") + userUpdateCmd.Flags().StringArrayVar(&userPolicies, "policy", nil, "replace attached policies (repeatable)") + userUpdateCmd.Flags().BoolVar(&userClearPolicies, "clear-policies", false, "remove all attached policies") +} + +func updateUser(cmd *cobra.Command, alias, selector string) error { + selector = strings.TrimSpace(selector) + if selector == "" { + return fmt.Errorf("username or id is required") + } + client := rootCmd.MustClient(alias) + user, err := client.FindUser(selector, selector) + if err != nil { + return err + } + if user == nil { + return fmt.Errorf("user %s is not present", selector) + } + + update := &userTY.UserAdminUpdate{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + FullName: user.FullName, + Policies: user.Policies, + Labels: user.Labels, + } + changed := false + if cmd.Flags().Changed("username") { + name := strings.TrimSpace(userNewUsername) + if name == "" { + return fmt.Errorf("username cannot be empty") + } + update.Username = name + changed = true + } + if cmd.Flags().Changed("email") { + update.Email = userEmail + changed = true + } + if cmd.Flags().Changed("full-name") { + update.FullName = userFullName + changed = true + } + if cmd.Flags().Changed("password") { + if strings.TrimSpace(userPassword) == "" { + return fmt.Errorf("password cannot be empty") + } + update.Password = userPassword + changed = true + } + if userClearPolicies { + update.Policies = []string{} + changed = true + } else if cmd.Flags().Changed("policy") { + update.Policies = userPolicies + changed = true + } + if !changed { + return fmt.Errorf("no fields to update") + } + if err := client.SaveUser(update); err != nil { + return err + } + _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "user: %s\n", update.Username) + return nil +} diff --git a/cmd/client/main.go b/cmd/client/main.go index 76828b6..6c91f1f 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -5,6 +5,7 @@ import ( clientTY "github.com/mycontroller-org/server/v2/pkg/types/client" _ "github.com/mycontroller-org/server/v2/cmd/client/command/action" + _ "github.com/mycontroller-org/server/v2/cmd/client/command/add" _ "github.com/mycontroller-org/server/v2/cmd/client/command/alias" _ "github.com/mycontroller-org/server/v2/cmd/client/command/apply" _ "github.com/mycontroller-org/server/v2/cmd/client/command/delete" @@ -15,6 +16,7 @@ import ( _ "github.com/mycontroller-org/server/v2/cmd/client/command/reload" _ "github.com/mycontroller-org/server/v2/cmd/client/command/server" _ "github.com/mycontroller-org/server/v2/cmd/client/command/set" + _ "github.com/mycontroller-org/server/v2/cmd/client/command/update" _ "github.com/mycontroller-org/server/v2/cmd/client/command/upload" ) diff --git a/docs/access-control.md b/docs/access-control.md index e764c09..907fe98 100644 --- a/docs/access-control.md +++ b/docs/access-control.md @@ -1,6 +1,6 @@ # Access control (policies) -This document describes MyController’s **policy-based access control**: how identities, policies, resources, and service tokens work, and how to configure them with examples. +This document describes MyController’s **policy-based access control**: how identities, policies, resources, and service accounts work, and how to configure them with examples. --- @@ -8,16 +8,16 @@ This document describes MyController’s **policy-based access control**: how id MyController authorizes HTTP API calls with: -1. **Authentication** – valid JWT (login or service token). +1. **Authentication** – valid JWT (login or service account). 2. **User state** – user must exist and must not be **disabled**. -3. **Service token** (if used) – must exist, belong to the user, and not be expired. +3. **Service account** (if used) – must exist, belong to the user, and not be expired. 4. **Authorization** – at least one attached **policy** must **Allow** the requested **action** on the requested **resource**. There is no multi-tenant isolation in this model. Policies define _what_ a principal may do on _which_ named resources. ### Naming -This feature is **policy-based access control**: reusable **policies** are attached to **users** (and optionally narrowed on service tokens). It is not classical role-based access control (User → Role → permissions). +This feature is **policy-based access control**: reusable **policies** are attached to **users** (and optionally narrowed on service accounts). It is not classical role-based access control (User → Role → permissions). | Term | Meaning | | ----------------- | -------------------------------------------------- | @@ -31,11 +31,11 @@ This feature is **policy-based access control**: reusable **policies** are attac | ------------------- | -------------------------------------------------------------------------------- | | **User** | Identity; holds password, `disabled`, and a list of **policy IDs** | | **Policy** | Named document: list of **statements** (effect, actions, resources) | -| **Service token** | Always tied to a user; optional **extra limits** that can only **reduce** access | +| **Service account** | Always tied to a user; optional **extra limits** that can only **reduce** access | | **Resource string** | `kind` or `kind:name` (name may use hierarchical wildcards) | | **Action** | Verb such as `get`, `list`, `update`, `delete`, … | -Effective access for a service token: +Effective access for a service account: ```text effective = permissions(user policies) ∩ token restrictions (if any) @@ -147,18 +147,17 @@ You do **not** need separate `source:` / `field:` lines unless you want a narrow scope (e.g. only one source). Kind-wide Deny (`node`, `node:*`, `*`) still blocks the whole kind (and descendants). -### 2.4 Service tokens +### 2.4 Service accounts -Service tokens always have a `userId`. They act **as that user**, with optional tightening: +Service accounts (API path `/api/serviceaccount`) always have a `userId`. They act **as that user**, with optional tightening: | Field | Description | | --------------------------- | ------------------------------------------------------------- | -| `userId` | Owning user (immutable after create) | +| `userId` / `username` | Owning user (immutable after create). Admins may set this | | `neverExpire` / `expiresOn` | Lifetime of the token | -| `actions` | Optional: only these actions (subset of the user’s) | -| `resources` | Optional: only these resource patterns (subset of the user’s) | +| `statements` | Optional Allow/Deny rules (same shape as a policy statement) | -Empty `actions` and `resources` mean “no extra limit” (same as the user). +Empty statements mean “no extra limit” (same as the user). Evaluation: @@ -220,7 +219,7 @@ All kinds recognized by the authorization engine are listed below. | `datarepository` | `/api/datarepository` | id | Yes | | `virtualdevice` | `/api/virtualdevice` | id | Yes | | `virtualassistant` | `/api/virtualassistant` | id | Yes | -| `servicetoken` | `/api/servicetoken` | entity id | Yes | +| `serviceaccount` | `/api/serviceaccount` | entity id | Yes | | `metric` | `/api/metric` | same hierarchy as **field** path | Yes (kind-level in built-ins; see metrics) | | `action` | `/api/action` | optional target name | Yes | | `status` | `/api/server/status` | (kind only) | Yes | @@ -270,7 +269,7 @@ Storage may still use a UUID as primary key for node/source/field. For **get by | `datarepository` | id | API path `/api/datarepository` | | `virtualdevice` | id | | | `virtualassistant` | id | | -| `servicetoken` | entity id | | +| `serviceaccount` | entity id | | | `user` | user management | Create/list/update/delete users | | `policy` | policy management | Create/list/update/delete policies | | `settings` | system settings | | @@ -338,7 +337,7 @@ Default user on fresh install: `admin` / `admin` with policy `admin`. HTTP request → JWT valid? → user active (not disabled)? - → service token valid (if present)? + → service account valid (if present)? → map path + method → action + resource → (optional) resolve UUID → business name → Allowed(user policies ∩ token limits)? ← layer 1: may you reach this endpoint? @@ -372,11 +371,12 @@ A payload that names **no** target — creating an object whose id the server ge **kind-wide** grant (`*`, `gateway`, or `gateway:*`). A grant on one named object is never enough to create new ones, which is what keeps `user:` from being a path to `admin`. -### 6.1b Service tokens are personal +### 6.1b Service accounts are personal -`/api/servicetoken` is always scoped to the caller, whatever the policies say. Tokens act as their -owner, so no principal can read, widen (drop the `actions`/`resources` limits, set `neverExpire`) or -delete another principal's tokens. To revoke someone else's access, disable the user. +A service account acts as its owner. Callers without user-admin rights only see and manage their +own accounts. A principal with kind-wide `user` update (for example built-in `admin`) can create, +list, update, and delete service accounts for any user. The owner (`userId`) cannot be changed +after create. To revoke someone else's access, disable the user. ### 6.2 List queries @@ -466,7 +466,7 @@ Managing other users requires the `user` resource (typically `admin` or a custom | -------------- | ------------------- | ------------------------- | | Users | `/api/user` | Settings → Users | | Policies | `/api/policy` | Settings → Policies | -| Service tokens | `/api/servicetoken` | Settings → Service Tokens | +| Service accounts | `/api/serviceaccount` | Settings → Service Accounts | Only principals with policy rights on `user` / `policy` can manage them (e.g. built-in `admin`). @@ -628,22 +628,22 @@ statements: Combine with another policy if that person also needs device access. -### 8.8 Service token narrower than the user +### 8.8 Service account narrower than the user -User has `readwrite`. Token for automation: +User has `readwrite`. Service account for automation: ```yaml name: plant-room-metrics-bot userId: neverExpire: false expiresOn: "2027-12-31" -actions: - - get - - list -resources: - - field:plant-room.* - - metric:plant-room.* - - quickid +statements: + - effect: Allow + actions: [get, list] + resources: + - field:plant-room.* + - metric:plant-room.* + - quickid ``` The bot cannot update devices or touch other gateways, even though Alice could. @@ -705,7 +705,7 @@ silently becoming empty (which would remove all of their access). ## 11. Performance notes -- Users, policies, and service tokens used for auth are kept in an **in-memory cache**, refreshed on write. +- Users, policies, and service accounts used for auth are kept in an **in-memory cache**, refreshed on write. - List scoping is applied as **storage query filters** (including OR of name patterns). - Get-by-UUID for device entities does one lookup to resolve the business name before the policy check. @@ -767,4 +767,4 @@ resources: ## 14. Changelog (feature introduction) -Policy-based access control was introduced for server release line **2.2.0** (upgrade id `2.2.0-1`): built-in policies, user `policies` / `disabled`, service token restrictions, and enforcement on the HTTP API. +Policy-based access control was introduced for server release line **2.2.0** (upgrade id `2.2.0-1`): built-in policies, user `policies` / `disabled`, service account restrictions, and enforcement on the HTTP API. diff --git a/docs/cli.md b/docs/cli.md index 29b12ea..8c0cf7e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -13,6 +13,8 @@ This document describes the **MyController command-line client**: how to build i | `alias` | Add, list, or remove named server connections | | `server` | Show server information for an alias | | `get` | List resources | +| `add` | Add a user or service account (`create` is an alias) | +| `update` | Update a user or service account | | `apply` | Add, merge, or delete resources from a YAML or JSON file | | `upload` | Upload a firmware binary to an existing firmware resource | | `set` | Update a stored property, or set a live field value | @@ -92,7 +94,7 @@ myc alias remove | --- | --- | --- | | `-u`, `--username` | | Login username | | `-p`, `--password` | | Login password | -| `-t`, `--token` | | Service token (skips username/password) | +| `-t`, `--token` | | Service account token (skips username/password) | | `--expires-in` | `720h` | Session lifetime | | `--insecure` | `false` | Skip TLS certificate verification | @@ -176,14 +178,81 @@ The key is matched against the table header title (spaces ignored, case insensit | `get task` | `tasks` | | `get schedule` | `schedules` | | `get handler` | `handlers` | +| `get user` | `users` | +| `get policy` | `policies` | +| `get service-account` | `service-accounts`, `sa` | | `get forward-payload` | `forward-payloads` | | `get backup` | `backups` | +```bash +myc get user +myc get user alice +myc get user alice policies +myc get policy +myc get policy admin +myc get service-account +myc get service-account ci-bot +myc get sa ci-bot --user alice +``` + +`get user policies` lists the policies attached to that user. + +`get service-account` lists accounts the caller can see. The secret token is never stored; it is shown only when the account is created. If the same name exists for more than one user, pass `--user`. + +### Add a user + +```bash +myc add user alice --password secret +myc add user alice --email alice@example.com --full-name Alice --policy readonly +myc add user alice +``` + +If `--password` is omitted, myc prompts. Repeat `--policy` to attach policies. Add fails if the username already exists. + +### Add a service account + +```bash +myc add service-account ci-bot +myc add sa mobile --user alice --description "phone login" +myc add sa ci-bot --action get --action list --resource "node:*" --resource "field:*" +myc add sa limited --effect Deny --action "*" --resource settings +myc add sa temp --expires-on 2027-12-31 +``` + +`create` is an alias of `add`. Omit `--user` to create the account for the logged-in user. `--never-expire` defaults to true; `--expires-on` (YYYY-MM-DD) turns that off. `--action` and `--resource` must be used together (repeatable) and form one statement; `--effect` is Allow or Deny (default Allow). Omit both for the same access as the owning user. + +The token is printed once. Save it; it cannot be retrieved later. If the name already exists for that user, add fails. Use `myc apply` to merge or replace. Add exits `1` on error. + +### Update a user + +```bash +myc update user alice --email alice@example.com --full-name Alice +myc update user alice --policy readonly --policy admin +myc update user alice --password newsecret +myc update user alice --username alice2 +myc update user alice --clear-policies +``` + +Only flags you pass are changed. `--policy` replaces the attached list. `--clear-policies` removes all policies. Password is not prompted; pass `--password` to change it. + +### Update a service account + +```bash +myc update sa ci-bot --description "CI" +myc update sa ci-bot --user alice --name ci-bot-2 +myc update sa ci-bot --never-expire +myc update sa ci-bot --expires-on 2027-12-31 +myc update sa ci-bot --action get --resource "node:*" +myc update sa ci-bot --clear-statements +``` + +The owner and token are not changed. If the name is used by more than one user, pass `--user`. `--action`/`--resource` replace statements; `--clear-statements` removes them. Update exits `1` on error. + --- ## 5. Apply -`myc apply` creates, merges, or deletes **gateways**, **nodes**, **sources**, **fields**, **firmware**, and **data repositories** from a YAML or JSON file. +`myc apply` creates, merges, or deletes **gateways**, **nodes**, **sources**, **fields**, **firmware**, **data repositories**, **users**, **policies**, and **service accounts** from a YAML or JSON file. Firmware **binaries** are not part of apply. Create the firmware resource with apply, then upload the file with `myc upload firmware`. @@ -274,7 +343,7 @@ sourceId: dht fieldId: temperature ``` -`kind` aliases: `gateway` / `gw` / `gateways`, `node` / `nodes`, `source` / `sources`, `field` / `fields`. +`kind` aliases: `gateway` / `gw` / `gateways`, `node` / `nodes`, `source` / `sources`, `field` / `fields`, `user` / `users`, `policy` / `policies`, `service-account` / `service-accounts` / `sa`. `operation` aliases: `add` / `create`, `update`, `delete` / `remove`. @@ -325,16 +394,23 @@ That item is a field, not a source. | gateway | `id` | `id` | `id` | | firmware | `id` | `id` | `id` | | data-repository | `id` | `id` | `id` | +| user | `username` or `id` | `username` and `password` on add | `id` or `username` | +| policy | `id` | `id` (generated on add if omitted) | `id` | +| service-account | `name` or `id` | `name`; optional `username` or `userId` (defaults to the logged-in user) | `id` or `name` | | node | `gatewayId` + `nodeId` | `gatewayId`, `nodeId` | `id` or `gatewayId`+`nodeId` | | source | `gatewayId` + `nodeId` + `sourceId` | those three | `id` or those three | | field | `gatewayId` + `nodeId` + `sourceId` + `fieldId` | those four | `id` or those four | Lookup uses `id` when it is set, otherwise the natural keys. -Gateway, firmware, and data-repository HTTP APIs require an `id` on save; supply it in the file. For a new node or source without `id`, the client generates a UUID. A new field may omit `id`; the server assigns one. +Gateway, firmware, and data-repository HTTP APIs require an `id` on save; supply it in the file. For a new node or source without `id`, the client generates a UUID. A new field, user, or service account may omit `id`; the server assigns one. Apply of firmware writes **metadata only** (`id`, `description`, `labels`). The binary stays empty until `myc upload firmware`. Updating firmware metadata keeps the existing file. Replacing a firmware deletes the old file; upload again after replace. +Adding or replacing a service account prints the secret token after the table. Save it immediately; it cannot be retrieved later. Replace issues a **new** token and keeps the same storage id. Merge updates name, description, expiry, and limits without rotating the token. + +Omit `username` / `userId` to create the account for the logged-in user. Set `username` or `userId` to create it for another user (requires user-admin rights). + ### 5.6 Operations **add** @@ -518,6 +594,35 @@ items: ``` ```yaml +kind: user +operation: add +username: alice +password: secret +email: alice@example.com +fullName: Alice +policies: + - readonly +--- +kind: policy +operation: add +id: sensors-read +description: read sensors +statements: + - effect: Allow + actions: ["get", "list"] + resources: ["node:*", "source:*", "field:*"] +--- +kind: service-account +operation: add +name: ci-bot +username: alice +description: CI automation +neverExpire: true +statements: + - effect: Allow + actions: ["get", "list"] + resources: ["node:*", "source:*", "field:*"] +--- kind: firmware operation: add id: stm32-app-slot-a @@ -666,7 +771,7 @@ Do not use `myc set field` for this. `set field` always updates a stored key pat ## 8. Delete, enable, disable, reload, reboot, action -`delete`, `enable`, `disable`, and `reload` take the **alias** first, then **storage ids** (the `id` column from `get`). Node `reboot` and `action node` take the alias, then **quick ids** (`gatewayId.nodeId`). +`delete`, `enable`, `disable`, and `reload` take the **alias** first, then **storage ids** (the `id` column from `get`). User and service-account delete also accept name. You cannot disable or delete the user you are logged in as. Node `reboot` and `action node` take the alias, then **quick ids** (`gatewayId.nodeId`). ### Delete @@ -675,6 +780,9 @@ myc delete gateway [...] myc delete node myc delete source myc delete field +myc delete user alice +myc delete service-account ci-bot +myc delete sa ci-bot --user alice ``` | Resource | Aliases | @@ -692,15 +800,20 @@ myc delete field | `handler` | `handlers` | | `forward-payload` | `forward-payloads` | | `backup` | `backups` | +| `user` | `users` | +| `policy` | `policies` | +| `service-account` | `service-accounts`, `sa` | ### Enable / disable ```bash myc enable gateway myc disable task +myc enable user alice +myc disable user alice bob ``` -Supported: `gateway`, `virtual-device`, `virtual-assistant`, `task`, `schedule`, `handler` (same aliases as `get`). +Supported: `gateway`, `virtual-device`, `virtual-assistant`, `task`, `schedule`, `handler`, `user`. User enable/disable accept username or id. A disabled user cannot log in. You cannot disable the user you are logged in as. ### Reload diff --git a/pkg/api/entities/api.go b/pkg/api/entities/api.go index fecbaee..9d17b38 100644 --- a/pkg/api/entities/api.go +++ b/pkg/api/entities/api.go @@ -14,7 +14,7 @@ import ( node "github.com/mycontroller-org/server/v2/pkg/api/node" policy "github.com/mycontroller-org/server/v2/pkg/api/policy" schedule "github.com/mycontroller-org/server/v2/pkg/api/schedule" - serviceToken "github.com/mycontroller-org/server/v2/pkg/api/service_token" + serviceAccount "github.com/mycontroller-org/server/v2/pkg/api/service_account" settings "github.com/mycontroller-org/server/v2/pkg/api/settings" source "github.com/mycontroller-org/server/v2/pkg/api/source" status "github.com/mycontroller-org/server/v2/pkg/api/status" @@ -125,8 +125,8 @@ func (a *API) Schedule() *schedule.ScheduleAPI { return schedule.New(a.ctx, a.logger, a.storage, a.bus) } -func (a *API) ServiceToken() *serviceToken.ServiceTokenAPI { - return serviceToken.New(a.ctx, a.logger, a.storage) +func (a *API) ServiceAccount() *serviceAccount.ServiceAccountAPI { + return serviceAccount.New(a.ctx, a.logger, a.storage) } func (a *API) Settings() *settings.SettingsAPI { return settings.New(a.ctx, a.logger, a.storage, a.enc, a.bus) diff --git a/pkg/api/policy/api.go b/pkg/api/policy/api.go index f53316d..1eeb23d 100644 --- a/pkg/api/policy/api.go +++ b/pkg/api/policy/api.go @@ -10,7 +10,7 @@ import ( types "github.com/mycontroller-org/server/v2/pkg/types" policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" "github.com/mycontroller-org/server/v2/pkg/utils" storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" @@ -58,7 +58,7 @@ func New(ctx context.Context, logger *zap.Logger, storage storageTY.Plugin) *API } return &p, nil }, - func(tokenID string) (*svcTokenTY.ServiceToken, error) { + func(tokenID string) (*svcAccountTY.ServiceAccount, error) { t, err := a.loadTokenFromStorage(tokenID) if err != nil { return nil, err @@ -167,9 +167,9 @@ func (a *API) loadUserFromStorage(id string) (userTY.User, error) { return result, err } -func (a *API) loadTokenFromStorage(tokenID string) (svcTokenTY.ServiceToken, error) { - result := svcTokenTY.ServiceToken{} - err := a.storage.FindOne(types.EntityServiceToken, &result, []storageTY.Filter{{Key: types.KeyTokenID, Value: tokenID}}) +func (a *API) loadTokenFromStorage(tokenID string) (svcAccountTY.ServiceAccount, error) { + result := svcAccountTY.ServiceAccount{} + err := a.storage.FindOne(types.EntityServiceAccount, &result, []storageTY.Filter{{Key: types.KeyTokenID, Value: tokenID}}) return result, err } @@ -284,7 +284,7 @@ func (a *API) NotifyUserDeleted(id string) { } // NotifyTokenUpdated refreshes token cache. -func (a *API) NotifyTokenUpdated(token *svcTokenTY.ServiceToken) { +func (a *API) NotifyTokenUpdated(token *svcAccountTY.ServiceAccount) { a.cache.PutToken(token) } diff --git a/pkg/api/policy/api_save_test.go b/pkg/api/policy/api_save_test.go index b926bac..e2675fa 100644 --- a/pkg/api/policy/api_save_test.go +++ b/pkg/api/policy/api_save_test.go @@ -6,7 +6,7 @@ import ( types "github.com/mycontroller-org/server/v2/pkg/types" policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" ) @@ -34,10 +34,10 @@ func (s *policyMemStore) Find(string, interface{}, []storageTY.Filter, *storageT func (s *policyMemStore) Delete(string, []storageTY.Filter) (int64, error) { return 0, errors.New("not implemented") } -func (s *policyMemStore) Pause() error { return nil } -func (s *policyMemStore) Resume() error { return nil } -func (s *policyMemStore) ClearDatabase() error { return nil } -func (s *policyMemStore) DoStartupImport() (bool, string, string) { return false, "", "" } +func (s *policyMemStore) Pause() error { return nil } +func (s *policyMemStore) Resume() error { return nil } +func (s *policyMemStore) ClearDatabase() error { return nil } +func (s *policyMemStore) DoStartupImport() (bool, string, string) { return false, "", "" } func (s *policyMemStore) FindOne(entityName string, out interface{}, filters []storageTY.Filter) error { if entityName != types.EntityPolicy || len(filters) == 0 { @@ -66,7 +66,7 @@ func testPolicyAPI(store *policyMemStore) *API { c.setLoaders( func(id string) (*userTY.User, error) { return nil, ErrUserNotFound }, func(id string) (*policyTY.Policy, error) { return nil, ErrUserNotFound }, - func(id string) (*svcTokenTY.ServiceToken, error) { return nil, ErrTokenNotFound }, + func(id string) (*svcAccountTY.ServiceAccount, error) { return nil, ErrTokenNotFound }, func() ([]policyTY.Policy, error) { return nil, nil }, ) return &API{storage: store, cache: c} diff --git a/pkg/api/policy/body_auth_test.go b/pkg/api/policy/body_auth_test.go index a2ad455..15def61 100644 --- a/pkg/api/policy/body_auth_test.go +++ b/pkg/api/policy/body_auth_test.go @@ -7,7 +7,7 @@ import ( "testing" policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" ) @@ -24,7 +24,7 @@ func apiWithPolicies(t *testing.T, policies ...policyTY.Policy) *API { c.setLoaders( func(id string) (*userTY.User, error) { return nil, ErrUserNotFound }, func(id string) (*policyTY.Policy, error) { return nil, ErrNotFound }, - func(tokenID string) (*svcTokenTY.ServiceToken, error) { return nil, ErrTokenNotFound }, + func(tokenID string) (*svcAccountTY.ServiceAccount, error) { return nil, ErrTokenNotFound }, func() ([]policyTY.Policy, error) { return nil, nil }, ) return &API{cache: c} diff --git a/pkg/api/policy/cache.go b/pkg/api/policy/cache.go index 7e97f21..364e45d 100644 --- a/pkg/api/policy/cache.go +++ b/pkg/api/policy/cache.go @@ -4,23 +4,23 @@ import ( "sync" policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" ) -// Cache holds users, policies, and service tokens in memory for fast auth checks. +// Cache holds users, policies, and service accounts in memory for fast auth checks. // Call Invalidate* after any write so the next request reloads from storage. type Cache struct { mu sync.RWMutex - users map[string]*userTY.User // by user id - policies map[string]*policyTY.Policy // by policy id - tokens map[string]*svcTokenTY.ServiceToken // by token.Token.ID (token id used in JWT) + users map[string]*userTY.User // by user id + policies map[string]*policyTY.Policy // by policy id + tokens map[string]*svcAccountTY.ServiceAccount // by token.Token.ID (token id used in JWT) // loaders - set by API loadUser func(id string) (*userTY.User, error) loadPolicy func(id string) (*policyTY.Policy, error) - loadToken func(tokenID string) (*svcTokenTY.ServiceToken, error) + loadToken func(tokenID string) (*svcAccountTY.ServiceAccount, error) loadAllPol func() ([]policyTY.Policy, error) } @@ -28,14 +28,14 @@ func newCache() *Cache { return &Cache{ users: make(map[string]*userTY.User), policies: make(map[string]*policyTY.Policy), - tokens: make(map[string]*svcTokenTY.ServiceToken), + tokens: make(map[string]*svcAccountTY.ServiceAccount), } } func (c *Cache) setLoaders( loadUser func(id string) (*userTY.User, error), loadPolicy func(id string) (*policyTY.Policy, error), - loadToken func(tokenID string) (*svcTokenTY.ServiceToken, error), + loadToken func(tokenID string) (*svcAccountTY.ServiceAccount, error), loadAllPol func() ([]policyTY.Policy, error), ) { c.mu.Lock() @@ -58,7 +58,7 @@ func (c *Cache) policyLoader() func(id string) (*policyTY.Policy, error) { return c.loadPolicy } -func (c *Cache) tokenLoader() func(tokenID string) (*svcTokenTY.ServiceToken, error) { +func (c *Cache) tokenLoader() func(tokenID string) (*svcAccountTY.ServiceAccount, error) { c.mu.RLock() defer c.mu.RUnlock() return c.loadToken @@ -114,8 +114,8 @@ func (c *Cache) GetPolicy(id string) (*policyTY.Policy, error) { return &cp, nil } -// GetToken returns a cached service token by raw token id (Token.ID), or loads it. -func (c *Cache) GetToken(tokenID string) (*svcTokenTY.ServiceToken, error) { +// GetToken returns a cached service account by raw token id (Token.ID), or loads it. +func (c *Cache) GetToken(tokenID string) (*svcAccountTY.ServiceAccount, error) { c.mu.RLock() if t, ok := c.tokens[tokenID]; ok { cp := *t @@ -162,7 +162,7 @@ func (c *Cache) PutPolicy(p *policyTY.Policy) { } // PutToken updates the cache after a write (keyed by Token.ID). -func (c *Cache) PutToken(t *svcTokenTY.ServiceToken) { +func (c *Cache) PutToken(t *svcAccountTY.ServiceAccount) { if t == nil { return } @@ -188,7 +188,7 @@ func (c *Cache) InvalidatePolicy(id string) { c.mu.Unlock() } -// InvalidateToken drops a service token from cache by Token.ID. +// InvalidateToken drops a service account from cache by Token.ID. func (c *Cache) InvalidateToken(tokenID string) { c.mu.Lock() delete(c.tokens, tokenID) diff --git a/pkg/api/policy/defaults.go b/pkg/api/policy/defaults.go index 3fe67d9..24cf160 100644 --- a/pkg/api/policy/defaults.go +++ b/pkg/api/policy/defaults.go @@ -33,7 +33,7 @@ func BuiltInPolicies() []policyTY.Policy { policyTY.ResourceGateway, policyTY.ResourceNode, policyTY.ResourceSource, policyTY.ResourceField, policyTY.ResourceTask, policyTY.ResourceSchedule, policyTY.ResourceHandler, policyTY.ResourceDashboard, policyTY.ResourceFirmware, policyTY.ResourceForwardPayload, policyTY.ResourceDataRepository, - policyTY.ResourceVirtualDevice, policyTY.ResourceVirtualAssistant, policyTY.ResourceServiceToken, + policyTY.ResourceVirtualDevice, policyTY.ResourceVirtualAssistant, policyTY.ResourceServiceAccount, policyTY.ResourceMetric, policyTY.ResourceAction, policyTY.ResourceStatus, policyTY.ResourceQuickID, } diff --git a/pkg/api/policy/engine.go b/pkg/api/policy/engine.go index 4586147..d7354f3 100644 --- a/pkg/api/policy/engine.go +++ b/pkg/api/policy/engine.go @@ -6,7 +6,7 @@ import ( "time" policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" ) @@ -14,15 +14,15 @@ var ( errCacheNotReady = errors.New("access control cache not ready") ErrUserDisabled = errors.New("user is disabled") ErrUserNotFound = errors.New("user not found") - ErrTokenExpired = errors.New("service token expired") - ErrTokenNotFound = errors.New("service token not found") + ErrTokenExpired = errors.New("service account expired") + ErrTokenNotFound = errors.New("service account not found") ErrAccessDenied = errors.New("access denied") ) // Subject is the authenticated principal for an access check. type Subject struct { - UserID string - ServiceTokenID string // raw Token.ID from JWT; empty for interactive login + UserID string + ServiceAccountID string // raw Token.ID from JWT; empty for interactive login } // Allowed reports whether the subject may perform action on resource. @@ -30,7 +30,7 @@ type Subject struct { // // Rules: // 1. User must exist and not be disabled -// 2. If service token: must exist, not expired, belong to user +// 2. If service account: must exist, not expired, belong to user // 3. User policies must allow (ceiling) // 4. If token has restrictions, they must also allow (can only lower) func (a *API) Allowed(subject Subject, action, resource string) error { @@ -48,8 +48,8 @@ func (a *API) Allowed(subject Subject, action, resource string) error { } // Token restrictions (optional lower bound): can only narrow further - if token != nil && (len(token.Actions) > 0 || len(token.Resources) > 0) { - if !restrictionsAllow(token.Actions, token.Resources, action, resource) { + if token != nil { + if !tokenRestrictionsAllow(token, action, resource) { return ErrAccessDenied } } @@ -76,13 +76,13 @@ func (a *API) activeUser(subject Subject) (*userTY.User, error) { return user, nil } -// activeToken loads the subject's service token, if the request presented one. +// activeToken loads the subject's service account, if the request presented one. // Returns (nil, nil) for an interactive login. -func (a *API) activeToken(subject Subject) (*svcTokenTY.ServiceToken, error) { - if subject.ServiceTokenID == "" { +func (a *API) activeToken(subject Subject) (*svcAccountTY.ServiceAccount, error) { + if subject.ServiceAccountID == "" { return nil, nil } - token, err := a.cache.GetToken(subject.ServiceTokenID) + token, err := a.cache.GetToken(subject.ServiceAccountID) if err != nil { return nil, ErrTokenNotFound } @@ -117,34 +117,12 @@ func (a *API) AllowedKindWide(subject Subject, action, kind string) error { return ErrAccessDenied } // A token restriction naming individual objects cannot satisfy a kind-wide check - if token != nil && (len(token.Actions) > 0 || len(token.Resources) > 0) { - if len(token.Actions) > 0 && !anyActionMatch(token.Actions, action) { - return ErrAccessDenied - } - if len(token.Resources) > 0 && !resourcesCoverKindWide(token.Resources, kind) { - return ErrAccessDenied - } + if token != nil && !tokenRestrictionsAllowKindWide(token, action, kind) { + return ErrAccessDenied } return nil } -// resourcesCoverKindWide reports whether any resource pattern covers the whole kind. -func resourcesCoverKindWide(resources []string, kind string) bool { - for _, res := range resources { - if res == "*" { - return true - } - k, name := splitResource(res) - if k != kind && k != "*" { - continue - } - if name == "" || name == "*" { - return true - } - } - return false -} - // EnsureUserActive loads user from cache and verifies not disabled (for auth middleware). func (a *API) EnsureUserActive(userID string) (*userTY.User, error) { user, err := a.cache.GetUser(userID) @@ -157,8 +135,8 @@ func (a *API) EnsureUserActive(userID string) (*userTY.User, error) { return user, nil } -// EnsureServiceTokenActive validates token still valid for requests. -func (a *API) EnsureServiceTokenActive(userID, tokenID string) error { +// EnsureServiceAccountActive validates service account still valid for requests. +func (a *API) EnsureServiceAccountActive(userID, tokenID string) error { if tokenID == "" { return nil } @@ -172,7 +150,7 @@ func (a *API) EnsureServiceTokenActive(userID, tokenID string) error { return validateTokenExpiry(token) } -func validateTokenExpiry(token *svcTokenTY.ServiceToken) error { +func validateTokenExpiry(token *svcAccountTY.ServiceAccount) error { if token.NeverExpire { return nil } @@ -332,6 +310,35 @@ func statementsAllow(statements []policyTY.Statement, action, resource string) b return evaluateStatements(statements, action, resource) } +func tokenRestrictionsAllow(token *svcAccountTY.ServiceAccount, action, resource string) bool { + if token == nil || len(token.Statements) == 0 { + return true + } + return evaluateStatements(token.Statements, action, resource) +} + +func tokenRestrictionsAllowKindWide(token *svcAccountTY.ServiceAccount, action, kind string) bool { + if token == nil || len(token.Statements) == 0 { + return true + } + return evaluateStatements(token.Statements, action, kind) || + evaluateStatements(token.Statements, action, FormatResource(kind, "*")) +} + +func tokenResourceLimits(token *svcAccountTY.ServiceAccount) []string { + if token == nil || len(token.Statements) == 0 { + return nil + } + resources := make([]string, 0) + for _, st := range token.Statements { + if normalizeEffect(st.Effect) == policyTY.EffectDeny { + continue + } + resources = append(resources, st.Resources...) + } + return resources +} + func restrictionsAllow(actions, resources []string, action, resource string) bool { // empty actions in restriction means all actions (still under user ceiling) if len(actions) > 0 && !anyActionMatch(actions, action) { @@ -484,13 +491,13 @@ func (a *API) ResourceNamesForList(subject Subject, kind string) (unrestricted b } // Intersect with token restrictions (token can only narrow) - if subject.ServiceTokenID != "" { - token, err := a.cache.GetToken(subject.ServiceTokenID) + if subject.ServiceAccountID != "" { + token, err := a.cache.GetToken(subject.ServiceAccountID) if err != nil { return false, nil, ErrTokenNotFound } - if len(token.Resources) > 0 { - tokenPatterns, tokenWild := tokenNamePatternsForKind(token.Resources, kind) + if tokenResources := tokenResourceLimits(token); len(tokenResources) > 0 { + tokenPatterns, tokenWild := tokenNamePatternsForKind(tokenResources, kind) if !tokenWild { unrestricted = false allowPart, denyPart := splitAllowDenyPatterns(patterns) @@ -517,7 +524,7 @@ func (a *API) ResourceNamesForList(subject Subject, kind string) (unrestricted b return unrestricted, patterns, nil } -// tokenNamePatternsForKind collects the name patterns a service token allows for kind. +// tokenNamePatternsForKind collects the name patterns a service account allows for kind. // Mirrors the policy loop above, including the device-tree parent cascade // (token resource "gateway:gw" reaches node/source/field/metric rows under gw), // so list scoping matches what Allowed() permits for a single resource. @@ -579,7 +586,7 @@ func subtractPatterns(allow, deny []string) []string { } // intersectPatterns keeps only name patterns allowed by both sides -// (a = user policy scope, b = service token scope). When one pattern covers the +// (a = user policy scope, b = service account scope). When one pattern covers the // other, the narrower one survives. No overlap means no access, so an empty // result is a valid answer and must be treated as "no rows" by the caller. func intersectPatterns(a, b []string) []string { diff --git a/pkg/api/policy/list_integration_test.go b/pkg/api/policy/list_integration_test.go index d9905a4..8426120 100644 --- a/pkg/api/policy/list_integration_test.go +++ b/pkg/api/policy/list_integration_test.go @@ -6,7 +6,7 @@ import ( fieldTY "github.com/mycontroller-org/server/v2/pkg/types/field" policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" sourceTY "github.com/mycontroller-org/server/v2/pkg/types/source" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" filterUtils "github.com/mycontroller-org/server/v2/pkg/utils/filter_sort" @@ -22,7 +22,7 @@ func mockAPIWithPolicy(t *testing.T, userID string, p policyTY.Policy) *API { c.setLoaders( func(id string) (*userTY.User, error) { return nil, ErrUserNotFound }, func(id string) (*policyTY.Policy, error) { return nil, ErrUserNotFound }, - func(id string) (*svcTokenTY.ServiceToken, error) { return nil, ErrTokenNotFound }, + func(id string) (*svcAccountTY.ServiceAccount, error) { return nil, ErrTokenNotFound }, func() ([]policyTY.Policy, error) { return nil, nil }, ) return &API{cache: c} diff --git a/pkg/api/policy/query_filters.go b/pkg/api/policy/query_filters.go index ee93eba..5c33135 100644 --- a/pkg/api/policy/query_filters.go +++ b/pkg/api/policy/query_filters.go @@ -151,7 +151,7 @@ func isIDKeyedKind(kind string) bool { policyTY.ResourceHandler, policyTY.ResourceDashboard, policyTY.ResourceFirmware, policyTY.ResourceForwardPayload, policyTY.ResourceDataRepository, policyTY.ResourceVirtualDevice, policyTY.ResourceVirtualAssistant, - policyTY.ResourceServiceToken, policyTY.ResourceUser, policyTY.ResourcePolicy: + policyTY.ResourceServiceAccount, policyTY.ResourceUser, policyTY.ResourcePolicy: return true default: return false @@ -178,7 +178,7 @@ func patternToFilterGroup(kind, namePattern string) []storageTY.Filter { policyTY.ResourceDataRepository, policyTY.ResourceVirtualDevice, policyTY.ResourceVirtualAssistant, - policyTY.ResourceServiceToken, + policyTY.ResourceServiceAccount, policyTY.ResourceUser, policyTY.ResourcePolicy: return idPatternFilters(types.KeyID, namePattern) diff --git a/pkg/api/policy/quickid_auth_test.go b/pkg/api/policy/quickid_auth_test.go index 43a61be..ea88d3d 100644 --- a/pkg/api/policy/quickid_auth_test.go +++ b/pkg/api/policy/quickid_auth_test.go @@ -6,7 +6,7 @@ import ( "testing" policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" ) @@ -58,7 +58,7 @@ func TestAuthorizeQuickIDRequest_DeniesNode1Field(t *testing.T) { c.setLoaders( func(id string) (*userTY.User, error) { return nil, ErrUserNotFound }, func(id string) (*policyTY.Policy, error) { return nil, ErrUserNotFound }, - func(id string) (*svcTokenTY.ServiceToken, error) { return nil, ErrTokenNotFound }, + func(id string) (*svcAccountTY.ServiceAccount, error) { return nil, ErrTokenNotFound }, func() ([]policyTY.Policy, error) { return nil, nil }, ) a := &API{cache: c} diff --git a/pkg/api/policy/token_scope_test.go b/pkg/api/policy/token_scope_test.go index 455d13a..4888957 100644 --- a/pkg/api/policy/token_scope_test.go +++ b/pkg/api/policy/token_scope_test.go @@ -4,34 +4,38 @@ import ( "testing" policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" ) -// tokenScopeAPI builds an API with one user, one policy and one service token in cache. +// tokenScopeAPI builds an API with one user, one policy and one service account in cache. func tokenScopeAPI(t *testing.T, p policyTY.Policy, tokenResources []string) *API { t.Helper() c := newCache() c.PutUser(&userTY.User{ID: "u1", Username: "u", Policies: []string{p.ID}}) cp := p c.PutPolicy(&cp) - c.PutToken(&svcTokenTY.ServiceToken{ + c.PutToken(&svcAccountTY.ServiceAccount{ ID: "t-entity", UserID: "u1", NeverExpire: true, - Token: svcTokenTY.Token{ID: "t1"}, - Resources: tokenResources, + Token: svcAccountTY.Token{ID: "t1"}, + Statements: []policyTY.Statement{{ + Effect: policyTY.EffectAllow, + Actions: []string{"*"}, + Resources: tokenResources, + }}, }) c.setLoaders( func(id string) (*userTY.User, error) { return nil, ErrUserNotFound }, func(id string) (*policyTY.Policy, error) { return nil, ErrNotFound }, - func(tokenID string) (*svcTokenTY.ServiceToken, error) { return nil, ErrTokenNotFound }, + func(tokenID string) (*svcAccountTY.ServiceAccount, error) { return nil, ErrTokenNotFound }, func() ([]policyTY.Policy, error) { return nil, nil }, ) return &API{cache: c} } -// A service token must only be able to narrow the user's scope, never widen it. +// A service account must only be able to narrow the user's scope, never widen it. func TestResourceNamesForList_TokenCanOnlyNarrow(t *testing.T) { userPolicy := policyTY.Policy{ ID: "p1", @@ -73,7 +77,7 @@ func TestResourceNamesForList_TokenCanOnlyNarrow(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { a := tokenScopeAPI(t, userPolicy, tc.tokenResources) - subject := Subject{UserID: "u1", ServiceTokenID: "t1"} + subject := Subject{UserID: "u1", ServiceAccountID: "t1"} unrestricted, patterns, err := a.ResourceNamesForList(subject, policyTY.ResourceField) if tc.wantDenied { @@ -132,7 +136,7 @@ func TestResourceNamesForList_TokenEmptiesScopeDropsDenyOnly(t *testing.T) { // token reaches the field kind (gateway is an ancestor) but names a gateway // the user policy does not cover a := tokenScopeAPI(t, userPolicy, []string{"gateway:other-gw"}) - subject := Subject{UserID: "u1", ServiceTokenID: "t1"} + subject := Subject{UserID: "u1", ServiceAccountID: "t1"} _, patterns, err := a.ResourceNamesForList(subject, policyTY.ResourceField) if err != nil { diff --git a/pkg/api/service_account/api.go b/pkg/api/service_account/api.go new file mode 100644 index 0000000..ffe2005 --- /dev/null +++ b/pkg/api/service_account/api.go @@ -0,0 +1,194 @@ +package service_account + +import ( + "context" + "errors" + "fmt" + "time" + + policyAPI "github.com/mycontroller-org/server/v2/pkg/api/policy" + types "github.com/mycontroller-org/server/v2/pkg/types" + dateTimeTY "github.com/mycontroller-org/server/v2/pkg/types/cusom_datetime" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" + "github.com/mycontroller-org/server/v2/pkg/utils" + "github.com/mycontroller-org/server/v2/pkg/utils/hashed" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" + "go.uber.org/zap" +) + +type ServiceAccountAPI struct { + ctx context.Context + logger *zap.Logger + storage storageTY.Plugin +} + +func New(ctx context.Context, logger *zap.Logger, storage storageTY.Plugin) *ServiceAccountAPI { + return &ServiceAccountAPI{ + ctx: ctx, + logger: logger.Named("service_account_api"), + storage: storage, + } +} + +func (st *ServiceAccountAPI) notifyCache(token *svcAccountTY.ServiceAccount) { + if token == nil { + return + } + policyAPI.New(st.ctx, st.logger, st.storage).NotifyTokenUpdated(token) +} + +// List by filter and pagination +func (st *ServiceAccountAPI) List(filters []storageTY.Filter, pagination *storageTY.Pagination) (*storageTY.Result, error) { + result := make([]svcAccountTY.ServiceAccount, 0) + return st.storage.Find(types.EntityServiceAccount, &result, filters, pagination) +} + +// Get returns a item +func (st *ServiceAccountAPI) Get(filters []storageTY.Filter) (svcAccountTY.ServiceAccount, error) { + result := svcAccountTY.ServiceAccount{} + err := st.storage.FindOne(types.EntityServiceAccount, &result, filters) + return result, err +} + +// GetByID returns a item +func (st *ServiceAccountAPI) GetByID(ID string) (svcAccountTY.ServiceAccount, error) { + result := svcAccountTY.ServiceAccount{} + filters := []storageTY.Filter{ + {Key: types.KeyID, Value: ID}, + } + err := st.storage.FindOne(types.EntityServiceAccount, &result, filters) + return result, err +} + +// GetByUserID returns a item +func (st *ServiceAccountAPI) GetByUserID(userID string) (svcAccountTY.ServiceAccount, error) { + result := svcAccountTY.ServiceAccount{} + filters := []storageTY.Filter{ + {Key: types.KeyUserID, Value: userID}, + } + err := st.storage.FindOne(types.EntityServiceAccount, &result, filters) + return result, err +} + +// GetByTokenID returns a item +func (st *ServiceAccountAPI) GetByTokenID(tokenID string) (svcAccountTY.ServiceAccount, error) { + result := svcAccountTY.ServiceAccount{} + filters := []storageTY.Filter{ + {Key: types.KeyTokenID, Value: tokenID}, + } + err := st.storage.FindOne(types.EntityServiceAccount, &result, filters) + return result, err +} + +// Save config into disk +func (st *ServiceAccountAPI) Save(token *svcAccountTY.ServiceAccount) error { + if token.ID == "" { + token.ID = utils.RandUUID() + } else { // get the existing entity and update token and other fields + oldToken, err := st.GetByID(token.ID) + if err != nil { + return fmt.Errorf("unable to get service account with id:%s, error:%s", token.ID, err.Error()) + } + // user tie-up is immutable + token.UserID = oldToken.UserID + token.Token = oldToken.Token + } + if token.UserID == "" { + return errors.New("user id can not be empty") + } + if token.Statements == nil { + token.Statements = []policyTY.Statement{} + } + if token.NeverExpire { + token.ExpiresOn = dateTimeTY.CustomDate{} + } + + filters := []storageTY.Filter{ + {Key: types.KeyID, Value: token.ID}, + } + + if err := st.storage.Upsert(types.EntityServiceAccount, token, filters); err != nil { + return err + } + st.notifyCache(token) + return nil +} + +// Delete items +func (st *ServiceAccountAPI) Delete(IDs []string) (int64, error) { + // load account token ids for cache invalidation + pac := policyAPI.New(st.ctx, st.logger, st.storage) + for _, id := range IDs { + if t, err := st.GetByID(id); err == nil { + pac.NotifyTokenDeleted(t.ID, t.Token.ID) + } + } + filters := []storageTY.Filter{{Key: types.KeyID, Operator: storageTY.OperatorIn, Value: IDs}} + return st.storage.Delete(types.EntityServiceAccount, filters) +} + +// creates new service account +func (st *ServiceAccountAPI) Create(newToken *svcAccountTY.ServiceAccount) (*svcAccountTY.CreateAccountResponse, error) { + if newToken.UserID == "" { + return nil, errors.New("user id can not be empty") + } + + // generate new token + generatedToken := svcAccountTY.GetNewToken() + hashedToken, err := hashed.GenerateHash(generatedToken.Token) + if err != nil { + return nil, fmt.Errorf("error on generating hash:%s", err.Error()) + } + + newToken.Token = svcAccountTY.Token{ID: generatedToken.ID, Token: hashedToken} + newToken.CreatedOn = time.Now() + if newToken.Statements == nil { + newToken.Statements = []policyTY.Statement{} + } + if newToken.NeverExpire { + newToken.ExpiresOn = dateTimeTY.CustomDate{} + } + + // Keep a caller-supplied id only when it is unused (apply --replace deletes + // first, then recreates with the same id). Never overwrite an existing row. + if newToken.ID != "" { + if _, err := st.GetByID(newToken.ID); err == nil { + newToken.ID = "" + } + } + if newToken.ID == "" { + newToken.ID = utils.RandUUID() + } + filters := []storageTY.Filter{{Key: types.KeyID, Value: newToken.ID}} + if err := st.storage.Upsert(types.EntityServiceAccount, newToken, filters); err != nil { + return nil, fmt.Errorf("error on saving service account:%s", err.Error()) + } + st.notifyCache(newToken) + + // returns generated token + return &svcAccountTY.CreateAccountResponse{ID: newToken.ID, Token: generatedToken.GetTokenWithID()}, nil +} + +func (st *ServiceAccountAPI) Import(data interface{}) error { + input, ok := data.(svcAccountTY.ServiceAccount) + if !ok { + return fmt.Errorf("invalid type:%T", data) + } + if input.ID == "" { + input.ID = utils.RandUUID() + } + + filters := []storageTY.Filter{ + {Key: types.KeyID, Value: input.ID}, + } + if err := st.storage.Upsert(types.EntityServiceAccount, &input, filters); err != nil { + return err + } + st.notifyCache(&input) + return nil +} + +func (st *ServiceAccountAPI) GetEntityInterface() interface{} { + return svcAccountTY.ServiceAccount{} +} diff --git a/pkg/api/service_token/api.go b/pkg/api/service_token/api.go deleted file mode 100644 index bcfc007..0000000 --- a/pkg/api/service_token/api.go +++ /dev/null @@ -1,189 +0,0 @@ -package service_token - -import ( - "context" - "errors" - "fmt" - "time" - - policyAPI "github.com/mycontroller-org/server/v2/pkg/api/policy" - types "github.com/mycontroller-org/server/v2/pkg/types" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" - "github.com/mycontroller-org/server/v2/pkg/utils" - "github.com/mycontroller-org/server/v2/pkg/utils/hashed" - storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" - "go.uber.org/zap" -) - -type ServiceTokenAPI struct { - ctx context.Context - logger *zap.Logger - storage storageTY.Plugin -} - -func New(ctx context.Context, logger *zap.Logger, storage storageTY.Plugin) *ServiceTokenAPI { - return &ServiceTokenAPI{ - ctx: ctx, - logger: logger.Named("service_token_api"), - storage: storage, - } -} - -func (st *ServiceTokenAPI) notifyCache(token *svcTokenTY.ServiceToken) { - if token == nil { - return - } - policyAPI.New(st.ctx, st.logger, st.storage).NotifyTokenUpdated(token) -} - -// List by filter and pagination -func (st *ServiceTokenAPI) List(filters []storageTY.Filter, pagination *storageTY.Pagination) (*storageTY.Result, error) { - result := make([]svcTokenTY.ServiceToken, 0) - return st.storage.Find(types.EntityServiceToken, &result, filters, pagination) -} - -// Get returns a item -func (st *ServiceTokenAPI) Get(filters []storageTY.Filter) (svcTokenTY.ServiceToken, error) { - result := svcTokenTY.ServiceToken{} - err := st.storage.FindOne(types.EntityServiceToken, &result, filters) - return result, err -} - -// GetByID returns a item -func (st *ServiceTokenAPI) GetByID(ID string) (svcTokenTY.ServiceToken, error) { - result := svcTokenTY.ServiceToken{} - filters := []storageTY.Filter{ - {Key: types.KeyID, Value: ID}, - } - err := st.storage.FindOne(types.EntityServiceToken, &result, filters) - return result, err -} - -// GetByUserID returns a item -func (st *ServiceTokenAPI) GetByUserID(userID string) (svcTokenTY.ServiceToken, error) { - result := svcTokenTY.ServiceToken{} - filters := []storageTY.Filter{ - {Key: types.KeyUserID, Value: userID}, - } - err := st.storage.FindOne(types.EntityServiceToken, &result, filters) - return result, err -} - -// GetByTokenID returns a item -func (st *ServiceTokenAPI) GetByTokenID(tokenID string) (svcTokenTY.ServiceToken, error) { - result := svcTokenTY.ServiceToken{} - filters := []storageTY.Filter{ - {Key: types.KeyTokenID, Value: tokenID}, - } - err := st.storage.FindOne(types.EntityServiceToken, &result, filters) - return result, err -} - -// Save config into disk -func (st *ServiceTokenAPI) Save(token *svcTokenTY.ServiceToken) error { - if token.ID == "" { - token.ID = utils.RandUUID() - } else { // get the existing entity and update token and other fields - oldToken, err := st.GetByID(token.ID) - if err != nil { - return fmt.Errorf("unable to get token with id:%s, error:%s", token.ID, err.Error()) - } - // user tie-up is immutable - token.UserID = oldToken.UserID - token.Token = oldToken.Token - } - if token.UserID == "" { - return errors.New("user id can not be empty") - } - if token.Actions == nil { - token.Actions = []string{} - } - if token.Resources == nil { - token.Resources = []string{} - } - - filters := []storageTY.Filter{ - {Key: types.KeyID, Value: token.ID}, - } - - if err := st.storage.Upsert(types.EntityServiceToken, token, filters); err != nil { - return err - } - st.notifyCache(token) - return nil -} - -// Delete items -func (st *ServiceTokenAPI) Delete(IDs []string) (int64, error) { - // load token ids for cache invalidation - pac := policyAPI.New(st.ctx, st.logger, st.storage) - for _, id := range IDs { - if t, err := st.GetByID(id); err == nil { - pac.NotifyTokenDeleted(t.ID, t.Token.ID) - } - } - filters := []storageTY.Filter{{Key: types.KeyID, Operator: storageTY.OperatorIn, Value: IDs}} - return st.storage.Delete(types.EntityServiceToken, filters) -} - -// creates new token -func (st *ServiceTokenAPI) Create(newToken *svcTokenTY.ServiceToken) (*svcTokenTY.CreateTokenResponse, error) { - if newToken.UserID == "" { - return nil, errors.New("user id can not be empty") - } - - // remove token id, will be generated - newToken.ID = "" - - // generate new token - generatedToken := svcTokenTY.GetNewToken() - hashedToken, err := hashed.GenerateHash(generatedToken.Token) - if err != nil { - return nil, fmt.Errorf("error on generating hash:%s", err.Error()) - } - - newToken.Token = svcTokenTY.Token{ID: generatedToken.ID, Token: hashedToken} - newToken.CreatedOn = time.Now() - if newToken.Actions == nil { - newToken.Actions = []string{} - } - if newToken.Resources == nil { - newToken.Resources = []string{} - } - - // Save would try to reload by ID - for create set ID first then upsert without old-token branch - if newToken.ID == "" { - newToken.ID = utils.RandUUID() - } - filters := []storageTY.Filter{{Key: types.KeyID, Value: newToken.ID}} - if err := st.storage.Upsert(types.EntityServiceToken, newToken, filters); err != nil { - return nil, fmt.Errorf("error on saving token:%s", err.Error()) - } - st.notifyCache(newToken) - - // returns generated token - return &svcTokenTY.CreateTokenResponse{ID: newToken.ID, Token: generatedToken.GetTokenWithID()}, nil -} - -func (st *ServiceTokenAPI) Import(data interface{}) error { - input, ok := data.(svcTokenTY.ServiceToken) - if !ok { - return fmt.Errorf("invalid type:%T", data) - } - if input.ID == "" { - input.ID = utils.RandUUID() - } - - filters := []storageTY.Filter{ - {Key: types.KeyID, Value: input.ID}, - } - if err := st.storage.Upsert(types.EntityServiceToken, &input, filters); err != nil { - return err - } - st.notifyCache(&input) - return nil -} - -func (st *ServiceTokenAPI) GetEntityInterface() interface{} { - return svcTokenTY.ServiceToken{} -} diff --git a/pkg/backup/backup_map.go b/pkg/backup/backup_map.go index 36833ed..5dc47c6 100644 --- a/pkg/backup/backup_map.go +++ b/pkg/backup/backup_map.go @@ -34,7 +34,7 @@ func GetStorageApiMap(ctx context.Context) (map[string]backupTY.Backup, error) { types.EntityUser: entities.User(), types.EntityVirtualAssistant: entities.VirtualAssistant(), types.EntityVirtualDevice: entities.VirtualDevice(), - types.EntityServiceToken: entities.ServiceToken(), + types.EntityServiceAccount: entities.ServiceAccount(), types.EntityPolicy: entities.Policy(), } diff --git a/pkg/http_router/middleware/auth.go b/pkg/http_router/middleware/auth.go index 98fedcb..21ade03 100644 --- a/pkg/http_router/middleware/auth.go +++ b/pkg/http_router/middleware/auth.go @@ -67,9 +67,9 @@ func getAccessControl() *policyAPI.API { // struct used in api request type McApiContext struct { - Tenant string `json:"tenant" yaml:"tenant"` - UserID string `json:"userId" yaml:"userId"` - ServiceTokenID string `json:"serviceTokenId" yaml:"serviceTokenId"` + Tenant string `json:"tenant" yaml:"tenant"` + UserID string `json:"userId" yaml:"userId"` + ServiceAccountID string `json:"serviceAccountId" yaml:"serviceAccountId"` } // MiddlewareAuthenticationVerification verifies user auth details @@ -105,7 +105,7 @@ func MiddlewareAuthenticationVerification(next http.Handler) http.Handler { } // authentication required if mcApiContext, err := IsValidToken(r); err == nil { - // verify user still active (cached) and service token still valid + // verify user still active (cached) and service account still valid if err := verifyPrincipalActive(mcApiContext); err != nil { w.Header().Set("Content-Type", "application/json") handlerUtils.PostErrorResponse(w, "401 Unauthorized", http.StatusUnauthorized) @@ -162,7 +162,7 @@ func verifyPrincipalActive(mc *McApiContext) error { if _, err := ac.EnsureUserActive(mc.UserID); err != nil { return err } - if err := ac.EnsureServiceTokenActive(mc.UserID, mc.ServiceTokenID); err != nil { + if err := ac.EnsureServiceAccountActive(mc.UserID, mc.ServiceAccountID); err != nil { return err } return nil @@ -188,7 +188,7 @@ func authorizeRequest(mc *McApiContext, r *http.Request) error { if isOwnProfilePath(r.URL.Path) { return nil } - subject := policyAPI.Subject{UserID: mc.UserID, ServiceTokenID: mc.ServiceTokenID} + subject := policyAPI.Subject{UserID: mc.UserID, ServiceAccountID: mc.ServiceAccountID} // Metrics: enforce per target field/node/gateway (quick_id or body tags.id), not bare "metric" if access.Kind == policyTY.ResourceMetric { @@ -247,7 +247,7 @@ func IsValidToken(r *http.Request) (*McApiContext, error) { // clear userID / svc token headers, might be injected from external r.Header.Del(handlerTY.HeaderUserID) - r.Header.Del(handlerTY.HeaderServiceTokenID) + r.Header.Del(handlerTY.HeaderServiceAccountID) userID := "" if v, ok := claims[handlerTY.KeyUserID]; ok { @@ -257,18 +257,18 @@ func IsValidToken(r *http.Request) (*McApiContext, error) { } } - svcTokenID := "" - if v, ok := claims[handlerTY.KeyServiceTokenID]; ok { + svcAccountID := "" + if v, ok := claims[handlerTY.KeyServiceAccountID]; ok { if id, ok := v.(string); ok && id != "" { - svcTokenID = id - r.Header.Set(handlerTY.HeaderServiceTokenID, id) + svcAccountID = id + r.Header.Set(handlerTY.HeaderServiceAccountID, id) } } mcApiContext := McApiContext{ - Tenant: "", - UserID: userID, - ServiceTokenID: svcTokenID, + Tenant: "", + UserID: userID, + ServiceAccountID: svcAccountID, } return &mcApiContext, nil @@ -326,7 +326,7 @@ func extractJwtToken(r *http.Request) string { } // CreateToken creates a token for a user -func CreateToken(user user.User, expiresIn, svcTokenID string) (string, error) { +func CreateToken(user user.User, expiresIn, svcAccountID string) (string, error) { if user.Disabled { return "", errors.New("user is disabled") } @@ -335,7 +335,7 @@ func CreateToken(user user.User, expiresIn, svcTokenID string) (string, error) { atClaims[handlerTY.KeyAuthorized] = true atClaims[handlerTY.KeyUserID] = user.ID atClaims[handlerTY.KeyFullName] = user.FullName - atClaims[handlerTY.KeyServiceTokenID] = svcTokenID + atClaims[handlerTY.KeyServiceAccountID] = svcAccountID expiresInDuration := handlerTY.DefaultTokenExpiration @@ -362,9 +362,9 @@ func GetUserID(r *http.Request) string { return r.Header.Get(handlerTY.HeaderUserID) } -// GetServiceTokenID returns service token id from request (if login used a service token) -func GetServiceTokenID(r *http.Request) string { - return r.Header.Get(handlerTY.HeaderServiceTokenID) +// GetServiceAccountID returns the service account token id from the request (if login used a service account) +func GetServiceAccountID(r *http.Request) string { + return r.Header.Get(handlerTY.HeaderServiceAccountID) } // GetAPIContext returns McApiContext from request context if present @@ -387,7 +387,7 @@ func SubjectFromRequest(r *http.Request) (policyAPI.Subject, error) { if mc == nil || mc.UserID == "" { return policyAPI.Subject{}, errors.New("unauthenticated request") } - return policyAPI.Subject{UserID: mc.UserID, ServiceTokenID: mc.ServiceTokenID}, nil + return policyAPI.Subject{UserID: mc.UserID, ServiceAccountID: mc.ServiceAccountID}, nil } func getJwtSecret() []byte { diff --git a/pkg/http_router/routes/auth/auth.go b/pkg/http_router/routes/auth/auth.go index 3380b91..e3b69a6 100644 --- a/pkg/http_router/routes/auth/auth.go +++ b/pkg/http_router/routes/auth/auth.go @@ -7,7 +7,7 @@ import ( "github.com/gorilla/mux" entityAPI "github.com/mycontroller-org/server/v2/pkg/api/entities" middleware "github.com/mycontroller-org/server/v2/pkg/http_router/middleware" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" handlerTY "github.com/mycontroller-org/server/v2/pkg/types/web_handler" "github.com/mycontroller-org/server/v2/pkg/utils/hashed" @@ -48,18 +48,18 @@ func (a *AuthRoutes) login(w http.ResponseWriter, r *http.Request) { } var userInDB userTY.User - var svcTokenID string + var svcAccountID string // if token available, it is token based authentication - if login.SvcToken != "" { - parsedToken, err := svcTokenTY.ParseToken(login.SvcToken) + if login.ServiceAccountToken != "" { + parsedToken, err := svcAccountTY.ParseToken(login.ServiceAccountToken) if err != nil { handlerUtils.PostErrorResponse(w, "invalid token", http.StatusUnauthorized) return } // get actual token - actualToken, err := a.api.ServiceToken().GetByTokenID(parsedToken.ID) + actualToken, err := a.api.ServiceAccount().GetByTokenID(parsedToken.ID) if err != nil { handlerUtils.PostErrorResponse(w, "invalid token", http.StatusUnauthorized) return @@ -86,7 +86,7 @@ func (a *AuthRoutes) login(w http.ResponseWriter, r *http.Request) { return } userInDB = _userInDB - svcTokenID = parsedToken.ID + svcAccountID = parsedToken.ID } else { // user based authentication // get user details _userInDB, err := a.api.User().GetByUsername(login.Username) @@ -108,7 +108,7 @@ func (a *AuthRoutes) login(w http.ResponseWriter, r *http.Request) { return } - token, err := middleware.CreateToken(userInDB, login.ExpiresIn, svcTokenID) + token, err := middleware.CreateToken(userInDB, login.ExpiresIn, svcAccountID) if err != nil { handlerUtils.PostErrorResponse(w, err.Error(), http.StatusInternalServerError) return diff --git a/pkg/http_router/routes/auth/oauth.go b/pkg/http_router/routes/auth/oauth.go index 5877013..268d3fa 100644 --- a/pkg/http_router/routes/auth/oauth.go +++ b/pkg/http_router/routes/auth/oauth.go @@ -60,45 +60,45 @@ func (oa *OAuthRoutes) login(w http.ResponseWriter, r *http.Request) { } userLogin := handlerTY.UserLogin{ - Username: credentials.Get("username"), - Password: credentials.Get("password"), - SvcToken: credentials.Get("token"), - ExpiresIn: "168h", // 7 days + Username: credentials.Get("username"), + Password: credentials.Get("password"), + ServiceAccountToken: credentials.Get("token"), + ExpiresIn: "168h", // 7 days } var userInDB userTY.User - var svcTokenID string + var svcAccountID string // if token available, it is token based authentication - if userLogin.SvcToken != "" { + if userLogin.ServiceAccountToken != "" { // get hashed token - hashedToken, err := hashed.GenerateHash(userLogin.SvcToken) + hashedToken, err := hashed.GenerateHash(userLogin.ServiceAccountToken) if err != nil { handlerUtils.PostErrorResponse(w, "invalid token", http.StatusUnauthorized) return } // verify token - svcToken, err := oa.api.ServiceToken().GetByTokenID(hashedToken) + svcAccount, err := oa.api.ServiceAccount().GetByTokenID(hashedToken) if err != nil { handlerUtils.PostErrorResponse(w, "invalid token", http.StatusUnauthorized) return } // verify validity - if svcToken.ExpiresOn.After(time.Now()) { + if svcAccount.ExpiresOn.After(time.Now()) { handlerUtils.PostErrorResponse(w, "invalid token", http.StatusUnauthorized) return } // get user details - _userInDB, err := oa.api.User().GetByID(svcToken.UserID) + _userInDB, err := oa.api.User().GetByID(svcAccount.UserID) if err != nil { handlerUtils.PostErrorResponse(w, "invalid token", http.StatusUnauthorized) return } userInDB = _userInDB - svcTokenID = svcToken.Token.ID + svcAccountID = svcAccount.Token.ID } else { // user based authentication // get user details _userInDB, err := oa.api.User().GetByUsername(userLogin.Username) @@ -120,7 +120,7 @@ func (oa *OAuthRoutes) login(w http.ResponseWriter, r *http.Request) { return } - accessToken, err := middleware.CreateToken(userInDB, userLogin.ExpiresIn, svcTokenID) + accessToken, err := middleware.CreateToken(userInDB, userLogin.ExpiresIn, svcAccountID) if err != nil { handlerUtils.PostErrorResponse(w, err.Error(), http.StatusInternalServerError) return diff --git a/pkg/http_router/routes/routes.go b/pkg/http_router/routes/routes.go index 642949e..73513e6 100644 --- a/pkg/http_router/routes/routes.go +++ b/pkg/http_router/routes/routes.go @@ -103,7 +103,7 @@ func New(ctx context.Context, router *mux.Router, enableProfiling bool) (*Routes routes.registerPolicyRoutes() routes.registerQuickIDRoutes() routes.registerSchedulerRoutes() - routes.registerServiceTokenRoutes() + routes.registerServiceAccountRoutes() routes.registerSourceRoutes() routes.registerStatusRoutes() routes.registerSystemRoutes() diff --git a/pkg/http_router/routes/service_account.go b/pkg/http_router/routes/service_account.go new file mode 100644 index 0000000..90450af --- /dev/null +++ b/pkg/http_router/routes/service_account.go @@ -0,0 +1,191 @@ +package routes + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gorilla/mux" + middleware "github.com/mycontroller-org/server/v2/pkg/http_router/middleware" + types "github.com/mycontroller-org/server/v2/pkg/types" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" + userTY "github.com/mycontroller-org/server/v2/pkg/types/user" + handlerUtils "github.com/mycontroller-org/server/v2/pkg/utils/http_handler" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" +) + +// registers service account routes +func (h *Routes) registerServiceAccountRoutes() { + h.router.HandleFunc("/api/serviceaccount", h.listServiceAccount).Methods(http.MethodGet) + h.router.HandleFunc("/api/serviceaccount/{id}", h.getServiceAccount).Methods(http.MethodGet) + h.router.HandleFunc("/api/serviceaccount/create", h.createServiceAccount).Methods(http.MethodPost) + h.router.HandleFunc("/api/serviceaccount/update", h.updateServiceAccount).Methods(http.MethodPost) + h.router.HandleFunc("/api/serviceaccount", h.deleteServiceAccount).Methods(http.MethodDelete) +} + +// ownedByCaller limits list results to the logged-in user. Callers who can +// manage users (kind-wide user update) see every service account. +func (h *Routes) ownedByCaller(r *http.Request) []storageTY.Filter { + return []storageTY.Filter{{Key: types.KeyUserID, Value: middleware.GetUserID(r)}} +} + +func (h *Routes) canManageOtherUsers(r *http.Request) bool { + subject, err := middleware.SubjectFromRequest(r) + if err != nil { + return false + } + return h.api.Policy().AllowedKindWide(subject, policyTY.ActionUpdate, policyTY.ResourceUser) == nil +} + +func (h *Routes) lookupUser(ref string) (*userTY.User, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, errors.New("user not found") + } + if user, err := h.api.User().GetByID(ref); err == nil && user.ID != "" { + return &user, nil + } + if user, err := h.api.User().GetByUsername(ref); err == nil && user.ID != "" { + return &user, nil + } + return nil, errors.New("user not found") +} + +// resolveOwner returns the user id the service account should belong to. +// Empty userId/username means the caller. Creating for someone else requires +// kind-wide user update (admin / user manager). +func (h *Routes) resolveOwner(r *http.Request, userID, username string) (*userTY.User, error) { + callerID := middleware.GetUserID(r) + ref := strings.TrimSpace(userID) + if ref == "" { + ref = strings.TrimSpace(username) + } + if ref == "" || ref == callerID { + user, err := h.api.User().GetByID(callerID) + if err != nil { + return nil, err + } + return &user, nil + } + user, err := h.lookupUser(ref) + if err != nil { + return nil, err + } + if user.ID != callerID && !h.canManageOtherUsers(r) { + return nil, errors.New("not allowed to create a service account for another user") + } + return user, nil +} + +func (h *Routes) listServiceAccount(w http.ResponseWriter, r *http.Request) { + entityFn := func(f []storageTY.Filter, p *storageTY.Pagination) (interface{}, error) { + filters := f + if !h.canManageOtherUsers(r) { + filters = append(filters, h.ownedByCaller(r)...) + } + return h.api.ServiceAccount().List(filters, p) + } + handlerUtils.LoadData(w, r, entityFn) +} + +func (h *Routes) getServiceAccount(w http.ResponseWriter, r *http.Request) { + token, err := h.loadServiceAccount(r, mux.Vars(r)["id"]) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + handlerUtils.PostSuccessResponse(w, token) +} + +// loadServiceAccount loads an account if the caller owns it or can manage users. +func (h *Routes) loadServiceAccount(r *http.Request, id string) (*svcAccountTY.ServiceAccount, error) { + if id == "" { + return nil, errors.New("id should not be an empty") + } + token, err := h.api.ServiceAccount().GetByID(id) + if err != nil { + return nil, err + } + if token.UserID != middleware.GetUserID(r) && !h.canManageOtherUsers(r) { + return nil, errors.New("service account not found") + } + return &token, nil +} + +func (h *Routes) updateServiceAccount(w http.ResponseWriter, r *http.Request) { + entity := &svcAccountTY.ServiceAccount{} + err := handlerUtils.LoadEntity(w, r, entity) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + existing, err := h.loadServiceAccount(r, entity.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + // owner is immutable + entity.UserID = existing.UserID + entity.Username = existing.Username + if user, err := h.api.User().GetByID(existing.UserID); err == nil { + entity.Username = user.Username + } + + err = h.api.ServiceAccount().Save(entity) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *Routes) createServiceAccount(w http.ResponseWriter, r *http.Request) { + entity := &svcAccountTY.ServiceAccount{} + err := handlerUtils.LoadEntity(w, r, entity) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + owner, err := h.resolveOwner(r, entity.UserID, entity.Username) + if err != nil { + status := http.StatusBadRequest + if err.Error() == "not allowed to create a service account for another user" { + status = http.StatusForbidden + } + http.Error(w, err.Error(), status) + return + } + entity.UserID = owner.ID + entity.Username = owner.Username + + generatedToken, err := h.api.ServiceAccount().Create(entity) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + handlerUtils.PostSuccessResponse(w, generatedToken) +} + +func (h *Routes) deleteServiceAccount(w http.ResponseWriter, r *http.Request) { + IDs := []string{} + updateFn := func(f []storageTY.Filter, p *storageTY.Pagination, d []byte) (interface{}, error) { + if len(IDs) == 0 { + return nil, errors.New("supply id(s)") + } + for _, id := range IDs { + if _, err := h.loadServiceAccount(r, id); err != nil { + return nil, err + } + } + count, err := h.api.ServiceAccount().Delete(IDs) + if err != nil { + return nil, err + } + return fmt.Sprintf("deleted: %d", count), nil + } + handlerUtils.UpdateData(w, r, &IDs, updateFn) +} diff --git a/pkg/http_router/routes/service_token.go b/pkg/http_router/routes/service_token.go deleted file mode 100644 index 96673db..0000000 --- a/pkg/http_router/routes/service_token.go +++ /dev/null @@ -1,127 +0,0 @@ -package routes - -import ( - "errors" - "fmt" - "net/http" - - "github.com/gorilla/mux" - middleware "github.com/mycontroller-org/server/v2/pkg/http_router/middleware" - types "github.com/mycontroller-org/server/v2/pkg/types" - svcTokenTY "github.com/mycontroller-org/server/v2/pkg/types/service_token" - handlerUtils "github.com/mycontroller-org/server/v2/pkg/utils/http_handler" - storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" -) - -// registers service token routes -func (h *Routes) registerServiceTokenRoutes() { - h.router.HandleFunc("/api/servicetoken", h.listServiceToken).Methods(http.MethodGet) - h.router.HandleFunc("/api/servicetoken/{id}", h.getServiceToken).Methods(http.MethodGet) - h.router.HandleFunc("/api/servicetoken/create", h.createServiceToken).Methods(http.MethodPost) - h.router.HandleFunc("/api/servicetoken/update", h.updateServiceToken).Methods(http.MethodPost) - h.router.HandleFunc("/api/servicetoken", h.deleteServiceToken).Methods(http.MethodDelete) -} - -// Service tokens are personal credentials: a token acts as its owner. They are -// therefore always scoped to the caller, whatever the caller's policies say - -// otherwise one principal could read, widen (drop the action/resource limits, set -// neverExpire) or delete another principal's credentials. -func (h *Routes) ownedByCaller(r *http.Request) []storageTY.Filter { - return []storageTY.Filter{{Key: types.KeyUserID, Value: middleware.GetUserID(r)}} -} - -func (h *Routes) listServiceToken(w http.ResponseWriter, r *http.Request) { - entityFn := func(f []storageTY.Filter, p *storageTY.Pagination) (interface{}, error) { - return h.api.ServiceToken().List(append(f, h.ownedByCaller(r)...), p) - } - handlerUtils.LoadData(w, r, entityFn) -} - -func (h *Routes) getServiceToken(w http.ResponseWriter, r *http.Request) { - token, err := h.callerToken(r, mux.Vars(r)["id"]) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - handlerUtils.PostSuccessResponse(w, token) -} - -// callerToken loads a token and verifies the caller owns it. -func (h *Routes) callerToken(r *http.Request, id string) (*svcTokenTY.ServiceToken, error) { - if id == "" { - return nil, errors.New("id should not be an empty") - } - token, err := h.api.ServiceToken().GetByID(id) - if err != nil { - return nil, err - } - if token.UserID != middleware.GetUserID(r) { - // do not disclose that the id exists - return nil, errors.New("service token not found") - } - return &token, nil -} - -func (h *Routes) updateServiceToken(w http.ResponseWriter, r *http.Request) { - entity := &svcTokenTY.ServiceToken{} - err := handlerUtils.LoadEntity(w, r, entity) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if _, err := h.callerToken(r, entity.ID); err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - // update userId - entity.UserID = middleware.GetUserID(r) - - err = h.api.ServiceToken().Save(entity) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - -func (h *Routes) createServiceToken(w http.ResponseWriter, r *http.Request) { - entity := &svcTokenTY.ServiceToken{} - err := handlerUtils.LoadEntity(w, r, entity) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // update userId - entity.UserID = middleware.GetUserID(r) - - generatedToken, err := h.api.ServiceToken().Create(entity) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // return generated token - handlerUtils.PostSuccessResponse(w, generatedToken) -} - -func (h *Routes) deleteServiceToken(w http.ResponseWriter, r *http.Request) { - IDs := []string{} - updateFn := func(f []storageTY.Filter, p *storageTY.Pagination, d []byte) (interface{}, error) { - if len(IDs) == 0 { - return nil, errors.New("supply id(s)") - } - // only the owner's tokens may be deleted - for _, id := range IDs { - if _, err := h.callerToken(r, id); err != nil { - return nil, err - } - } - count, err := h.api.ServiceToken().Delete(IDs) - if err != nil { - return nil, err - } - return fmt.Sprintf("deleted: %d", count), nil - } - handlerUtils.UpdateData(w, r, &IDs, updateFn) -} diff --git a/pkg/http_router/routes/user.go b/pkg/http_router/routes/user.go index 3b466a5..5f1fad7 100644 --- a/pkg/http_router/routes/user.go +++ b/pkg/http_router/routes/user.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gorilla/mux" + middleware "github.com/mycontroller-org/server/v2/pkg/http_router/middleware" userTY "github.com/mycontroller-org/server/v2/pkg/types/user" handlerUtils "github.com/mycontroller-org/server/v2/pkg/utils/http_handler" storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" @@ -69,6 +70,10 @@ func (h *Routes) updateUser(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if entity.ID != "" && entity.Disabled != nil && *entity.Disabled && entity.ID == middleware.GetUserID(r) { + http.Error(w, "cannot disable the current user", http.StatusBadRequest) + return + } if entity.ID == "" { // create new user from admin update payload disabled := false @@ -108,14 +113,20 @@ func (h *Routes) createUserWithPassword(user *userTY.User, plainPassword string) func (h *Routes) deleteUsers(w http.ResponseWriter, r *http.Request) { IDs := make([]string, 0) updateFn := func(f []storageTY.Filter, p *storageTY.Pagination, d []byte) (interface{}, error) { - if len(IDs) > 0 { - count, err := h.api.User().Delete(IDs) - if err != nil { - return nil, err + if len(IDs) == 0 { + return nil, errors.New("supply id(s)") + } + callerID := middleware.GetUserID(r) + for _, id := range IDs { + if id == callerID { + return nil, errors.New("cannot delete the current user") } - return count, nil } - return nil, errors.New("supply id(s)") + count, err := h.api.User().Delete(IDs) + if err != nil { + return nil, err + } + return count, nil } handlerUtils.UpdateData(w, r, &IDs, updateFn) } diff --git a/pkg/types/entities.go b/pkg/types/entities.go index 634dea1..7f5ccfd 100644 --- a/pkg/types/entities.go +++ b/pkg/types/entities.go @@ -17,7 +17,7 @@ const ( EntityDataRepository = "data_repository" // holds user data, can be used across EntityVirtualDevice = "virtual_device" // holds virtual devices EntityVirtualAssistant = "virtual_assistant" // holds virtual assistants - EntityServiceToken = "service_token" // holds service token + EntityServiceAccount = "service_account" // holds service account EntityPolicy = "policy" // access control policies ) diff --git a/pkg/types/policy/types.go b/pkg/types/policy/types.go index adc6c3f..c184d9c 100644 --- a/pkg/types/policy/types.go +++ b/pkg/types/policy/types.go @@ -49,7 +49,7 @@ const ( ResourceDataRepository = "datarepository" ResourceVirtualDevice = "virtualdevice" ResourceVirtualAssistant = "virtualassistant" - ResourceServiceToken = "servicetoken" + ResourceServiceAccount = "serviceaccount" ResourceSettings = "settings" ResourceBackup = "backup" ResourceUser = "user" @@ -82,8 +82,8 @@ func NormalizeKind(value string) string { return ResourceVirtualDevice case "virtual_assistant", "virtualassistant": return ResourceVirtualAssistant - case "service_token", "servicetoken": - return ResourceServiceToken + case "service_account", "serviceaccount", "service_token", "servicetoken": + return ResourceServiceAccount default: return s } diff --git a/pkg/types/service_token/types.go b/pkg/types/service_account/types.go similarity index 79% rename from pkg/types/service_token/types.go rename to pkg/types/service_account/types.go index 8c42953..6f5b498 100644 --- a/pkg/types/service_token/types.go +++ b/pkg/types/service_account/types.go @@ -1,4 +1,4 @@ -package service_token +package service_account import ( "errors" @@ -8,12 +8,14 @@ import ( "github.com/mycontroller-org/server/v2/pkg/types/cmap" dateTimeTY "github.com/mycontroller-org/server/v2/pkg/types/cusom_datetime" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" "github.com/mycontroller-org/server/v2/pkg/utils" ) -type ServiceToken struct { +type ServiceAccount struct { ID string `json:"id" yaml:"id"` UserID string `json:"userId" yaml:"userId"` // always tied to a user; permissions cannot exceed this user + Username string `json:"username" yaml:"username"` Name string `json:"name" yaml:"name"` Description string `json:"description" yaml:"description"` Token Token `json:"token" yaml:"token"` // keeps hashed token, not the actual token @@ -21,13 +23,12 @@ type ServiceToken struct { ExpiresOn dateTimeTY.CustomDate `json:"expiresOn" yaml:"expiresOn"` // Optional restrictions - empty means same access as the owning user. // When set, effective access = user policies ∩ these limits (can only lower). - Actions []string `json:"actions" yaml:"actions"` - Resources []string `json:"resources" yaml:"resources"` - Labels cmap.CustomStringMap `json:"labels" yaml:"labels"` - CreatedOn time.Time `json:"createdOn" yaml:"createdOn"` + Statements []policyTY.Statement `json:"statements" yaml:"statements"` + Labels cmap.CustomStringMap `json:"labels" yaml:"labels"` + CreatedOn time.Time `json:"createdOn" yaml:"createdOn"` } -type CreateTokenResponse struct { +type CreateAccountResponse struct { ID string `json:"id" yaml:"id"` Token string `json:"token" yaml:"token"` } diff --git a/pkg/types/web_handler/types.go b/pkg/types/web_handler/types.go index fe8a17b..11c13c3 100644 --- a/pkg/types/web_handler/types.go +++ b/pkg/types/web_handler/types.go @@ -4,15 +4,15 @@ import "time" // global constants const ( - KeyUserID = "user_id" - KeyServiceTokenID = "svc_token_id" - KeyFullName = "fullname" - KeyAuthorized = "authorized" - KeyExpiresAt = "expires_at" + KeyUserID = "user_id" + KeyServiceAccountID = "svc_account_id" + KeyFullName = "fullname" + KeyAuthorized = "authorized" + KeyExpiresAt = "expires_at" - HeaderAuthorization = "Authorization" - HeaderUserID = "mc_userid" - HeaderServiceTokenID = "mc_svc_token_id" + HeaderAuthorization = "Authorization" + HeaderUserID = "mc_userid" + HeaderServiceAccountID = "mc_svc_account_id" AccessToken = "access_token" @@ -26,10 +26,10 @@ const ( // UserLogin struct type UserLogin struct { - Username string `json:"username" yaml:"username"` - Password string `json:"password" yaml:"password"` - SvcToken string `json:"token" yaml:"token"` - ExpiresIn string `json:"expiresIn" yaml:"expiresIn"` + Username string `json:"username" yaml:"username"` + Password string `json:"password" yaml:"password"` + ServiceAccountToken string `json:"token" yaml:"token"` + ExpiresIn string `json:"expiresIn" yaml:"expiresIn"` } // JwtToken struct diff --git a/pkg/upgrade/restore_api_update.go b/pkg/upgrade/restore_api_update.go index 362a810..37126dd 100644 --- a/pkg/upgrade/restore_api_update.go +++ b/pkg/upgrade/restore_api_update.go @@ -4,6 +4,7 @@ import ( "context" semver "github.com/Masterminds/semver/v3" + "github.com/mycontroller-org/server/v2/pkg/types" backupTY "github.com/mycontroller-org/server/v2/plugin/database/storage/backup" storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" "go.uber.org/zap" @@ -30,7 +31,16 @@ func UpdateStorageRestoreApiMap(ctx context.Context, logger *zap.Logger, storage // there is change on version 2.1.1 on "virtual_devices" if updatedBackupSemver.LessThan(semver.MustParse("2.1.1")) { logger.Info("backup is from 2.1.0 or lower version of server, updating required schema changes") - return updateRestoreApiMap_2_1_1(ctx, logger, storage, apiMap) + var err error + apiMap, err = updateRestoreApiMap_2_1_1(ctx, logger, storage, apiMap) + if err != nil { + return nil, err + } + } + + // service_token was renamed to service_account + if api, ok := apiMap[types.EntityServiceAccount]; ok { + apiMap[oldServiceTokenEntity] = api } return apiMap, nil diff --git a/pkg/upgrade/v2_2_0__2.go b/pkg/upgrade/v2_2_0__2.go new file mode 100644 index 0000000..957d5bb --- /dev/null +++ b/pkg/upgrade/v2_2_0__2.go @@ -0,0 +1,118 @@ +package upgrade + +import ( + "context" + "strings" + + entitiesAPI "github.com/mycontroller-org/server/v2/pkg/api/entities" + "github.com/mycontroller-org/server/v2/pkg/types" + policyTY "github.com/mycontroller-org/server/v2/pkg/types/policy" + svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" + storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types" + "go.uber.org/zap" +) + +const oldServiceTokenEntity = "service_token" + +// Rename storage entity service_token → service_account and rewrite policy +// resource kinds servicetoken → serviceaccount. +func upgrade_2_2_0__2(ctx context.Context, logger *zap.Logger, storage storageTY.Plugin, api *entitiesAPI.API) error { + if err := migrateServiceTokenEntity(logger, storage); err != nil { + return err + } + if err := rewriteServiceTokenPolicyResources(logger, storage); err != nil { + return err + } + if err := api.Policy().EnsureBuiltInPolicies(); err != nil { + logger.Error("error on refreshing built-in policies", zap.Error(err)) + return err + } + return nil +} + +func migrateServiceTokenEntity(logger *zap.Logger, storage storageTY.Plugin) error { + accounts := make([]svcAccountTY.ServiceAccount, 0) + result, err := storage.Find(oldServiceTokenEntity, &accounts, []storageTY.Filter{}, &storageTY.Pagination{}) + if err != nil { + logger.Info("no service_token entity to migrate", zap.Error(err)) + return nil + } + data, ok := result.Data.(*[]svcAccountTY.ServiceAccount) + if !ok { + data = &accounts + } + if len(*data) == 0 { + return nil + } + + ids := make([]string, 0, len(*data)) + for i := range *data { + account := (*data)[i] + filters := []storageTY.Filter{{Key: types.KeyID, Value: account.ID}} + if err := storage.Upsert(types.EntityServiceAccount, &account, filters); err != nil { + logger.Error("error on migrating service account", zap.String("id", account.ID), zap.Error(err)) + return err + } + ids = append(ids, account.ID) + } + if _, err := storage.Delete(oldServiceTokenEntity, []storageTY.Filter{ + {Key: types.KeyID, Operator: storageTY.OperatorIn, Value: ids}, + }); err != nil { + logger.Error("error on deleting migrated service_token rows", zap.Error(err)) + return err + } + logger.Info("migrated service_token rows to service_account", zap.Int("count", len(ids))) + return nil +} + +func rewriteServiceTokenPolicyResources(logger *zap.Logger, storage storageTY.Plugin) error { + policies := make([]policyTY.Policy, 0) + result, err := storage.Find(types.EntityPolicy, &policies, []storageTY.Filter{}, &storageTY.Pagination{}) + if err != nil { + logger.Info("no policies to rewrite", zap.Error(err)) + return nil + } + data, ok := result.Data.(*[]policyTY.Policy) + if !ok { + data = &policies + } + + updated := 0 + for i := range *data { + policy := (*data)[i] + changed := false + for si := range policy.Statements { + for ri, resource := range policy.Statements[si].Resources { + rewritten := rewriteServiceTokenResource(resource) + if rewritten != resource { + policy.Statements[si].Resources[ri] = rewritten + changed = true + } + } + } + if !changed { + continue + } + filters := []storageTY.Filter{{Key: types.KeyID, Value: policy.ID}} + if err := storage.Upsert(types.EntityPolicy, &policy, filters); err != nil { + logger.Error("error on rewriting policy resources", zap.String("id", policy.ID), zap.Error(err)) + return err + } + updated++ + } + if updated > 0 { + logger.Info("rewrote servicetoken policy resources to serviceaccount", zap.Int("count", updated)) + } + return nil +} + +func rewriteServiceTokenResource(resource string) string { + switch { + case resource == "servicetoken" || strings.HasPrefix(resource, "servicetoken:"): + return "serviceaccount" + strings.TrimPrefix(resource, "servicetoken") + case resource == "service_token" || strings.HasPrefix(resource, "service_token:"): + return "serviceaccount" + strings.TrimPrefix(resource, "service_token") + default: + return resource + } +} diff --git a/pkg/upgrade/versions.go b/pkg/upgrade/versions.go index f946891..fa99b00 100644 --- a/pkg/upgrade/versions.go +++ b/pkg/upgrade/versions.go @@ -17,4 +17,5 @@ var upgrades = map[string]upgradeFunction{ "2.0.0-1": upgrade_2_0_0__1, // 2.0.0 upgrade #1 "2.1.1-1": upgrade_2_1_1__1, // 2.1.1 upgrade #2 "2.2.0-1": upgrade_2_2_0__1, // 2.2.0: RBAC policies + admin for existing users + "2.2.0-2": upgrade_2_2_0__2, // 2.2.0: rename service_token entity to service_account } diff --git a/pkg/utils/filter_sort/utils_filter.go b/pkg/utils/filter_sort/utils_filter.go index 77ec34f..ca7e345 100644 --- a/pkg/utils/filter_sort/utils_filter.go +++ b/pkg/utils/filter_sort/utils_filter.go @@ -82,6 +82,9 @@ func IsMatching(entity interface{}, filters []storageTY.Filter) bool { case reflect.Bool: match = CompareBool(value, filter.Operator, filter.Value) + case reflect.Slice, reflect.Array: + match = CompareStringSliceContains(value, filter.Operator, filter.Value) + case reflect.Struct: timeValue, ok := value.(time.Time) if !ok { @@ -221,6 +224,71 @@ func CompareString(value interface{}, operator string, filterValue interface{}) return false } +// CompareStringSliceContains matches a stored string slice (e.g. user.policies) +// against a scalar or list filter value. +func CompareStringSliceContains(value interface{}, operator string, filterValue interface{}) bool { + items := stringSliceOf(value) + switch operator { + case storageTY.OperatorEqual, storageTY.OperatorNone: + want := converterUtils.ToString(filterValue) + for _, item := range items { + if item == want { + return true + } + } + return false + case storageTY.OperatorNotEqual: + want := converterUtils.ToString(filterValue) + for _, item := range items { + if item == want { + return false + } + } + return true + case storageTY.OperatorIn: + for _, item := range items { + if VerifyStringSlice(item, storageTY.OperatorIn, filterValue) { + return true + } + } + return false + case storageTY.OperatorNotIn: + for _, item := range items { + if VerifyStringSlice(item, storageTY.OperatorIn, filterValue) { + return false + } + } + return true + case storageTY.OperatorExists: + return len(items) > 0 + default: + return false + } +} + +func stringSliceOf(value interface{}) []string { + switch typed := value.(type) { + case []string: + return typed + case []interface{}: + out := make([]string, 0, len(typed)) + for _, item := range typed { + out = append(out, converterUtils.ToString(item)) + } + return out + default: + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil + } + out := make([]string, 0, rv.Len()) + for i := 0; i < rv.Len(); i++ { + out = append(out, converterUtils.ToString(rv.Index(i).Interface())) + } + return out + } +} + // VerifyBoolSlice implementation func VerifyBoolSlice(value bool, operator string, filterValue interface{}) bool { genericSlice, ok := filterValue.([]interface{}) diff --git a/pkg/utils/filter_sort/utils_filter_test.go b/pkg/utils/filter_sort/utils_filter_test.go index f2ae125..590e5bb 100644 --- a/pkg/utils/filter_sort/utils_filter_test.go +++ b/pkg/utils/filter_sort/utils_filter_test.go @@ -217,6 +217,20 @@ func TestCompareTime(t *testing.T) { } } +func TestIsMatchingStringSliceContains(t *testing.T) { + type user struct { + ID string + Policies []string + } + entity := &user{ID: "u1", Policies: []string{"admin", "readwrite"}} + assert.True(t, IsMatching(entity, []storageTY.Filter{ + {Key: "policies", Operator: storageTY.OperatorEqual, Value: "admin"}, + })) + assert.False(t, IsMatching(entity, []storageTY.Filter{ + {Key: "policies", Operator: storageTY.OperatorEqual, Value: "readonly"}, + })) +} + func TestIsMatchingTimeField(t *testing.T) { type event struct { ID string diff --git a/pkg/utils/printer/print.go b/pkg/utils/printer/print.go index 90436b4..ed3e49b 100644 --- a/pkg/utils/printer/print.go +++ b/pkg/utils/printer/print.go @@ -9,6 +9,7 @@ import ( "time" "github.com/mycontroller-org/server/v2/pkg/types/cmap" + dateTimeTY "github.com/mycontroller-org/server/v2/pkg/types/cusom_datetime" convertorUtils "github.com/mycontroller-org/server/v2/pkg/utils/convertor" filterUtils "github.com/mycontroller-org/server/v2/pkg/utils/filter_sort" "github.com/nleeper/goment" @@ -121,17 +122,14 @@ func PrintConsole(out io.Writer, headers []Header, data []interface{}, hideHeade if value != nil { switch _value := value.(type) { case time.Time: - if !_value.IsZero() { - if header.DisplayStyle == DisplayStyleRelativeTime { - g, err := goment.New(_value.UnixNano()) - if err != nil { - rowValue = err.Error() - } else { - rowValue = g.FromNow() - } - } + rowValue = FormatTimeValue(_value, header.DisplayStyle) + case dateTimeTY.CustomDate: + rowValue = FormatTimeValue(_value.Time, header.DisplayStyle) + case *dateTimeTY.CustomDate: + if _value == nil { + rowValue = "-" } else { - rowValue = "" + rowValue = FormatTimeValue(_value.Time, header.DisplayStyle) } case cmap.CustomStringMap: @@ -170,3 +168,17 @@ func PrintConsole(out io.Writer, headers []Header, data []interface{}, hideHeade table.AppendBulk(rows) // Add Bulk Data table.Render() } + +func FormatTimeValue(value time.Time, displayStyle string) string { + if value.IsZero() { + return "-" + } + if displayStyle != DisplayStyleRelativeTime { + return value.Format(time.RFC3339) + } + g, err := goment.New(value.UnixNano()) + if err != nil { + return err.Error() + } + return g.FromNow() +}