package handlers import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "net/http" "strings" "time" "github.com/glueops/autoglue/internal/api/httpmiddleware" "github.com/glueops/autoglue/internal/auth" "github.com/glueops/autoglue/internal/models" "github.com/glueops/autoglue/internal/utils" "github.com/go-chi/chi/v5" "github.com/google/uuid" "gorm.io/gorm" ) // ---------- Helpers ---------- func mustUser(r *http.Request) (*models.User, bool) { return httpmiddleware.UserFrom(r.Context()) } func isOrgRole(db *gorm.DB, userID, orgID uuid.UUID, want ...string) (bool, string) { var m models.Membership if err := db.Where("user_id = ? AND organization_id = ?", userID, orgID).First(&m).Error; err != nil { return false, "" } got := strings.ToLower(m.Role) for _, w := range want { if got == strings.ToLower(w) { return true, got } } return false, got } func mustMember(db *gorm.DB, userID, orgID uuid.UUID) bool { ok, _ := isOrgRole(db, userID, orgID, "owner", "admin", "member") return ok } func randomB64URL(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } // ---------- Orgs: list/create/get/update/delete ---------- type orgCreateReq struct { Name string `json:"name" example:"Acme Corp"` Domain *string `json:"domain,omitempty" example:"acme.com"` } // CreateOrg godoc // // @ID CreateOrg // @Summary Create organization // @Tags Orgs // @Accept json // @Produce json // @Param body body orgCreateReq true "Org payload" // @Success 201 {object} models.Organization // @Failure 400 {object} utils.ErrorResponse // @Failure 401 {object} utils.ErrorResponse // @Failure 409 {object} utils.ErrorResponse // @Router /orgs [post] // @ID createOrg // @Security BearerAuth func CreateOrg(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "") return } var req orgCreateReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, 400, "invalid_json", err.Error()) return } if strings.TrimSpace(req.Name) == "" { utils.WriteError(w, 400, "validation_error", "name is required") return } org := models.Organization{Name: req.Name} if req.Domain != nil && strings.TrimSpace(*req.Domain) != "" { org.Domain = req.Domain } if err := db.Create(&org).Error; err != nil { utils.WriteError(w, 409, "conflict", err.Error()) return } // creator is owner _ = db.Create(&models.Membership{ UserID: u.ID, OrganizationID: org.ID, Role: "owner", }).Error utils.WriteJSON(w, 201, org) } } // ListMyOrgs godoc // // @ID ListMyOrgs // @Summary List organizations I belong to // @Tags Orgs // @Produce json // @Success 200 {array} models.Organization // @Failure 401 {object} utils.ErrorResponse // @Router /orgs [get] // @ID listMyOrgs // @Security BearerAuth func ListMyOrgs(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "") return } var orgs []models.Organization if err := db. Joins("join memberships m on m.organization_id = organizations.id"). Where("m.user_id = ?", u.ID). Order("organizations.created_at desc"). Find(&orgs).Error; err != nil { utils.WriteError(w, 500, "db_error", err.Error()) return } utils.WriteJSON(w, 200, orgs) } } // GetOrg godoc // // @ID GetOrg // @Summary Get organization // @Tags Orgs // @Produce json // @Param id path string true "Org ID (UUID)" // @Success 200 {object} models.Organization // @Failure 401 {object} utils.ErrorResponse // @Failure 404 {object} utils.ErrorResponse // @Router /orgs/{id} [get] // @ID getOrg // @Security BearerAuth func GetOrg(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, 401, "unauthorized", "") return } oid, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, 404, "not_found", "org not found") return } if !mustMember(db, u.ID, oid) { utils.WriteError(w, 401, "forbidden", "not a member") return } var org models.Organization if err := db.First(&org, "id = ?", oid).Error; err != nil { utils.WriteError(w, 404, "not_found", "org not found") return } utils.WriteJSON(w, 200, org) } } type orgUpdateReq struct { Name *string `json:"name,omitempty"` Domain *string `json:"domain,omitempty"` } // UpdateOrg godoc // // @ID UpdateOrg // @Summary Update organization (owner/admin) // @Tags Orgs // @Accept json // @Produce json // @Param id path string true "Org ID (UUID)" // @Param body body orgUpdateReq true "Update payload" // @Success 200 {object} models.Organization // @Failure 401 {object} utils.ErrorResponse // @Failure 404 {object} utils.ErrorResponse // @Router /orgs/{id} [patch] // @ID updateOrg // @Security BearerAuth func UpdateOrg(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, 401, "unauthorized", "") return } oid, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, 404, "not_found", "org not found") return } if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok { utils.WriteError(w, 401, "forbidden", "admin or owner required") return } var req orgUpdateReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, 400, "invalid_json", err.Error()) return } changes := map[string]any{} if req.Name != nil { changes["name"] = strings.TrimSpace(*req.Name) } if req.Domain != nil { if d := strings.TrimSpace(*req.Domain); d == "" { changes["domain"] = nil } else { changes["domain"] = d } } if len(changes) > 0 { if err := db.Model(&models.Organization{}).Where("id = ?", oid).Updates(changes).Error; err != nil { utils.WriteError(w, 500, "db_error", err.Error()) return } } var out models.Organization _ = db.First(&out, "id = ?", oid).Error utils.WriteJSON(w, 200, out) } } // DeleteOrg godoc // // @ID DeleteOrg // @Summary Delete organization (owner) // @Tags Orgs // @Produce json // @Param id path string true "Org ID (UUID)" // @Success 204 "Deleted" // @Failure 401 {object} utils.ErrorResponse // @Failure 404 {object} utils.ErrorResponse // @Router /orgs/{id} [delete] // @ID deleteOrg // @Security BearerAuth func DeleteOrg(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, 401, "unauthorized", "") return } oid, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, 404, "not_found", "org not found") return } if ok, _ := isOrgRole(db, u.ID, oid, "owner"); !ok { utils.WriteError(w, 401, "forbidden", "owner required") return } // Optional safety: deny if members >1 or resources exist; here we just delete. res := db.Delete(&models.Organization{}, "id = ?", oid) if res.Error != nil { utils.WriteError(w, 500, "db_error", res.Error.Error()) return } if res.RowsAffected == 0 { utils.WriteError(w, 404, "not_found", "org not found") return } w.WriteHeader(204) } } // ---------- Members: list/add/update/delete ---------- type memberOut struct { UserID uuid.UUID `json:"user_id" format:"uuid"` Email string `json:"email"` Role string `json:"role"` // owner/admin/member } type memberUpsertReq struct { UserID uuid.UUID `json:"user_id" format:"uuid"` Role string `json:"role" example:"member"` } // ListMembers godoc // // @ID ListMembers // @Summary List members in org // @Tags Orgs // @Produce json // @Param id path string true "Org ID (UUID)" // @Success 200 {array} memberOut // @Failure 401 {object} utils.ErrorResponse // @Router /orgs/{id}/members [get] // @ID listMembers // @Security BearerAuth func ListMembers(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, 401, "unauthorized", "") return } oid, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil || !mustMember(db, u.ID, oid) { utils.WriteError(w, 401, "forbidden", "") return } var ms []models.Membership if err := db.Where("organization_id = ?", oid).Find(&ms).Error; err != nil { utils.WriteError(w, 500, "db_error", err.Error()) return } // load emails userIDs := make([]uuid.UUID, 0, len(ms)) for _, m := range ms { userIDs = append(userIDs, m.UserID) } var emails []models.UserEmail if len(userIDs) > 0 { _ = db.Where("user_id in ?", userIDs).Where("is_primary = true").Find(&emails).Error } emailByUser := map[uuid.UUID]string{} for _, e := range emails { emailByUser[e.UserID] = e.Email } out := make([]memberOut, 0, len(ms)) for _, m := range ms { out = append(out, memberOut{ UserID: m.UserID, Email: emailByUser[m.UserID], Role: m.Role, }) } utils.WriteJSON(w, 200, out) } } // AddOrUpdateMember godoc // // @ID AddOrUpdateMember // @Summary Add or update a member (owner/admin) // @Tags Orgs // @Accept json // @Produce json // @Param id path string true "Org ID (UUID)" // @Param body body memberUpsertReq true "User & role" // @Success 200 {object} memberOut // @Failure 401 {object} utils.ErrorResponse // @Router /orgs/{id}/members [post] // @ID addOrUpdateMember // @Security BearerAuth func AddOrUpdateMember(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, 401, "unauthorized", "") return } oid, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, 404, "not_found", "org not found") return } if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok { utils.WriteError(w, 401, "forbidden", "admin or owner required") return } var req memberUpsertReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, 400, "invalid_json", err.Error()) return } role := strings.ToLower(strings.TrimSpace(req.Role)) if role != "owner" && role != "admin" && role != "member" { utils.WriteError(w, 400, "validation_error", "role must be owner|admin|member") return } var m models.Membership tx := db.Where("user_id = ? AND organization_id = ?", req.UserID, oid).First(&m) if tx.Error == nil { // update if err := db.Model(&m).Update("role", role).Error; err != nil { utils.WriteError(w, 500, "db_error", err.Error()) return } } else if errors.Is(tx.Error, gorm.ErrRecordNotFound) { m = models.Membership{UserID: req.UserID, OrganizationID: oid, Role: role} if err := db.Create(&m).Error; err != nil { utils.WriteError(w, 500, "db_error", err.Error()) return } } else { utils.WriteError(w, 500, "db_error", tx.Error.Error()) return } // make response var ue models.UserEmail _ = db.Where("user_id = ? AND is_primary = true", req.UserID).First(&ue).Error utils.WriteJSON(w, 200, memberOut{ UserID: req.UserID, Email: ue.Email, Role: m.Role, }) } } // RemoveMember godoc // // @ID RemoveMember // @Summary Remove a member (owner/admin) // @Tags Orgs // @Produce json // @Param id path string true "Org ID (UUID)" // @Param user_id path string true "User ID (UUID)" // @Success 204 "Removed" // @Failure 401 {object} utils.ErrorResponse // @Router /orgs/{id}/members/{user_id} [delete] // @ID removeMember // @Security BearerAuth func RemoveMember(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, 401, "unauthorized", "") return } oid, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, 404, "not_found", "org not found") return } if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok { utils.WriteError(w, 401, "forbidden", "admin or owner required") return } uid, err := uuid.Parse(chi.URLParam(r, "user_id")) if err != nil { utils.WriteError(w, 400, "invalid_user_id", "") return } res := db.Where("user_id = ? AND organization_id = ?", uid, oid).Delete(&models.Membership{}) if res.Error != nil { utils.WriteError(w, 500, "db_error", res.Error.Error()) return } w.WriteHeader(204) } } // ---------- Org API Keys (key/secret pair) ---------- type orgKeyCreateReq struct { Name string `json:"name,omitempty" example:"automation-bot"` ExpiresInHours *int `json:"expires_in_hours,omitempty" example:"720"` } type orgKeyCreateResp struct { ID uuid.UUID `json:"id"` Name string `json:"name,omitempty"` Scope string `json:"scope"` // "org" CreatedAt time.Time `json:"created_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` OrgKey string `json:"org_key"` // shown once: OrgSecret string `json:"org_secret"` // shown once: } // ListOrgKeys godoc // // @ID ListOrgKeys // @Summary List org-scoped API keys (no secrets) // @Tags Orgs // @Produce json // @Param id path string true "Org ID (UUID)" // @Success 200 {array} models.APIKey // @Failure 401 {object} utils.ErrorResponse // @Router /orgs/{id}/api-keys [get] // @ID listOrgKeys // @Security BearerAuth func ListOrgKeys(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, 401, "unauthorized", "") return } oid, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil || !mustMember(db, u.ID, oid) { utils.WriteError(w, 401, "forbidden", "") return } var keys []models.APIKey if err := db.Where("org_id = ? AND scope = ?", oid, "org"). Order("created_at desc"). Find(&keys).Error; err != nil { utils.WriteError(w, 500, "db_error", err.Error()) return } // SecretHash must not be exposed; your json tags likely hide it already. utils.WriteJSON(w, 200, keys) } } // CreateOrgKey godoc // // @ID CreateOrgKey // @Summary Create org key/secret pair (owner/admin) // @Tags Orgs // @Accept json // @Produce json // @Param id path string true "Org ID (UUID)" // @Param body body orgKeyCreateReq true "Key name + optional expiry" // @Success 201 {object} orgKeyCreateResp // @Failure 401 {object} utils.ErrorResponse // @Router /orgs/{id}/api-keys [post] // @ID createOrgKey // @Security BearerAuth func CreateOrgKey(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, 401, "unauthorized", "") return } oid, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, 404, "not_found", "org not found") return } if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok { utils.WriteError(w, 401, "forbidden", "admin or owner required") return } var req orgKeyCreateReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, 400, "invalid_json", err.Error()) return } // generate keySuffix, err := randomB64URL(16) if err != nil { utils.WriteError(w, 500, "entropy_error", err.Error()) return } sec, err := randomB64URL(32) if err != nil { utils.WriteError(w, 500, "entropy_error", err.Error()) return } orgKey := "org_" + keySuffix secretPlain := sec keyHash := auth.SHA256Hex(orgKey) secretHash, err := auth.HashSecretArgon2id(secretPlain) if err != nil { utils.WriteError(w, 500, "hash_error", err.Error()) return } var exp *time.Time if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 { e := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour) exp = &e } prefix := orgKey if len(prefix) > 12 { prefix = prefix[:12] } rec := models.APIKey{ OrgID: &oid, Scope: "org", Purpose: "user", IsEphemeral: false, Name: req.Name, KeyHash: keyHash, SecretHash: &secretHash, ExpiresAt: exp, Revoked: false, Prefix: &prefix, } if err := db.Create(&rec).Error; err != nil { utils.WriteError(w, 500, "db_error", err.Error()) return } utils.WriteJSON(w, 201, orgKeyCreateResp{ ID: rec.ID, Name: rec.Name, Scope: rec.Scope, CreatedAt: rec.CreatedAt, ExpiresAt: rec.ExpiresAt, OrgKey: orgKey, OrgSecret: secretPlain, }) } } // DeleteOrgKey godoc // // @ID DeleteOrgKey // @Summary Delete org key (owner/admin) // @Tags Orgs // @Produce json // @Param id path string true "Org ID (UUID)" // @Param key_id path string true "Key ID (UUID)" // @Success 204 "Deleted" // @Failure 401 {object} utils.ErrorResponse // @Router /orgs/{id}/api-keys/{key_id} [delete] // @ID deleteOrgKey // @Security BearerAuth func DeleteOrgKey(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := mustUser(r) if !ok { utils.WriteError(w, 401, "unauthorized", "") return } oid, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, 404, "not_found", "org not found") return } if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok { utils.WriteError(w, 401, "forbidden", "admin or owner required") return } kid, err := uuid.Parse(chi.URLParam(r, "key_id")) if err != nil { utils.WriteError(w, 400, "invalid_key_id", "") return } res := db.Where("id = ? AND org_id = ? AND scope = ?", kid, oid, "org").Delete(&models.APIKey{}) if res.Error != nil { utils.WriteError(w, 500, "db_error", res.Error.Error()) return } if res.RowsAffected == 0 { utils.WriteError(w, 404, "not_found", "key not found") return } w.WriteHeader(204) } }