diff --git a/README.md b/README.md index ea7c864..f03ae40 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Implemented: - Output formatting (human-readable + `--json`) - `hf version`, `hf health`, `hf config` - Auth token resolution (padded-cell + manual) +- Backend-aligned role/permission commands, including role-name lookup and permission-name↔id translation against current API routes Planned: - User, role, project, task, milestone, meeting, support, propose, monitor commands diff --git a/internal/commands/role.go b/internal/commands/role.go index bfe4732..219c1ee 100644 --- a/internal/commands/role.go +++ b/internal/commands/role.go @@ -11,31 +11,145 @@ import ( "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" ) -// roleResponse matches the backend RoleResponse schema. +// roleResponse matches the backend role list schema. type roleResponse struct { - ID int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - IsGlobal bool `json:"is_global"` - Permissions []string `json:"permissions"` - CreatedAt string `json:"created_at"` + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsGlobal bool `json:"is_global"` + PermissionIDs []int `json:"permission_ids"` + CreatedAt string `json:"created_at"` } -// permissionResponse matches the backend PermissionResponse schema. +// roleDetailResponse matches the backend role detail schema. +type roleDetailResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsGlobal bool `json:"is_global"` + Permissions []permissionResponse `json:"permissions"` + CreatedAt string `json:"created_at"` +} + +// permissionResponse matches the backend permission schema. type permissionResponse struct { ID int `json:"id"` - Codename string `json:"codename"` + Name string `json:"name"` Description string `json:"description"` + Category string `json:"category"` +} + +func loadRoleClient(tokenFlag string) (*client.Client, error) { + token := ResolveToken(tokenFlag) + cfg, err := config.Load() + if err != nil { + return nil, fmt.Errorf("config error: %w", err) + } + return client.New(cfg.BaseURL, token), nil +} + +func fetchRoles(c *client.Client) ([]roleResponse, error) { + data, err := c.Get("/roles") + if err != nil { + return nil, err + } + var roles []roleResponse + if err := json.Unmarshal(data, &roles); err != nil { + return nil, fmt.Errorf("cannot parse role list: %w", err) + } + return roles, nil +} + +func fetchPermissions(c *client.Client) ([]permissionResponse, error) { + data, err := c.Get("/roles/permissions") + if err != nil { + return nil, err + } + var perms []permissionResponse + if err := json.Unmarshal(data, &perms); err != nil { + return nil, fmt.Errorf("cannot parse permission list: %w", err) + } + return perms, nil +} + +func findRoleByName(c *client.Client, roleName string) (*roleResponse, error) { + roles, err := fetchRoles(c) + if err != nil { + return nil, err + } + for _, r := range roles { + if r.Name == roleName { + role := r + return &role, nil + } + } + return nil, fmt.Errorf("role not found: %s", roleName) +} + +func fetchRoleDetailByName(c *client.Client, roleName string) (*roleDetailResponse, error) { + role, err := findRoleByName(c, roleName) + if err != nil { + return nil, err + } + data, err := c.Get(fmt.Sprintf("/roles/%d", role.ID)) + if err != nil { + return nil, err + } + var detail roleDetailResponse + if err := json.Unmarshal(data, &detail); err != nil { + return nil, fmt.Errorf("cannot parse role detail: %w", err) + } + return &detail, nil +} + +func resolvePermissionIDs(c *client.Client, names []string) ([]int, error) { + perms, err := fetchPermissions(c) + if err != nil { + return nil, err + } + byName := make(map[string]int, len(perms)) + for _, p := range perms { + byName[p.Name] = p.ID + } + ids := make([]int, 0, len(names)) + seen := map[int]struct{}{} + var missing []string + for _, name := range names { + id, ok := byName[name] + if !ok { + missing = append(missing, name) + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(missing) > 0 { + return nil, fmt.Errorf("unknown permission(s): %s", strings.Join(missing, ", ")) + } + return ids, nil +} + +func replaceRolePermissions(c *client.Client, roleID int, permissionIDs []int) error { + payload := map[string]interface{}{ + "permission_ids": permissionIDs, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("cannot marshal payload: %w", err) + } + _, err = c.Post(fmt.Sprintf("/roles/%d/permissions", roleID), bytes.NewReader(body)) + return err } // RunRoleList implements `hf role list`. func RunRoleList(tokenFlag string) { - token := ResolveToken(tokenFlag) - cfg, err := config.Load() + c, err := loadRoleClient(tokenFlag) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("%v", err) } - c := client.New(cfg.BaseURL, token) data, err := c.Get("/roles") if err != nil { output.Errorf("failed to list roles: %v", err) @@ -55,31 +169,37 @@ func RunRoleList(tokenFlag string) { output.Errorf("cannot parse role list: %v", err) } - headers := []string{"NAME", "DESCRIPTION", "GLOBAL", "PERMISSIONS"} + headers := []string{"NAME", "DESCRIPTION", "GLOBAL", "PERM IDS"} var rows [][]string for _, r := range roles { global := "" if r.IsGlobal { global = "yes" } - perms := strings.Join(r.Permissions, ", ") - if len(perms) > 60 { - perms = perms[:57] + "..." + permIDs := "" + if len(r.PermissionIDs) > 0 { + parts := make([]string, 0, len(r.PermissionIDs)) + for _, id := range r.PermissionIDs { + parts = append(parts, fmt.Sprintf("%d", id)) + } + permIDs = strings.Join(parts, ", ") } - rows = append(rows, []string{r.Name, r.Description, global, perms}) + rows = append(rows, []string{r.Name, r.Description, global, permIDs}) } output.PrintTable(headers, rows) } // RunRoleGet implements `hf role get `. func RunRoleGet(roleName, tokenFlag string) { - token := ResolveToken(tokenFlag) - cfg, err := config.Load() + c, err := loadRoleClient(tokenFlag) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("%v", err) } - c := client.New(cfg.BaseURL, token) - data, err := c.Get("/roles/" + roleName) + role, err := findRoleByName(c, roleName) + if err != nil { + output.Errorf("failed to get role: %v", err) + } + data, err := c.Get(fmt.Sprintf("/roles/%d", role.ID)) if err != nil { output.Errorf("failed to get role: %v", err) } @@ -93,7 +213,7 @@ func RunRoleGet(roleName, tokenFlag string) { return } - var r roleResponse + var r roleDetailResponse if err := json.Unmarshal(data, &r); err != nil { output.Errorf("cannot parse role: %v", err) } @@ -104,20 +224,26 @@ func RunRoleGet(roleName, tokenFlag string) { } perms := "(none)" if len(r.Permissions) > 0 { - perms = strings.Join(r.Permissions, ", ") + names := make([]string, 0, len(r.Permissions)) + for _, p := range r.Permissions { + names = append(names, p.Name) + } + perms = strings.Join(names, ", ") } output.PrintKeyValue( "name", r.Name, "description", r.Description, "global", global, "permissions", perms, - "created", r.CreatedAt, ) } // RunRoleCreate implements `hf role create`. func RunRoleCreate(name, desc string, global bool, tokenFlag string) { - token := ResolveToken(tokenFlag) + c, err := loadRoleClient(tokenFlag) + if err != nil { + output.Errorf("%v", err) + } if name == "" { output.Error("usage: hf role create --name ") } @@ -137,11 +263,6 @@ func RunRoleCreate(name, desc string, global bool, tokenFlag string) { output.Errorf("cannot marshal payload: %v", err) } - cfg, err := config.Load() - if err != nil { - output.Errorf("config error: %v", err) - } - c := client.New(cfg.BaseURL, token) data, err := c.Post("/roles", bytes.NewReader(body)) if err != nil { output.Errorf("failed to create role: %v", err) @@ -161,7 +282,14 @@ func RunRoleCreate(name, desc string, global bool, tokenFlag string) { // RunRoleUpdate implements `hf role update `. func RunRoleUpdate(roleName string, args []string, tokenFlag string) { - token := ResolveToken(tokenFlag) + c, err := loadRoleClient(tokenFlag) + if err != nil { + output.Errorf("%v", err) + } + role, err := findRoleByName(c, roleName) + if err != nil { + output.Errorf("failed to update role: %v", err) + } payload := make(map[string]interface{}) for i := 0; i < len(args); i++ { @@ -186,12 +314,7 @@ func RunRoleUpdate(roleName string, args []string, tokenFlag string) { output.Errorf("cannot marshal payload: %v", err) } - cfg, err := config.Load() - if err != nil { - output.Errorf("config error: %v", err) - } - c := client.New(cfg.BaseURL, token) - _, err = c.Patch("/roles/"+roleName, bytes.NewReader(body)) + _, err = c.Patch(fmt.Sprintf("/roles/%d", role.ID), bytes.NewReader(body)) if err != nil { output.Errorf("failed to update role: %v", err) } @@ -201,13 +324,15 @@ func RunRoleUpdate(roleName string, args []string, tokenFlag string) { // RunRoleDelete implements `hf role delete `. func RunRoleDelete(roleName, tokenFlag string) { - token := ResolveToken(tokenFlag) - cfg, err := config.Load() + c, err := loadRoleClient(tokenFlag) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("%v", err) } - c := client.New(cfg.BaseURL, token) - _, err = c.Delete("/roles/" + roleName) + role, err := findRoleByName(c, roleName) + if err != nil { + output.Errorf("failed to delete role: %v", err) + } + _, err = c.Delete(fmt.Sprintf("/roles/%d", role.ID)) if err != nil { output.Errorf("failed to delete role: %v", err) } @@ -216,26 +341,22 @@ func RunRoleDelete(roleName, tokenFlag string) { // RunRoleSetPermissions implements `hf role set-permissions --permission [...]`. func RunRoleSetPermissions(roleName string, permissions []string, tokenFlag string) { - token := ResolveToken(tokenFlag) + c, err := loadRoleClient(tokenFlag) + if err != nil { + output.Errorf("%v", err) + } if len(permissions) == 0 { output.Error("usage: hf role set-permissions --permission [--permission ...]") } - - payload := map[string]interface{}{ - "permissions": permissions, - } - body, err := json.Marshal(payload) + role, err := findRoleByName(c, roleName) if err != nil { - output.Errorf("cannot marshal payload: %v", err) + output.Errorf("failed to set permissions: %v", err) } - - cfg, err := config.Load() + permissionIDs, err := resolvePermissionIDs(c, permissions) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("failed to set permissions: %v", err) } - c := client.New(cfg.BaseURL, token) - _, err = c.Put("/roles/"+roleName+"/permissions", bytes.NewReader(body)) - if err != nil { + if err := replaceRolePermissions(c, role.ID, permissionIDs); err != nil { output.Errorf("failed to set permissions: %v", err) } @@ -244,69 +365,91 @@ func RunRoleSetPermissions(roleName string, permissions []string, tokenFlag stri // RunRoleAddPermissions implements `hf role add-permissions --permission [...]`. func RunRoleAddPermissions(roleName string, permissions []string, tokenFlag string) { - token := ResolveToken(tokenFlag) + c, err := loadRoleClient(tokenFlag) + if err != nil { + output.Errorf("%v", err) + } if len(permissions) == 0 { output.Error("usage: hf role add-permissions --permission [--permission ...]") } - - payload := map[string]interface{}{ - "permissions": permissions, - } - body, err := json.Marshal(payload) + role, err := findRoleByName(c, roleName) if err != nil { - output.Errorf("cannot marshal payload: %v", err) + output.Errorf("failed to add permissions: %v", err) } - - cfg, err := config.Load() + detail, err := fetchRoleDetailByName(c, roleName) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("failed to add permissions: %v", err) } - c := client.New(cfg.BaseURL, token) - _, err = c.Post("/roles/"+roleName+"/permissions", bytes.NewReader(body)) + currentIDs := make([]int, 0, len(detail.Permissions)) + seen := map[int]struct{}{} + for _, p := range detail.Permissions { + seen[p.ID] = struct{}{} + currentIDs = append(currentIDs, p.ID) + } + newIDs, err := resolvePermissionIDs(c, permissions) if err != nil { output.Errorf("failed to add permissions: %v", err) } + for _, id := range newIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + currentIDs = append(currentIDs, id) + } + if err := replaceRolePermissions(c, role.ID, currentIDs); err != nil { + output.Errorf("failed to add permissions: %v", err) + } fmt.Printf("permissions added to role %s: %s\n", roleName, strings.Join(permissions, ", ")) } // RunRoleRemovePermissions implements `hf role remove-permissions --permission [...]`. func RunRoleRemovePermissions(roleName string, permissions []string, tokenFlag string) { - token := ResolveToken(tokenFlag) + c, err := loadRoleClient(tokenFlag) + if err != nil { + output.Errorf("%v", err) + } if len(permissions) == 0 { output.Error("usage: hf role remove-permissions --permission [--permission ...]") } - - payload := map[string]interface{}{ - "permissions": permissions, - } - body, err := json.Marshal(payload) + role, err := findRoleByName(c, roleName) if err != nil { - output.Errorf("cannot marshal payload: %v", err) + output.Errorf("failed to remove permissions: %v", err) } - - cfg, err := config.Load() + detail, err := fetchRoleDetailByName(c, roleName) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("failed to remove permissions: %v", err) } - c := client.New(cfg.BaseURL, token) - _, err = c.Do("DELETE", "/roles/"+roleName+"/permissions", bytes.NewReader(body)) + removeIDs, err := resolvePermissionIDs(c, permissions) if err != nil { output.Errorf("failed to remove permissions: %v", err) } + removeSet := map[int]struct{}{} + for _, id := range removeIDs { + removeSet[id] = struct{}{} + } + remaining := make([]int, 0, len(detail.Permissions)) + for _, p := range detail.Permissions { + if _, ok := removeSet[p.ID]; ok { + continue + } + remaining = append(remaining, p.ID) + } + if err := replaceRolePermissions(c, role.ID, remaining); err != nil { + output.Errorf("failed to remove permissions: %v", err) + } fmt.Printf("permissions removed from role %s: %s\n", roleName, strings.Join(permissions, ", ")) } // RunPermissionList implements `hf permission list`. func RunPermissionList(tokenFlag string) { - token := ResolveToken(tokenFlag) - cfg, err := config.Load() + c, err := loadRoleClient(tokenFlag) if err != nil { - output.Errorf("config error: %v", err) + output.Errorf("%v", err) } - c := client.New(cfg.BaseURL, token) - data, err := c.Get("/permissions") + data, err := c.Get("/roles/permissions") if err != nil { output.Errorf("failed to list permissions: %v", err) } @@ -325,10 +468,10 @@ func RunPermissionList(tokenFlag string) { output.Errorf("cannot parse permission list: %v", err) } - headers := []string{"CODENAME", "DESCRIPTION"} + headers := []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"} var rows [][]string for _, p := range perms { - rows = append(rows, []string{p.Codename, p.Description}) + rows = append(rows, []string{fmt.Sprintf("%d", p.ID), p.Name, p.Category, p.Description}) } output.PrintTable(headers, rows) }