Files
goclaw/internal/http/secure_cli_user_credentials.go
T
2026-05-31 19:42:05 +07:00

200 lines
7.6 KiB
Go

package http
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (h *SecureCLIHandler) handleListUserCredentials(w http.ResponseWriter, r *http.Request) {
binaryID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID)})
return
}
creds, err := h.store.ListUserCredentials(r.Context(), binaryID)
if err != nil {
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
return
}
// Return without env values for listing (names only + timestamps).
// credential_type + host_scope surface so the table can show a Type badge
// (PAT / SSH / Env) without a second round-trip per row.
type entry struct {
ID uuid.UUID `json:"id"`
BinaryID uuid.UUID `json:"binary_id"`
UserID string `json:"user_id"`
HasEnv bool `json:"has_env"`
EnvKeys []string `json:"env_keys,omitempty"`
Env map[string]store.SecureCLIEnvResponseEntry `json:"env,omitempty"`
CredentialType *string `json:"credential_type,omitempty"`
HostScope *string `json:"host_scope,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
entries := make([]entry, 0, len(creds))
for _, c := range creds {
envKeys := envKeysFromDecryptedJSON(c.EncryptedEnv)
// Typed credentials (pat / ssh_key) hold a single secret in the blob.
// Suppress env / env_keys for them — those keys would leak the wire
// shape (`token` / `key`) to the listing endpoint.
isTyped := c.CredentialType != nil && *c.CredentialType != "" && *c.CredentialType != "env"
e := entry{
ID: c.ID,
BinaryID: c.BinaryID,
UserID: c.UserID,
HasEnv: len(c.EncryptedEnv) > 0,
CredentialType: c.CredentialType,
HostScope: c.HostScope,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if !isTyped {
e.EnvKeys = envKeys
e.Env = store.SanitizeSecureCLIEnvJSON(c.EncryptedEnv)
}
entries = append(entries, e)
}
writeJSON(w, http.StatusOK, map[string]any{"user_credentials": entries})
}
func (h *SecureCLIHandler) handleGetUserCredentials(w http.ResponseWriter, r *http.Request) {
binaryID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID)})
return
}
userID := r.PathValue("userId")
if userID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id required"})
return
}
cred, err := h.store.GetUserCredentials(r.Context(), binaryID, userID)
if err != nil {
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
return
}
if cred == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
// Typed creds suppress env to avoid leaking the blob shape (`token`/`key`).
isTyped := cred.CredentialType != nil && *cred.CredentialType != "" && *cred.CredentialType != "env"
resp := map[string]any{
"user_id": cred.UserID,
"credential_type": cred.CredentialType,
"host_scope": cred.HostScope,
"has_secret": len(cred.EncryptedEnv) > 0,
}
if !isTyped {
resp["env"] = store.SanitizeSecureCLIEnvJSON(cred.EncryptedEnv)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *SecureCLIHandler) handleSetUserCredentials(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
binaryID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID)})
return
}
userID := r.PathValue("userId")
if userID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id required"})
return
}
var body struct {
Env json.RawMessage `json:"env"`
typedCredentialBody
}
if !bindJSON(w, r, locale, &body) {
return
}
// Typed branch (pat / ssh_key): validate + encrypt blob, store with
// credential_type + host_scope. Audit emits credential_type only — never
// the secret or host (host is operator-visible config but still scoped to
// the audit channel).
envBytes, credType, hostScope, terr := prepareTypedCredentialEnv(r.Context(), locale, body.typedCredentialBody)
if terr != nil {
writeTypedCredentialError(w, terr)
return
}
if envBytes != nil {
if err := h.store.SetUserCredentialsTyped(r.Context(), binaryID, userID, envBytes, credType, hostScope); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
return
}
emitAudit(h.msgBus, r, "secure_cli.user_credentials.updated", "secure_cli_user_credentials", binaryID.String()+"/"+userID+"#"+*credType)
h.emitCacheInvalidate("")
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
// Legacy env-paste branch (no credential_type or credential_type="env").
if len(body.Env) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "env is required"})
return
}
existing, err := h.store.GetUserCredentials(r.Context(), binaryID, userID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
return
}
var existingEnv []byte
if existing != nil {
existingEnv = existing.EncryptedEnv
}
envJSON, err := store.MergeSecureCLIEnv(existingEnv, body.Env)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgGrantEnvValueInvalid, err.Error())})
return
}
if err := h.store.SetUserCredentials(r.Context(), binaryID, userID, envJSON); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
return
}
emitAudit(h.msgBus, r, "secure_cli.user_credentials.updated", "secure_cli_user_credentials", binaryID.String()+"/"+userID)
h.emitCacheInvalidate("")
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *SecureCLIHandler) handleDeleteUserCredentials(w http.ResponseWriter, r *http.Request) {
binaryID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID)})
return
}
userID := r.PathValue("userId")
if userID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id required"})
return
}
if err := h.store.DeleteUserCredentials(r.Context(), binaryID, userID); err != nil {
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
return
}
emitAudit(h.msgBus, r, "secure_cli.user_credentials.deleted", "secure_cli_user_credentials", binaryID.String()+"/"+userID)
h.emitCacheInvalidate("")
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}