Pulse/internal/cloudcp/account/handlers.go
2026-03-18 16:06:30 +00:00

650 lines
20 KiB
Go

package account
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
"github.com/rs/zerolog/log"
)
type memberResponse struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role registry.MemberRole `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
// HandleListMembers returns an authenticated handler that lists all members of an account.
func HandleListMembers(reg *registry.TenantRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
accountID := strings.TrimSpace(r.PathValue("account_id"))
if accountID == "" {
http.Error(w, "missing account_id", http.StatusBadRequest)
return
}
a, err := reg.GetAccount(accountID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if a == nil {
http.Error(w, "account not found", http.StatusNotFound)
return
}
memberships, err := reg.ListMembersByAccount(accountID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if memberships == nil {
memberships = []*registry.AccountMembership{}
}
resp := make([]memberResponse, 0, len(memberships))
for _, m := range memberships {
u, err := reg.GetUser(m.UserID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if u == nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
resp = append(resp, memberResponse{
UserID: m.UserID,
Email: u.Email,
Role: m.Role,
CreatedAt: m.CreatedAt,
})
}
w.Header().Set("Content-Type", "application/json")
encodeJSON(w, resp)
}
}
type inviteMemberRequest struct {
Email string `json:"email"`
Role string `json:"role"`
}
func requestActorRole(r *http.Request) (registry.MemberRole, bool) {
if r == nil {
return "", false
}
role := registry.MemberRole(strings.TrimSpace(r.Header.Get("X-User-Role")))
if role == "" {
return "", false
}
return role, true
}
func actorCanManageAccount(role registry.MemberRole) bool {
return role == registry.MemberRoleOwner || role == registry.MemberRoleAdmin
}
// HandleInviteMember returns an authenticated handler that invites a user to an account.
func HandleInviteMember(reg *registry.TenantRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
accountID := strings.TrimSpace(r.PathValue("account_id"))
if accountID == "" {
auditEvent(r, "cp_account_member_invite", "failure").
Str("reason", "missing_account_id").
Msg("Account member invite failed")
http.Error(w, "missing account_id", http.StatusBadRequest)
return
}
a, err := reg.GetAccount(accountID)
if err != nil {
auditEvent(r, "cp_account_member_invite", "failure").
Err(err).
Str("account_id", accountID).
Str("reason", "account_lookup_failed").
Msg("Account member invite failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if a == nil {
auditEvent(r, "cp_account_member_invite", "failure").
Str("account_id", accountID).
Str("reason", "account_not_found").
Msg("Account member invite failed")
http.Error(w, "account not found", http.StatusNotFound)
return
}
var req inviteMemberRequest
if err := decodeJSON(w, r, &req); err != nil {
auditEvent(r, "cp_account_member_invite", "failure").
Str("account_id", accountID).
Str("reason", "invalid_json_body").
Msg("Account member invite failed")
return
}
email := normalizeEmail(req.Email)
if email == "" {
auditEvent(r, "cp_account_member_invite", "failure").
Str("account_id", accountID).
Str("reason", "invalid_email").
Msg("Account member invite failed")
http.Error(w, "invalid email", http.StatusBadRequest)
return
}
role, ok := parseMemberRole(req.Role)
if !ok {
auditEvent(r, "cp_account_member_invite", "failure").
Str("account_id", accountID).
Str("email", email).
Str("reason", "invalid_role").
Msg("Account member invite failed")
http.Error(w, "invalid role", http.StatusBadRequest)
return
}
actorRole, hasActorRole := requestActorRole(r)
if !hasActorRole || !actorCanManageAccount(actorRole) {
auditEvent(r, "cp_account_member_invite", "failure").
Str("account_id", accountID).
Str("email", email).
Str("actor_role", string(actorRole)).
Str("reason", "missing_or_insufficient_role").
Msg("Account member invite failed")
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if role == registry.MemberRoleOwner && actorRole != registry.MemberRoleOwner {
auditEvent(r, "cp_account_member_invite", "failure").
Str("account_id", accountID).
Str("email", email).
Str("actor_role", string(actorRole)).
Str("requested_role", string(role)).
Str("reason", "owner_role_requires_owner_actor").
Msg("Account member invite failed")
http.Error(w, "forbidden", http.StatusForbidden)
return
}
u, err := reg.GetUserByEmail(email)
if err != nil {
auditEvent(r, "cp_account_member_invite", "failure").
Err(err).
Str("account_id", accountID).
Str("email", email).
Str("reason", "user_lookup_failed").
Msg("Account member invite failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if u == nil {
userID, err := registry.GenerateUserID()
if err != nil {
auditEvent(r, "cp_account_member_invite", "failure").
Err(err).
Str("account_id", accountID).
Str("email", email).
Str("reason", "user_id_generation_failed").
Msg("Account member invite failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
u = &registry.User{
ID: userID,
Email: email,
}
if err := reg.CreateUser(u); err != nil {
// If a concurrent request created the user, fall back to lookup.
u2, gerr := reg.GetUserByEmail(email)
if gerr != nil || u2 == nil {
auditEvent(r, "cp_account_member_invite", "failure").
Err(err).
Str("account_id", accountID).
Str("email", email).
Str("reason", "user_create_failed").
Msg("Account member invite failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
u = u2
}
}
if err := reg.CreateMembership(&registry.AccountMembership{
AccountID: accountID,
UserID: u.ID,
Role: role,
}); err != nil {
if isUniqueViolation(err) {
auditEvent(r, "cp_account_member_invite", "failure").
Str("account_id", accountID).
Str("user_id", u.ID).
Str("email", email).
Str("role", string(role)).
Str("reason", "membership_already_exists").
Msg("Account member invite failed")
http.Error(w, "membership already exists", http.StatusConflict)
return
}
auditEvent(r, "cp_account_member_invite", "failure").
Err(err).
Str("account_id", accountID).
Str("user_id", u.ID).
Str("email", email).
Str("role", string(role)).
Str("reason", "membership_create_failed").
Msg("Account member invite failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
auditEvent(r, "cp_account_member_invite", "success").
Str("account_id", accountID).
Str("user_id", u.ID).
Str("email", email).
Str("role", string(role)).
Msg("Account member invited")
w.WriteHeader(http.StatusCreated)
}
}
type updateMemberRoleRequest struct {
Role string `json:"role"`
}
// HandleUpdateMemberRole returns an authenticated handler that updates a member's role.
func HandleUpdateMemberRole(reg *registry.TenantRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
accountID := strings.TrimSpace(r.PathValue("account_id"))
userID := strings.TrimSpace(r.PathValue("user_id"))
if accountID == "" || userID == "" {
auditEvent(r, "cp_account_member_role_update", "failure").
Str("reason", "missing_account_id_or_user_id").
Msg("Account member role update failed")
http.Error(w, "missing account_id or user_id", http.StatusBadRequest)
return
}
a, err := reg.GetAccount(accountID)
if err != nil {
auditEvent(r, "cp_account_member_role_update", "failure").
Err(err).
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "account_lookup_failed").
Msg("Account member role update failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if a == nil {
auditEvent(r, "cp_account_member_role_update", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "account_not_found").
Msg("Account member role update failed")
http.Error(w, "account not found", http.StatusNotFound)
return
}
actorRole, hasActorRole := requestActorRole(r)
if !hasActorRole || !actorCanManageAccount(actorRole) {
auditEvent(r, "cp_account_member_role_update", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("actor_role", string(actorRole)).
Str("reason", "missing_or_insufficient_role").
Msg("Account member role update failed")
http.Error(w, "forbidden", http.StatusForbidden)
return
}
var req updateMemberRoleRequest
if err := decodeJSON(w, r, &req); err != nil {
auditEvent(r, "cp_account_member_role_update", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "invalid_json_body").
Msg("Account member role update failed")
return
}
role, ok := parseMemberRole(req.Role)
if !ok {
auditEvent(r, "cp_account_member_role_update", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "invalid_role").
Msg("Account member role update failed")
http.Error(w, "invalid role", http.StatusBadRequest)
return
}
existing, err := reg.GetMembership(accountID, userID)
if err != nil {
auditEvent(r, "cp_account_member_role_update", "failure").
Err(err).
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "membership_lookup_failed").
Msg("Account member role update failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if existing == nil {
auditEvent(r, "cp_account_member_role_update", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "membership_not_found").
Msg("Account member role update failed")
http.Error(w, "membership not found", http.StatusNotFound)
return
}
if (role == registry.MemberRoleOwner || existing.Role == registry.MemberRoleOwner) && actorRole != registry.MemberRoleOwner {
auditEvent(r, "cp_account_member_role_update", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("actor_role", string(actorRole)).
Str("target_current_role", string(existing.Role)).
Str("target_new_role", string(role)).
Str("reason", "owner_role_change_requires_owner_actor").
Msg("Account member role update failed")
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if existing.Role == registry.MemberRoleOwner && role != registry.MemberRoleOwner {
memberships, listErr := reg.ListMembersByAccount(accountID)
if listErr != nil {
auditEvent(r, "cp_account_member_role_update", "failure").
Err(listErr).
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "membership_list_failed").
Msg("Account member role update failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
owners := 0
for _, mm := range memberships {
if mm.Role == registry.MemberRoleOwner {
owners++
}
}
if owners <= 1 {
auditEvent(r, "cp_account_member_role_update", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "cannot_demote_last_owner").
Msg("Account member role update denied")
http.Error(w, "cannot demote last owner", http.StatusConflict)
return
}
}
if err := reg.UpdateMembershipRole(accountID, userID, role); err != nil {
if isNotFoundErr(err) {
auditEvent(r, "cp_account_member_role_update", "failure").
Err(err).
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "membership_not_found").
Msg("Account member role update failed")
http.Error(w, "membership not found", http.StatusNotFound)
return
}
auditEvent(r, "cp_account_member_role_update", "failure").
Err(err).
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "membership_update_failed").
Msg("Account member role update failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
auditEvent(r, "cp_account_member_role_update", "success").
Str("account_id", accountID).
Str("user_id", userID).
Str("old_role", string(existing.Role)).
Str("new_role", string(role)).
Msg("Account member role updated")
w.WriteHeader(http.StatusOK)
}
}
// HandleRemoveMember returns an authenticated handler that removes a user from an account.
func HandleRemoveMember(reg *registry.TenantRegistry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
accountID := strings.TrimSpace(r.PathValue("account_id"))
userID := strings.TrimSpace(r.PathValue("user_id"))
if accountID == "" || userID == "" {
auditEvent(r, "cp_account_member_remove", "failure").
Str("reason", "missing_account_id_or_user_id").
Msg("Account member removal failed")
http.Error(w, "missing account_id or user_id", http.StatusBadRequest)
return
}
a, err := reg.GetAccount(accountID)
if err != nil {
auditEvent(r, "cp_account_member_remove", "failure").
Err(err).
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "account_lookup_failed").
Msg("Account member removal failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if a == nil {
auditEvent(r, "cp_account_member_remove", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "account_not_found").
Msg("Account member removal failed")
http.Error(w, "account not found", http.StatusNotFound)
return
}
actorRole, hasActorRole := requestActorRole(r)
if !hasActorRole || !actorCanManageAccount(actorRole) {
auditEvent(r, "cp_account_member_remove", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("actor_role", string(actorRole)).
Str("reason", "missing_or_insufficient_role").
Msg("Account member removal failed")
http.Error(w, "forbidden", http.StatusForbidden)
return
}
m, err := reg.GetMembership(accountID, userID)
if err != nil {
auditEvent(r, "cp_account_member_remove", "failure").
Err(err).
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "membership_lookup_failed").
Msg("Account member removal failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if m == nil {
auditEvent(r, "cp_account_member_remove", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "membership_not_found").
Msg("Account member removal failed")
http.Error(w, "membership not found", http.StatusNotFound)
return
}
if m.Role == registry.MemberRoleOwner {
if actorRole != registry.MemberRoleOwner {
auditEvent(r, "cp_account_member_remove", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("actor_role", string(actorRole)).
Str("target_role", string(m.Role)).
Str("reason", "owner_removal_requires_owner_actor").
Msg("Account member removal failed")
http.Error(w, "forbidden", http.StatusForbidden)
return
}
memberships, err := reg.ListMembersByAccount(accountID)
if err != nil {
auditEvent(r, "cp_account_member_remove", "failure").
Err(err).
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "membership_list_failed").
Msg("Account member removal failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
owners := 0
for _, mm := range memberships {
if mm.Role == registry.MemberRoleOwner {
owners++
}
}
if owners <= 1 {
auditEvent(r, "cp_account_member_remove", "failure").
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "cannot_remove_last_owner").
Msg("Account member removal denied")
http.Error(w, "cannot remove last owner", http.StatusConflict)
return
}
}
if err := reg.DeleteMembership(accountID, userID); err != nil {
if isNotFoundErr(err) {
auditEvent(r, "cp_account_member_remove", "failure").
Err(err).
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "membership_not_found").
Msg("Account member removal failed")
http.Error(w, "membership not found", http.StatusNotFound)
return
}
auditEvent(r, "cp_account_member_remove", "failure").
Err(err).
Str("account_id", accountID).
Str("user_id", userID).
Str("reason", "membership_delete_failed").
Msg("Account member removal failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
auditEvent(r, "cp_account_member_remove", "success").
Str("account_id", accountID).
Str("user_id", userID).
Str("removed_role", string(m.Role)).
Msg("Account member removed")
w.WriteHeader(http.StatusNoContent)
}
}
func normalizeEmail(s string) string {
s = strings.TrimSpace(s)
s = strings.ToLower(s)
// Minimal sanity; deeper validation comes later with session auth flows.
if s == "" || !strings.Contains(s, "@") {
return ""
}
return s
}
func parseMemberRole(s string) (registry.MemberRole, bool) {
switch registry.MemberRole(strings.TrimSpace(s)) {
case registry.MemberRoleOwner:
return registry.MemberRoleOwner, true
case registry.MemberRoleAdmin:
return registry.MemberRoleAdmin, true
case registry.MemberRoleTech:
return registry.MemberRoleTech, true
case registry.MemberRoleReadOnly:
return registry.MemberRoleReadOnly, true
default:
return "", false
}
}
func decodeJSON[T any](w http.ResponseWriter, r *http.Request, dst *T) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return fmt.Errorf("decode request body: %w", err)
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
if err == nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return fmt.Errorf("decode request body: multiple JSON values")
}
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return fmt.Errorf("decode request body: %w", err)
}
return nil
}
func encodeJSON(w http.ResponseWriter, payload any) {
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Error().Err(err).Msg("cloudcp.account: encode JSON response")
}
}
func isNotFoundErr(err error) bool {
if err == nil {
return false
}
// Registry uses fmt.Errorf("... not found") (no sentinel errors yet).
return strings.Contains(err.Error(), "not found")
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
// modernc.org/sqlite returns strings containing "UNIQUE constraint failed".
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unique constraint failed")
}