From b2bf75ffebce2c86a75e928a3b11b33e70f05fba Mon Sep 17 00:00:00 2001 From: Jeeva Kandasamy Date: Sun, 13 Sep 2026 18:10:15 +0530 Subject: [PATCH 1/4] cli: manage users, policies, and service accounts Add myc apply/get/delete/enable/disable for users and policies, and myc add/get/delete for service accounts. Rename the service token resource to service account (entity service_account, API /api/serviceaccount). Create prints the token once. Admins can create service accounts for any user. Empty statements mean the same access as the owner; statements can only narrow it. Block disabling or deleting the logged-in user. Upgrade 2.2.0-2 migrates stored service_token rows and policy resource kinds. --- cmd/client/api/api.go | 12 ++ cmd/client/api/delete.go | 29 ++- cmd/client/api/get.go | 12 ++ cmd/client/api/login.go | 8 +- cmd/client/api/service_account.go | 85 ++++++++ cmd/client/api/user_policy.go | 151 ++++++++++++++ cmd/client/command/add/cmd.go | 21 ++ cmd/client/command/add/service_account_cmd.go | 111 ++++++++++ cmd/client/command/alias/cmd.go | 8 +- cmd/client/command/alias/cmd_test.go | 2 + cmd/client/command/apply/apply.go | 98 +++++++-- cmd/client/command/apply/apply_test.go | 130 ++++++++++++ cmd/client/command/apply/client.go | 84 ++++++++ cmd/client/command/apply/cmd.go | 5 +- cmd/client/command/apply/parse.go | 93 ++++++++- cmd/client/command/apply/parse_test.go | 80 ++++++++ cmd/client/command/delete/delete_cmd.go | 55 +++++ cmd/client/command/disable/disable_cmd.go | 17 ++ cmd/client/command/enable/enable_cmd.go | 17 ++ cmd/client/command/get/get_cmd.go | 3 + cmd/client/command/get/service_account_cmd.go | 79 +++++++ cmd/client/command/get/user_policy_cmd.go | 169 +++++++++++++++ cmd/client/main.go | 1 + docs/access-control.md | 62 +++--- docs/cli.md | 86 +++++++- pkg/api/entities/api.go | 6 +- pkg/api/policy/api.go | 12 +- pkg/api/policy/api_save_test.go | 12 +- pkg/api/policy/body_auth_test.go | 4 +- pkg/api/policy/cache.go | 26 +-- pkg/api/policy/defaults.go | 2 +- pkg/api/policy/engine.go | 80 +++++--- pkg/api/policy/list_integration_test.go | 4 +- pkg/api/policy/query_filters.go | 4 +- pkg/api/policy/quickid_auth_test.go | 4 +- pkg/api/policy/token_scope_test.go | 22 +- pkg/api/service_account/api.go | 194 ++++++++++++++++++ pkg/api/service_token/api.go | 189 ----------------- pkg/backup/backup_map.go | 2 +- pkg/http_router/middleware/auth.go | 40 ++-- pkg/http_router/routes/auth/auth.go | 14 +- pkg/http_router/routes/auth/oauth.go | 24 +-- pkg/http_router/routes/routes.go | 2 +- pkg/http_router/routes/service_account.go | 191 +++++++++++++++++ pkg/http_router/routes/service_token.go | 127 ------------ pkg/http_router/routes/user.go | 23 ++- pkg/types/entities.go | 2 +- pkg/types/policy/types.go | 6 +- .../types.go | 15 +- pkg/types/web_handler/types.go | 24 +-- pkg/upgrade/restore_api_update.go | 12 +- pkg/upgrade/v2_2_0__2.go | 118 +++++++++++ pkg/upgrade/versions.go | 1 + pkg/utils/filter_sort/utils_filter.go | 68 ++++++ pkg/utils/filter_sort/utils_filter_test.go | 14 ++ pkg/utils/printer/print.go | 32 ++- 56 files changed, 2155 insertions(+), 537 deletions(-) create mode 100644 cmd/client/api/service_account.go create mode 100644 cmd/client/api/user_policy.go create mode 100644 cmd/client/command/add/cmd.go create mode 100644 cmd/client/command/add/service_account_cmd.go create mode 100644 cmd/client/command/get/service_account_cmd.go create mode 100644 cmd/client/command/get/user_policy_cmd.go create mode 100644 pkg/api/service_account/api.go delete mode 100644 pkg/api/service_token/api.go create mode 100644 pkg/http_router/routes/service_account.go delete mode 100644 pkg/http_router/routes/service_token.go rename pkg/types/{service_token => service_account}/types.go (79%) create mode 100644 pkg/upgrade/v2_2_0__2.go diff --git a/cmd/client/api/api.go b/cmd/client/api/api.go index 1cf33592..264aee6c 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 8758575e..9c4421b0 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 933fc7b9..e820d67e 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 a025ca72..68d54a90 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/service_account.go b/cmd/client/api/service_account.go new file mode 100644 index 00000000..19b3ca78 --- /dev/null +++ b/cmd/client/api/service_account.go @@ -0,0 +1,85 @@ +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" + 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) { + item := &svcAccountTY.ServiceAccount{} + found, err := c.findResource(API_SERVICE_ACCOUNT_LIST, idFilters(id), item) + if err != nil { + return nil, err + } + if found { + return item, nil + } + if name == "" { + return nil, nil + } + filters := []storageTY.Filter{equalFilter(types.KeyName, name)} + if userID := c.resolveUserRef(userRef); userID != "" { + filters = append(filters, equalFilter(types.KeyUserID, userID)) + } + item = &svcAccountTY.ServiceAccount{} + found, err = c.findResource(API_SERVICE_ACCOUNT_LIST, filters, item) + if err != nil || !found { + return nil, err + } + return item, nil +} + +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) ([]string, error) { + ids := make([]string, 0, len(selectors)) + missing := make([]string, 0) + for _, selector := range selectors { + item, err := c.FindServiceAccount(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("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 00000000..fedb61a0 --- /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 00000000..a25a4234 --- /dev/null +++ b/cmd/client/command/add/cmd.go @@ -0,0 +1,21 @@ +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) +} + +var addCmd = &cobra.Command{ + Use: "add", + Aliases: []string{"create"}, + Short: "Adds resources", + 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 00000000..fd1c0f24 --- /dev/null +++ b/cmd/client/command/add/service_account_cmd.go @@ -0,0 +1,111 @@ +package add + +import ( + "fmt" + "strings" + + 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 + 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 temp --expires-on 2027-12-31`, + Args: cobra.ExactArgs(2), + PreRun: func(cmd *cobra.Command, args []string) { + rootCmd.UpdateStreams(cmd) + }, + Run: func(cmd *cobra.Command, args []string) { + if err := addServiceAccount(args[0], args[1]); err != nil { + _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) + } + }, +} + +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().StringArrayVar(&saActions, "action", nil, "statement action (repeatable)") + serviceAccountAddCmd.Flags().StringArrayVar(&saResources, "resource", nil, "statement resource (repeatable)") +} + +func addServiceAccount(alias, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("name is required") + } + client := rootCmd.MustClient(alias) + + existing, err := client.FindServiceAccount("", name, saUser) + if err != nil { + return err + } + if existing != nil { + 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") + } + + if len(saActions) > 0 || len(saResources) > 0 { + account.Statements = []policyTY.Statement{{ + Effect: policyTY.EffectAllow, + Actions: saActions, + Resources: saResources, + }} + } + + 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/alias/cmd.go b/cmd/client/command/alias/cmd.go index b48e9690..5292c819 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": {}, "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 eb8b0843..9f1b913f 100644 --- a/cmd/client/command/alias/cmd_test.go +++ b/cmd/client/command/alias/cmd_test.go @@ -17,4 +17,6 @@ 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") } diff --git a/cmd/client/command/apply/apply.go b/cmd/client/command/apply/apply.go index 7256865c..7c83e7e4 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 7e0bb46d..845ab443 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 bca623d4..f94e99cd 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 060a7082..d5a87c6a 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 e1a4dbdf..0e30ea72 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 f294e363..18de9bfd 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/delete/delete_cmd.go b/cmd/client/command/delete/delete_cmd.go index 7a5dffeb..5414dc1d 100644 --- a/cmd/client/command/delete/delete_cmd.go +++ b/cmd/client/command/delete/delete_cmd.go @@ -19,6 +19,9 @@ func init() { deleteCmd.AddCommand(handlerDeleteCmd) deleteCmd.AddCommand(forwardPayloadDeleteCmd) deleteCmd.AddCommand(backupDeleteCmd) + deleteCmd.AddCommand(userDeleteCmd) + deleteCmd.AddCommand(policyDeleteCmd) + deleteCmd.AddCommand(serviceAccountDeleteCmd) } var gwDeleteCmd = &cobra.Command{ @@ -215,3 +218,55 @@ 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 serviceAccountDeleteCmd = &cobra.Command{ + Use: "service-account [...]", + Aliases: []string{"service-accounts", "sa"}, + Short: "Deletes the given service accounts", + 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) + 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 32bbf600..180b4d62 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 5d3baee5..a2ec7a65 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 cdf08556..75432a78 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 00000000..e8545665 --- /dev/null +++ b/cmd/client/command/get/service_account_cmd.go @@ -0,0 +1,79 @@ +package get + +import ( + "fmt" + "strconv" + + 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 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`, + 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], "") + 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 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 "0" + } + return strconv.Itoa(len(account.Statements)) +} 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 00000000..e1124fbf --- /dev/null +++ b/cmd/client/command/get/user_policy_cmd.go @@ -0,0 +1,169 @@ +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 fmt.Sprintf("%d", len(statements)) +} diff --git a/cmd/client/main.go b/cmd/client/main.go index 76828b63..099c1bcb 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" diff --git a/docs/access-control.md b/docs/access-control.md index e764c091..907fe98b 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 29b12ea8..144d654c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -13,6 +13,7 @@ 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 service account (`create` is an alias) | | `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 +93,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 +177,44 @@ 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 +``` + +`get user policies` lists the policies attached to that user. + +`get service-account` lists accounts owned by the logged-in user. The secret token is never stored; it is shown only when the account is created. + +### 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 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. Repeat `--action` and `--resource` to add one Allow statement; 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. + --- ## 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 +305,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 +356,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 +556,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 +733,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 +742,8 @@ myc delete gateway [...] myc delete node myc delete source myc delete field +myc delete user alice +myc delete service-account ci-bot ``` | Resource | Aliases | @@ -692,15 +761,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 fecbaee3..9d17b384 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 f53316d4..1eeb23de 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 b926bace..e2675fa5 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 a2ad4555..15def617 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 7e97f21d..364e45d9 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 3fe67d96..24cf1605 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 45861472..b7ed6cfc 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,13 +117,8 @@ 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 } @@ -157,8 +152,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 +167,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 +327,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 +508,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 +541,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 +603,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 d9905a47..8426120f 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 ee93eba5..5c331350 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 43a61be9..ea88d3dc 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 455d13a4..4888957f 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 00000000..ffe20056 --- /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 bcfc007e..00000000 --- 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 36833ed4..5dc47c6d 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 98fedcbc..21ade030 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 3380b918..e3b69a64 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 58770134..268d3fac 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 642949ee..73513e6a 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 00000000..90450af2 --- /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 96673db0..00000000 --- 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 3b466a5d..5f1fad78 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 634dea14..7f5ccfd6 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 adc6c3fe..c184d9c9 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 8c42953d..6f5b4985 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 fe8a17b5..11c13c3d 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 362a810c..37126dd1 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 00000000..957d5bb4 --- /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 f9468911..fa99b00d 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 77ec34ff..ca7e3453 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 f2ae1252..590e5bbe 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 90436b47..ed3e49b6 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() +} From 6454adde649fda9aa1cb4449b885d2c8be749d8f Mon Sep 17 00:00:00 2001 From: Jeeva Kandasamy Date: Sun, 13 Sep 2026 18:29:12 +0530 Subject: [PATCH 2/4] cli: tighten service-account add/get/delete and add users Require --action and --resource together so a half statement cannot deny all access. myc add now exits 1 on failure. Resolve service accounts by name+user when the name is not unique (--user). Add myc add user. Support --effect Allow|Deny on service-account add. Print statement rules in get user/policy/service-account instead of a count. --- cmd/client/api/resource.go | 47 +++++---- cmd/client/api/service_account.go | 70 +++++++++++--- cmd/client/command/add/cmd.go | 9 +- cmd/client/command/add/service_account_cmd.go | 45 +++++++-- cmd/client/command/add/user_cmd.go | 96 +++++++++++++++++++ cmd/client/command/delete/delete_cmd.go | 7 +- cmd/client/command/get/service_account_cmd.go | 15 ++- cmd/client/command/get/statements_test.go | 22 +++++ cmd/client/command/get/user_policy_cmd.go | 25 ++++- docs/cli.md | 21 +++- 10 files changed, 304 insertions(+), 53 deletions(-) create mode 100644 cmd/client/command/add/user_cmd.go create mode 100644 cmd/client/command/get/statements_test.go diff --git a/cmd/client/api/resource.go b/cmd/client/api/resource.go index acc1130b..7b7aad3a 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 index 19b3ca78..030458d9 100644 --- a/cmd/client/api/service_account.go +++ b/cmd/client/api/service_account.go @@ -8,6 +8,7 @@ import ( "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" ) @@ -29,27 +30,72 @@ func (c *Client) UpdateServiceAccount(account *svcAccountTY.ServiceAccount) erro } func (c *Client) FindServiceAccount(id, name, userRef string) (*svcAccountTY.ServiceAccount, error) { - item := &svcAccountTY.ServiceAccount{} - found, err := c.findResource(API_SERVICE_ACCOUNT_LIST, idFilters(id), item) + 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 } - if found { - return item, nil - } - if name == "" { + 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)) } - item = &svcAccountTY.ServiceAccount{} - found, err = c.findResource(API_SERVICE_ACCOUNT_LIST, filters, item) - if err != nil || !found { + raw, err := c.findResources(API_SERVICE_ACCOUNT_LIST, filters, 1000) + if err != nil { return nil, err } - return item, nil + 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 { @@ -64,11 +110,11 @@ func (c *Client) resolveUserRef(userRef string) string { return user.ID } -func (c *Client) ResolveServiceAccountIDs(selectors []string) ([]string, error) { +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, "") + item, err := c.FindServiceAccount(selector, selector, userRef) if err != nil { return ids, err } diff --git a/cmd/client/command/add/cmd.go b/cmd/client/command/add/cmd.go index a25a4234..6384b273 100644 --- a/cmd/client/command/add/cmd.go +++ b/cmd/client/command/add/cmd.go @@ -9,12 +9,15 @@ import ( func init() { rootCmd.Cmd.AddCommand(addCmd) addCmd.AddCommand(serviceAccountAddCmd) + addCmd.AddCommand(userAddCmd) } var addCmd = &cobra.Command{ - Use: "add", - Aliases: []string{"create"}, - Short: "Adds resources", + 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 index fd1c0f24..7f5e08dd 100644 --- a/cmd/client/command/add/service_account_cmd.go +++ b/cmd/client/command/add/service_account_cmd.go @@ -16,6 +16,7 @@ var ( saDescription string saNeverExpire bool saExpiresOn string + saEffect string saActions []string saResources []string ) @@ -27,15 +28,19 @@ var serviceAccountAddCmd = &cobra.Command{ 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), + Args: cobra.ExactArgs(2), + SilenceUsage: true, + SilenceErrors: true, PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) }, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if err := addServiceAccount(args[0], args[1]); err != nil { - _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) + return fmt.Errorf("error:%s", err) } + return nil }, } @@ -44,8 +49,9 @@ func init() { 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().StringArrayVar(&saActions, "action", nil, "statement action (repeatable)") - serviceAccountAddCmd.Flags().StringArrayVar(&saResources, "resource", nil, "statement resource (repeatable)") + 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 { @@ -55,11 +61,19 @@ func addServiceAccount(alias, name string) error { } client := rootCmd.MustClient(alias) - existing, err := client.FindServiceAccount("", name, saUser) + 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 existing != nil { + if len(existing) > 0 { return fmt.Errorf("service-account %s is already present", name) } @@ -81,8 +95,23 @@ func addServiceAccount(alias, name string) error { } if len(saActions) > 0 || len(saResources) > 0 { + if len(saActions) == 0 || len(saResources) == 0 { + return fmt.Errorf("--action and --resource must be used together") + } + effect := strings.TrimSpace(saEffect) + if effect == "" { + effect = policyTY.EffectAllow + } + switch strings.ToLower(effect) { + case "allow": + effect = policyTY.EffectAllow + case "deny": + effect = policyTY.EffectDeny + default: + return fmt.Errorf("effect must be Allow or Deny") + } account.Statements = []policyTY.Statement{{ - Effect: policyTY.EffectAllow, + Effect: effect, Actions: saActions, Resources: saResources, }} diff --git a/cmd/client/command/add/user_cmd.go b/cmd/client/command/add/user_cmd.go new file mode 100644 index 00000000..edd28bd3 --- /dev/null +++ b/cmd/client/command/add/user_cmd.go @@ -0,0 +1,96 @@ +package add + +import ( + "fmt" + "os" + "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" + "golang.org/x/term" +) + +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 = 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 +} + +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/delete/delete_cmd.go b/cmd/client/command/delete/delete_cmd.go index 5414dc1d..14f1bb0b 100644 --- a/cmd/client/command/delete/delete_cmd.go +++ b/cmd/client/command/delete/delete_cmd.go @@ -22,6 +22,7 @@ func init() { 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{ @@ -252,17 +253,21 @@ var policyDeleteCmd = &cobra.Command{ }, } +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) + ids, err := client.ResolveServiceAccountIDs(selectors, saDeleteUser) if err != nil { printStatus(err) return diff --git a/cmd/client/command/get/service_account_cmd.go b/cmd/client/command/get/service_account_cmd.go index e8545665..88759722 100644 --- a/cmd/client/command/get/service_account_cmd.go +++ b/cmd/client/command/get/service_account_cmd.go @@ -2,7 +2,6 @@ package get import ( "fmt" - "strconv" rootCmd "github.com/mycontroller-org/server/v2/cmd/client/command/root" svcAccountTY "github.com/mycontroller-org/server/v2/pkg/types/service_account" @@ -10,13 +9,15 @@ import ( "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`, + myc get sa ci-bot --user alice`, Args: cobra.RangeArgs(1, 2), PreRun: func(cmd *cobra.Command, args []string) { rootCmd.UpdateStreams(cmd) @@ -37,7 +38,7 @@ var serviceAccountGetCmd = &cobra.Command{ executeGetCmd(headers, client.ListServiceAccount, svcAccountTY.ServiceAccount{}) return } - account, err := client.FindServiceAccount(args[1], args[1], "") + account, err := client.FindServiceAccount(args[1], args[1], saGetUser) if err != nil { _, _ = fmt.Fprintf(rootCmd.IOStreams.ErrOut, "error:%s\n", err) return @@ -51,6 +52,10 @@ var serviceAccountGetCmd = &cobra.Command{ }, } +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: @@ -73,7 +78,7 @@ func formatServiceAccountExpiresOn(item interface{}) string { func formatServiceAccountStatements(item interface{}) string { account := serviceAccountFromItem(item) if account == nil { - return "0" + return "-" } - return strconv.Itoa(len(account.Statements)) + 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 00000000..6968367f --- /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 index e1124fbf..54a42d2b 100644 --- a/cmd/client/command/get/user_policy_cmd.go +++ b/cmd/client/command/get/user_policy_cmd.go @@ -165,5 +165,28 @@ func formatPolicyStatements(item interface{}) string { default: return "" } - return fmt.Sprintf("%d", len(statements)) + 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/docs/cli.md b/docs/cli.md index 144d654c..9e2ba6f4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -13,7 +13,7 @@ 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 service account (`create` is an alias) | +| `add` | Add a user or service account (`create` is an alias) | | `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 | @@ -191,11 +191,22 @@ 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 owned by the logged-in user. The secret token is never stored; it is shown only when the account is created. +`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 @@ -203,12 +214,13 @@ myc get service-account ci-bot 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. Repeat `--action` and `--resource` to add one Allow statement; omit both for the same access as the owning user. +`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. +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. --- @@ -744,6 +756,7 @@ 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 | From 2bd3d62314d796aa4ba1cc7667bb0f42fa788b6e Mon Sep 17 00:00:00 2001 From: Jeeva Kandasamy Date: Sun, 13 Sep 2026 18:35:56 +0530 Subject: [PATCH 3/4] cli: add update for users and service accounts myc update user changes only the flags you pass (email, name, password, username, policies). myc update sa updates name, description, expiry, and statements without rotating the token. Statement parsing is shared with add. --- cmd/client/command/add/service_account_cmd.go | 28 +--- cmd/client/command/add/user_cmd.go | 15 +- cmd/client/command/alias/cmd.go | 2 +- cmd/client/command/alias/cmd_test.go | 1 + cmd/client/command/common/statement.go | 56 ++++++++ cmd/client/command/common/statement_test.go | 29 ++++ cmd/client/command/update/cmd.go | 23 +++ .../command/update/service_account_cmd.go | 132 ++++++++++++++++++ cmd/client/command/update/user_cmd.go | 113 +++++++++++++++ cmd/client/main.go | 1 + docs/cli.md | 26 ++++ 11 files changed, 391 insertions(+), 35 deletions(-) create mode 100644 cmd/client/command/common/statement.go create mode 100644 cmd/client/command/common/statement_test.go create mode 100644 cmd/client/command/update/cmd.go create mode 100644 cmd/client/command/update/service_account_cmd.go create mode 100644 cmd/client/command/update/user_cmd.go diff --git a/cmd/client/command/add/service_account_cmd.go b/cmd/client/command/add/service_account_cmd.go index 7f5e08dd..a0e51867 100644 --- a/cmd/client/command/add/service_account_cmd.go +++ b/cmd/client/command/add/service_account_cmd.go @@ -4,6 +4,7 @@ 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" @@ -94,27 +95,12 @@ func addServiceAccount(alias, name string) error { return fmt.Errorf("--expires-on is required when never-expire is false") } - if len(saActions) > 0 || len(saResources) > 0 { - if len(saActions) == 0 || len(saResources) == 0 { - return fmt.Errorf("--action and --resource must be used together") - } - effect := strings.TrimSpace(saEffect) - if effect == "" { - effect = policyTY.EffectAllow - } - switch strings.ToLower(effect) { - case "allow": - effect = policyTY.EffectAllow - case "deny": - effect = policyTY.EffectDeny - default: - return fmt.Errorf("effect must be Allow or Deny") - } - account.Statements = []policyTY.Statement{{ - Effect: effect, - Actions: saActions, - Resources: saResources, - }} + statements, provided, err := common.ParseOptionalStatement(saEffect, saActions, saResources) + if err != nil { + return err + } + if provided { + account.Statements = statements } created, err := client.CreateServiceAccount(account) diff --git a/cmd/client/command/add/user_cmd.go b/cmd/client/command/add/user_cmd.go index edd28bd3..9d29f7f3 100644 --- a/cmd/client/command/add/user_cmd.go +++ b/cmd/client/command/add/user_cmd.go @@ -2,13 +2,12 @@ package add import ( "fmt" - "os" "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" - "golang.org/x/term" ) var ( @@ -63,7 +62,7 @@ func addUser(alias, username string) error { password := userPassword if strings.TrimSpace(password) == "" { - password, err = promptPassword() + password, err = common.PromptPassword() if err != nil { return err } @@ -84,13 +83,3 @@ func addUser(alias, username string) error { _, _ = fmt.Fprintf(rootCmd.IOStreams.Out, "user: %s\n", username) return nil } - -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/alias/cmd.go b/cmd/client/command/alias/cmd.go index 5292c819..c5b91e9a 100644 --- a/cmd/client/command/alias/cmd.go +++ b/cmd/client/command/alias/cmd.go @@ -21,7 +21,7 @@ var aliasNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) var reservedAliasNames = map[string]struct{}{ "alias": {}, "add": {}, "create": {}, "apply": {}, "action": {}, "completion": {}, "delete": {}, "disable": {}, "enable": {}, "get": {}, "help": {}, "reboot": {}, - "reload": {}, "server": {}, "set": {}, "upload": {}, "user": {}, "policy": {}, + "reload": {}, "server": {}, "set": {}, "update": {}, "upload": {}, "user": {}, "policy": {}, "service-account": {}, "service-accounts": {}, "sa": {}, "myc": {}, } diff --git a/cmd/client/command/alias/cmd_test.go b/cmd/client/command/alias/cmd_test.go index 9f1b913f..4dfe9618 100644 --- a/cmd/client/command/alias/cmd_test.go +++ b/cmd/client/command/alias/cmd_test.go @@ -19,4 +19,5 @@ func TestValidateAliasName(t *testing.T) { 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/common/statement.go b/cmd/client/command/common/statement.go new file mode 100644 index 00000000..f02cb5d4 --- /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 00000000..ab091394 --- /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/update/cmd.go b/cmd/client/command/update/cmd.go new file mode 100644 index 00000000..13f21314 --- /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 00000000..e77e8c0b --- /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 00000000..3ee75e15 --- /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 099c1bcb..6c91f1f8 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -16,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/cli.md b/docs/cli.md index 9e2ba6f4..8c0cf7e2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -14,6 +14,7 @@ This document describes the **MyController command-line client**: how to build i | `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 | @@ -222,6 +223,31 @@ myc add sa temp --expires-on 2027-12-31 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 From d38c5be31a37f98b546354e05a0f3d5acd26e564 Mon Sep 17 00:00:00 2001 From: Jeeva Kandasamy Date: Sun, 13 Sep 2026 18:38:26 +0530 Subject: [PATCH 4/4] cli: fix golangci-lint errcheck and unused helper Check print errors in add/apply token output and remove unused resourcesCoverKindWide after statement-based token limits. --- cmd/client/command/add/service_account_cmd.go | 6 +++--- cmd/client/command/apply/apply.go | 6 +++--- pkg/api/policy/engine.go | 17 ----------------- 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/cmd/client/command/add/service_account_cmd.go b/cmd/client/command/add/service_account_cmd.go index a0e51867..f65826ba 100644 --- a/cmd/client/command/add/service_account_cmd.go +++ b/cmd/client/command/add/service_account_cmd.go @@ -112,9 +112,9 @@ func addServiceAccount(alias, name string) error { } 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) + _, _ = 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 } diff --git a/cmd/client/command/apply/apply.go b/cmd/client/command/apply/apply.go index 7c83e7e4..64d5f22a 100644 --- a/cmd/client/command/apply/apply.go +++ b/cmd/client/command/apply/apply.go @@ -205,10 +205,10 @@ 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.") + _, _ = 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) + _, _ = fmt.Fprintf(out, "\n %s\n %s\n", item.resource, item.token) } } diff --git a/pkg/api/policy/engine.go b/pkg/api/policy/engine.go index b7ed6cfc..d7354f33 100644 --- a/pkg/api/policy/engine.go +++ b/pkg/api/policy/engine.go @@ -123,23 +123,6 @@ func (a *API) AllowedKindWide(subject Subject, action, kind string) error { 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)