Compare commits

...

8 Commits

Author SHA1 Message Date
f1ebc52cca fix: allow reset-apikey command without user.manage permission
The reset-apikey command has its own auth mechanism via --acc-mgr-token,
so it should not be gated by permission introspection. This matches the
behavior of "user create" which is also Permitted: true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:32:30 +01:00
h z
de0ea39b2a Merge pull request 'dev-2026-03-29' (#3) from dev-2026-03-29 into main
Reviewed-on: #3
2026-04-16 21:21:32 +00:00
6dae490257 refactor: rename pass_mgr to secret-mgr
The secret manager binary was renamed from pass_mgr to secret-mgr.
Update all references in CLI code, mode detection, and help text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
53b5b88fc2 feat: user reset-apikey supports acc-mgr-token auth
Allows reset-apikey to use --acc-mgr-token or auto-resolve from
secret-mgr in padded-cell mode, enabling API key provisioning
without an existing user Bearer token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
6252039fc5 feat: add user reset-apikey command
Adds `hf user reset-apikey <username>` to regenerate a user API key.
Requires user.manage permission. Returns the new key (shown once only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
h z
cd22642472 Merge pull request 'HarborForge.Cli: dev-2026-03-29 -> main' (#2) from dev-2026-03-29 into main
Reviewed-on: #2
2026-04-05 22:08:34 +00:00
5ac90408f3 feat: support discord id account updates 2026-04-04 20:16:59 +00:00
ad0e123666 fix: send account-manager token as x-api-key 2026-04-03 19:12:34 +00:00
8 changed files with 215 additions and 24 deletions

View File

@@ -32,6 +32,28 @@ func main() {
handleLeafOrRun("health", args[1:], commands.RunHealth)
case "config":
handleConfig(args[1:])
case "update-discord-id":
tokenFlag := ""
var filtered []string
for i := 1; i < len(args); i++ {
switch args[i] {
case "--token":
if i+1 < len(args) {
i++
tokenFlag = args[i]
}
default:
filtered = append(filtered, args[i])
}
}
if len(filtered) < 1 {
output.Error("usage: hf update-discord-id <username> [discord-id]")
}
discordID := ""
if len(filtered) >= 2 {
discordID = filtered[1]
}
commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag)
default:
if group, ok := findGroup(args[0]); ok {
handleGroup(group, args[1:])
@@ -204,6 +226,31 @@ func handleGroup(group help.Group, args []string) {
return
}
if len(args) > 0 && args[0] == "update-discord-id" {
tokenFlag := ""
var filtered []string
for i := 1; i < len(args); i++ {
switch args[i] {
case "--token":
if i+1 < len(args) {
i++
tokenFlag = args[i]
}
default:
filtered = append(filtered, args[i])
}
}
if len(filtered) < 1 {
output.Error("usage: hf update-discord-id <username> [discord-id]")
}
discordID := ""
if len(filtered) >= 2 {
discordID = filtered[1]
}
commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag)
return
}
output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name)
}
@@ -238,7 +285,7 @@ func handleUserCommand(subCmd string, args []string) {
}
commands.RunUserGet(filtered[0], tokenFlag)
case "create":
username, password, email, fullName := "", "", "", ""
username, password, email, fullName, discordUserID := "", "", "", "", ""
for i := 0; i < len(filtered); i++ {
switch filtered[i] {
case "--user":
@@ -261,6 +308,11 @@ func handleUserCommand(subCmd string, args []string) {
i++
fullName = filtered[i]
}
case "--discord-user-id":
if i+1 < len(filtered) {
i++
discordUserID = filtered[i]
}
default:
output.Errorf("unknown flag: %s", filtered[i])
}
@@ -268,7 +320,7 @@ func handleUserCommand(subCmd string, args []string) {
if username == "" {
output.Error("usage: hf user create --user <username>")
}
commands.RunUserCreate(username, password, email, fullName, accMgrTokenFlag)
commands.RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag)
case "update":
if len(filtered) < 1 {
output.Error("usage: hf user update <username> [--email ...] [--full-name ...] [--pass ...] [--active ...]")
@@ -289,6 +341,11 @@ func handleUserCommand(subCmd string, args []string) {
output.Error("usage: hf user delete <username>")
}
commands.RunUserDelete(filtered[0], tokenFlag)
case "reset-apikey":
if len(filtered) < 1 {
output.Error("usage: hf user reset-apikey <username>")
}
commands.RunUserResetAPIKey(filtered[0], tokenFlag, accMgrTokenFlag)
default:
output.Errorf("hf user %s is not implemented yet", subCmd)
}

View File

@@ -14,6 +14,7 @@ import (
type Client struct {
BaseURL string
Token string
APIKey string
HTTPClient *http.Client
}
@@ -28,6 +29,17 @@ func New(baseURL, token string) *Client {
}
}
// NewWithAPIKey creates a Client that authenticates using X-API-Key.
func NewWithAPIKey(baseURL, apiKey string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// RequestError represents a non-2xx HTTP response.
type RequestError struct {
StatusCode int
@@ -45,7 +57,9 @@ func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("cannot create request: %w", err)
}
if c.Token != "" {
if c.APIKey != "" {
req.Header.Set("X-API-Key", c.APIKey)
} else if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if body != nil {

View File

@@ -20,7 +20,7 @@ func RunConfigURL(url string) {
fmt.Printf("base-url set to %s\n", url)
}
// RunConfigAccMgrToken stores the account-manager token via pass_mgr.
// RunConfigAccMgrToken stores the account-manager token via secret-mgr.
func RunConfigAccMgrToken(token string) {
if token == "" {
output.Error("usage: hf config --acc-mgr-token <token>")

View File

@@ -4,6 +4,8 @@ import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
@@ -23,6 +25,7 @@ type userResponse struct {
IsAdmin bool `json:"is_admin"`
RoleID *int `json:"role_id"`
RoleName *string `json:"role_name"`
DiscordUserID *string `json:"discord_user_id"`
CreatedAt string `json:"created_at"`
}
@@ -137,10 +140,41 @@ type userCreatePayload struct {
Email string `json:"email"`
FullName *string `json:"full_name,omitempty"`
Password *string `json:"password,omitempty"`
DiscordUserID *string `json:"discord_user_id,omitempty"`
}
func maybeResolveDiscordUserID(explicit string, requireEnv bool) (string, bool, error) {
if strings.TrimSpace(explicit) != "" {
return strings.TrimSpace(explicit), true, nil
}
agentID := strings.TrimSpace(os.Getenv("AGENT_ID"))
agentVerify := strings.TrimSpace(os.Getenv("AGENT_VERIFY"))
if agentID == "" || agentVerify == "" {
if requireEnv {
return "", false, fmt.Errorf("discord id not provided and AGENT_ID/AGENT_VERIFY are missing")
}
return "", false, nil
}
cmd := exec.Command("ego-mgr", "get", "discord-id")
out, err := cmd.Output()
if err != nil {
if requireEnv {
return "", false, fmt.Errorf("failed to resolve discord id from ego-mgr: %w", err)
}
return "", false, nil
}
value := strings.TrimSpace(string(out))
if value == "" {
if requireEnv {
return "", false, fmt.Errorf("ego-mgr returned empty discord id")
}
return "", false, nil
}
return value, true, nil
}
// RunUserCreate implements `hf user create`.
func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string) {
func RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag string) {
// Resolve account-manager token
var accMgrToken string
if mode.IsPaddedCell() {
@@ -181,6 +215,11 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
Email: email,
Password: &password,
}
if resolvedDiscordID, ok, err := maybeResolveDiscordUserID(discordUserID, false); err != nil {
output.Errorf("failed to resolve discord user id: %v", err)
} else if ok {
payload.DiscordUserID = &resolvedDiscordID
}
if fullName != "" {
payload.FullName = &fullName
}
@@ -194,7 +233,7 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, accMgrToken)
c := client.NewWithAPIKey(cfg.BaseURL, accMgrToken)
data, err := c.Post("/users", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create user: %v", err)
@@ -216,6 +255,28 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
fmt.Printf("user created: %s\n", u.Username)
}
// RunUserUpdateDiscordID updates a user's discord_user_id field.
func RunUserUpdateDiscordID(username, discordUserID, tokenFlag string) {
token := ResolveToken(tokenFlag)
resolvedDiscordID, _, err := maybeResolveDiscordUserID(discordUserID, true)
if err != nil {
output.Errorf("failed to resolve discord user id: %v", err)
}
body, err := json.Marshal(map[string]interface{}{"discord_user_id": resolvedDiscordID})
if err != nil {
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)
if _, err := c.Patch("/users/"+username, bytes.NewReader(body)); err != nil {
output.Errorf("failed to update discord id: %v", err)
}
fmt.Printf("discord id updated: %s\n", username)
}
// RunUserUpdate implements `hf user update <username>`.
func RunUserUpdate(username string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
@@ -329,3 +390,60 @@ func RunUserDelete(username, tokenFlag string) {
}
fmt.Printf("user deleted: %s\n", username)
}
// resetAPIKeyResponse matches the backend reset-apikey response.
type resetAPIKeyResponse struct {
UserID int `json:"user_id"`
Username string `json:"username"`
APIKey string `json:"api_key"`
Message string `json:"message"`
}
// RunUserResetAPIKey implements `hf user reset-apikey <username>`.
func RunUserResetAPIKey(username, tokenFlag, accMgrTokenFlag string) {
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
// Try acc-mgr-token first (allows provisioning without existing user token)
var c *client.Client
if accMgrTokenFlag != "" {
c = client.NewWithAPIKey(cfg.BaseURL, accMgrTokenFlag)
} else if mode.IsPaddedCell() {
if tok, err := passmgr.GetAccountManagerToken(); err == nil && tok != "" {
c = client.NewWithAPIKey(cfg.BaseURL, tok)
} else {
token := ResolveToken(tokenFlag)
c = client.New(cfg.BaseURL, token)
}
} else {
token := ResolveToken(tokenFlag)
c = client.New(cfg.BaseURL, token)
}
data, err := c.Post("/users/"+username+"/reset-apikey", nil)
if err != nil {
output.Errorf("failed to reset API key: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var r resetAPIKeyResponse
if err := json.Unmarshal(data, &r); err != nil {
fmt.Printf("API key reset for: %s\n", username)
return
}
output.PrintKeyValue(
"username", r.Username,
"api-key", r.APIKey,
"message", r.Message,
)
}

View File

@@ -95,9 +95,9 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
Notes: []string{"Writes base-url into .hf-config.json next to the hf binary."},
},
"config/acc-mgr-token": {
Summary: "Store the account-manager token via pass_mgr",
Summary: "Store the account-manager token via secret-mgr",
Usage: []string{"hf config --acc-mgr-token <token>"},
Notes: []string{"Only available in padded-cell mode with pass_mgr installed."},
Notes: []string{"Only available in padded-cell mode with secret-mgr installed."},
},
"user/create": {
Summary: "Create a user account",
@@ -105,7 +105,7 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
Flags: accountManagerFlagHelp(),
Notes: []string{
"This command uses the account-manager token flow, not the normal user token flow.",
"In padded-cell mode, --acc-mgr-token is hidden and password generation can fall back to pass_mgr.",
"In padded-cell mode, --acc-mgr-token is hidden and password generation can fall back to secret-mgr.",
},
},
"user/list": {Summary: "List users", Usage: []string{"hf user list"}, Flags: authFlagHelp()},
@@ -114,6 +114,7 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
"user/activate": {Summary: "Activate a user", Usage: []string{"hf user activate <username>"}, Flags: authFlagHelp()},
"user/deactivate": {Summary: "Deactivate a user", Usage: []string{"hf user deactivate <username>"}, Flags: authFlagHelp()},
"user/delete": {Summary: "Delete a user", Usage: []string{"hf user delete <username>"}, Flags: authFlagHelp()},
"user/reset-apikey": {Summary: "Reset a user's API key", Usage: []string{"hf user reset-apikey <username>"}, Flags: authFlagHelp(), Notes: []string{"The new API key is shown once and cannot be retrieved again."}},
"role/list": {Summary: "List roles", Usage: []string{"hf role list"}, Flags: authFlagHelp()},
"role/get": {Summary: "Show a role by name", Usage: []string{"hf role get <role-name>"}, Flags: authFlagHelp()},
"role/create": {Summary: "Create a role", Usage: []string{"hf role create --name <role-name> [--desc <desc>] [--global <true|false>]"}, Flags: authFlagHelp()},

View File

@@ -40,6 +40,7 @@ func CommandSurface() []Group {
{Name: "activate", Description: "Activate a user", Permitted: has(perms, "user.manage")},
{Name: "deactivate", Description: "Deactivate a user", Permitted: has(perms, "user.manage")},
{Name: "delete", Description: "Delete a user", Permitted: has(perms, "user.manage")},
{Name: "reset-apikey", Description: "Reset a user's API key", Permitted: true},
},
},
{

View File

@@ -12,7 +12,7 @@ type RuntimeMode int
const (
// ManualMode requires explicit --token / --acc-mgr-token flags.
ManualMode RuntimeMode = iota
// PaddedCellMode resolves secrets via pass_mgr automatically.
// PaddedCellMode resolves secrets via secret-mgr automatically.
PaddedCellMode
)
@@ -21,11 +21,11 @@ var (
detectOnce sync.Once
)
// Detect checks whether pass_mgr is available and returns the runtime mode.
// Detect checks whether secret-mgr is available and returns the runtime mode.
// The result is cached after the first call.
func Detect() RuntimeMode {
detectOnce.Do(func() {
_, err := exec.LookPath("pass_mgr")
_, err := exec.LookPath("secret-mgr")
if err == nil {
detectedMode = PaddedCellMode
} else {

View File

@@ -1,4 +1,4 @@
// Package passmgr wraps calls to the pass_mgr binary for secret resolution.
// Package passmgr wraps calls to the secret-mgr binary for secret resolution.
package passmgr
import (
@@ -7,49 +7,49 @@ import (
"strings"
)
// GetSecret calls: pass_mgr get-secret [--public] --key <key>
// GetSecret calls: secret-mgr get-secret [--public] --key <key>
func GetSecret(key string, public bool) (string, error) {
args := []string{"get-secret"}
if public {
args = append(args, "--public")
}
args = append(args, "--key", key)
out, err := exec.Command("pass_mgr", args...).Output()
out, err := exec.Command("secret-mgr", args...).Output()
if err != nil {
return "", fmt.Errorf("pass_mgr get-secret --key %s failed: %w", key, err)
return "", fmt.Errorf("secret-mgr get-secret --key %s failed: %w", key, err)
}
return strings.TrimSpace(string(out)), nil
}
// SetSecret calls: pass_mgr set [--public] --key <key> --secret <secret>
// SetSecret calls: secret-mgr set [--public] --key <key> --secret <secret>
func SetSecret(key, secret string, public bool) error {
args := []string{"set"}
if public {
args = append(args, "--public")
}
args = append(args, "--key", key, "--secret", secret)
if err := exec.Command("pass_mgr", args...).Run(); err != nil {
return fmt.Errorf("pass_mgr set --key %s failed: %w", key, err)
if err := exec.Command("secret-mgr", args...).Run(); err != nil {
return fmt.Errorf("secret-mgr set --key %s failed: %w", key, err)
}
return nil
}
// GeneratePassword calls: pass_mgr generate --key <key> --username <username>
// GeneratePassword calls: secret-mgr generate --key <key> --username <username>
func GeneratePassword(key, username string) (string, error) {
args := []string{"generate", "--key", key, "--username", username}
out, err := exec.Command("pass_mgr", args...).Output()
out, err := exec.Command("secret-mgr", args...).Output()
if err != nil {
return "", fmt.Errorf("pass_mgr generate failed: %w", err)
return "", fmt.Errorf("secret-mgr generate failed: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// GetToken retrieves the normal hf-token via pass_mgr.
// GetToken retrieves the normal hf-token via secret-mgr.
func GetToken() (string, error) {
return GetSecret("hf-token", false)
}
// GetAccountManagerToken retrieves the public hf-acc-mgr-token via pass_mgr.
// GetAccountManagerToken retrieves the public hf-acc-mgr-token via secret-mgr.
func GetAccountManagerToken() (string, error) {
return GetSecret("hf-acc-mgr-token", true)
}