-
Notifications
You must be signed in to change notification settings - Fork 28
[feature] - Add feature of promoting suborg to parent org #371
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13165,6 +13165,257 @@ func HandleCreateSubOrg(resp http.ResponseWriter, request *http.Request) { | |
|
|
||
| } | ||
|
|
||
| func resolveParentOrgId(subOrg *Org) string { | ||
| if subOrg == nil { | ||
| return "" | ||
| } | ||
|
|
||
| if subOrg.CreatorOrg != "" { | ||
| return subOrg.CreatorOrg | ||
| } | ||
|
|
||
| if len(subOrg.ManagerOrgs) > 0 { | ||
| return subOrg.ManagerOrgs[0].Id | ||
| } | ||
|
|
||
| return "" | ||
| } | ||
|
|
||
| func clearPromoteSubOrgCaches(ctx context.Context, parentOrgId, subOrgId string, parentOrg *Org, subOrg *Org) { | ||
| DeleteCache(ctx, fmt.Sprintf("Organizations_%s", parentOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("Organizations_%s", subOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("%s__childorgs", parentOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("%s_childorgs", parentOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("creator_Organizations_%s", subOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("apps_%s", parentOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("apps_%s", subOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("%s_workflows", parentOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("%s_workflows", subOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("files_%s_", parentOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("files_%s_", subOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("org_cache_%s_%s_%s", "", parentOrgId, "")) | ||
| DeleteCache(ctx, fmt.Sprintf("org_cache_%s_%s_%s", "", subOrgId, "")) | ||
| DeleteCache(ctx, fmt.Sprintf("datastore_category_%s", parentOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("datastore_category_%s", subOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("workflowappauth_%s", subOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("workflowappauth_%s", parentOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("Environments_%s", subOrgId)) | ||
| DeleteCache(ctx, fmt.Sprintf("Environments_%s", parentOrgId)) | ||
|
|
||
| processedUsers := map[string]bool{} | ||
| allUsers := []User{} | ||
| if parentOrg != nil { | ||
| allUsers = append(allUsers, parentOrg.Users...) | ||
| } | ||
| if subOrg != nil { | ||
| allUsers = append(allUsers, subOrg.Users...) | ||
| } | ||
|
|
||
| for _, orgUser := range allUsers { | ||
| if processedUsers[orgUser.Id] { | ||
| continue | ||
| } | ||
| processedUsers[orgUser.Id] = true | ||
|
|
||
| DeleteCache(ctx, fmt.Sprintf("user_%s", orgUser.Id)) | ||
| DeleteCache(ctx, fmt.Sprintf("user_%s", orgUser.Username)) | ||
| DeleteCache(ctx, fmt.Sprintf("apps_%s", orgUser.Id)) | ||
| DeleteCache(ctx, fmt.Sprintf("%s_workflows", orgUser.Id)) | ||
| DeleteCache(ctx, fmt.Sprintf("user_orgs_%s", orgUser.Id)) | ||
| DeleteCache(ctx, fmt.Sprintf("Users_%s", orgUser.ApiKey)) | ||
| DeleteCache(ctx, fmt.Sprintf("%s", orgUser.ApiKey)) | ||
| DeleteCache(ctx, orgUser.Session) | ||
| DeleteCache(ctx, fmt.Sprintf("session_%s", orgUser.Session)) | ||
| } | ||
| } | ||
|
|
||
| func HandlePromoteSubOrg(resp http.ResponseWriter, request *http.Request) { | ||
| cors := HandleCors(resp, request) | ||
| if cors { | ||
| return | ||
| } | ||
|
|
||
| user, err := HandleApiAuthentication(resp, request) | ||
| if err != nil { | ||
| log.Printf("[WARNING] Api authentication failed in promote suborg: %s", err) | ||
| resp.WriteHeader(401) | ||
| resp.Write([]byte(`{"success": false, "reason": "Authentication failed"}`)) | ||
| return | ||
| } | ||
|
|
||
| var parentOrgId string | ||
| location := strings.Split(request.URL.String(), "/") | ||
| if location[1] == "api" { | ||
| if len(location) <= 4 { | ||
| resp.WriteHeader(401) | ||
| resp.Write([]byte(`{"success": false, "reason": "Bad request path"}`)) | ||
| return | ||
| } | ||
|
|
||
| parentOrgId = location[4] | ||
| } | ||
|
|
||
| if strings.Contains(parentOrgId, "?") { | ||
| parentOrgId = strings.Split(parentOrgId, "?")[0] | ||
| } | ||
|
|
||
| type promoteRequest struct { | ||
| OrgId string `json:"org_id"` | ||
| SuborgId string `json:"suborg_id"` | ||
| } | ||
|
|
||
| body, err := ioutil.ReadAll(request.Body) | ||
| if err != nil { | ||
| resp.WriteHeader(400) | ||
| resp.Write([]byte(`{"success": false, "reason": "Failed reading body"}`)) | ||
| return | ||
| } | ||
|
|
||
| requestData := promoteRequest{} | ||
| if err := json.Unmarshal(body, &requestData); err != nil { | ||
| resp.WriteHeader(400) | ||
| resp.Write([]byte(`{"success": false, "reason": "Invalid request body"}`)) | ||
| return | ||
| } | ||
|
|
||
| if user.ActiveOrg.Id != parentOrgId { | ||
| resp.WriteHeader(400) | ||
| resp.Write([]byte(`{"success": false, "reason": "org_id mismatch between path and body"}`)) | ||
| return | ||
| } | ||
|
|
||
| if requestData.SuborgId == "" { | ||
| resp.WriteHeader(400) | ||
| resp.Write([]byte(`{"success": false, "reason": "suborg_id is required"}`)) | ||
| return | ||
| } | ||
|
|
||
| if !user.SupportAccess { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm primarily worried what would happen here if we give this to users, which is.. kind of the point. They should control this themselves.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we make this available to normal users? I am afraid that if someone promotes a sub-organization to a parent organization without understanding the impact, they could lose access to distributed auth, files, datastore, and environment data due to how things are currently set up and this can impact workflows. I would prefer to keep this limited to support users. Let me know in case you want me to make it public for normal users.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They can just move it back then? References aren't lost.. |
||
| log.Printf("[WARNING] User %s (%s) attempted to promote suborg without support access", user.Username, user.Id) | ||
| resp.WriteHeader(403) | ||
| resp.Write([]byte(`{"success": false, "reason": "Support access required"}`)) | ||
| return | ||
| } | ||
|
|
||
| if project.Environment == "cloud" { | ||
| gceProject := os.Getenv("SHUFFLE_GCEPROJECT") | ||
| if gceProject != "shuffler" && gceProject != sandboxProject && len(gceProject) > 0 { | ||
| ctx := GetContext(request) | ||
| parentOrg, _ := GetOrg(ctx, parentOrgId) | ||
| subOrg, _ := GetOrg(ctx, requestData.SuborgId) | ||
| clearPromoteSubOrgCaches(ctx, parentOrgId, requestData.SuborgId, parentOrg, subOrg) | ||
|
|
||
| log.Printf("[DEBUG] Redirecting Promote Suborg request to main site handler (shuffler.io)") | ||
| RedirectUserRequest(resp, request) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| ctx := GetContext(request) | ||
| parentOrg, err := GetOrg(ctx, parentOrgId) | ||
| if err != nil { | ||
| log.Printf("[WARNING] Failed loading parent org '%s' in promote suborg: %s", parentOrgId, err) | ||
| resp.WriteHeader(404) | ||
| resp.Write([]byte(`{"success": false, "reason": "Parent organization not found"}`)) | ||
| return | ||
| } | ||
|
|
||
| subOrg, err := GetOrg(ctx, requestData.SuborgId) | ||
| if err != nil { | ||
| log.Printf("[WARNING] Failed loading suborg '%s' in promote suborg: %s", requestData.SuborgId, err) | ||
| resp.WriteHeader(404) | ||
| resp.Write([]byte(`{"success": false, "reason": "Sub-organization not found"}`)) | ||
| return | ||
| } | ||
|
|
||
| if len(parentOrg.CreatorOrg) > 0 { | ||
| resp.WriteHeader(400) | ||
| resp.Write([]byte(`{"success": false, "reason": "Cannot promote from a sub-organization context"}`)) | ||
| return | ||
| } | ||
|
|
||
| resolvedParentId := resolveParentOrgId(subOrg) | ||
| if resolvedParentId == "" { | ||
| resp.WriteHeader(400) | ||
| resp.Write([]byte(`{"success": false, "reason": "Sub-organization is already standalone"}`)) | ||
| return | ||
| } | ||
|
|
||
| if resolvedParentId != parentOrg.Id { | ||
| resp.WriteHeader(400) | ||
| resp.Write([]byte(`{"success": false, "reason": "Sub-organization does not belong to this parent"}`)) | ||
| return | ||
| } | ||
|
|
||
| if len(subOrg.ChildOrgs) > 0 { | ||
| resp.WriteHeader(400) | ||
| resp.Write([]byte(`{"success": false, "reason": "Cannot promote a sub-organization that has child organizations"}`)) | ||
| return | ||
| } | ||
|
|
||
| subOrg.CreatorOrg = "" | ||
| subOrg.ManagerOrgs = []OrgMini{} | ||
|
|
||
| newChildOrgs := []OrgMini{} | ||
| removedSuborg := false | ||
| for _, child := range parentOrg.ChildOrgs { | ||
| if child.Id == subOrg.Id { | ||
| removedSuborg = true | ||
| continue | ||
| } | ||
|
|
||
| newChildOrgs = append(newChildOrgs, child) | ||
| } | ||
| parentOrg.ChildOrgs = newChildOrgs | ||
| subOrg.Description = "" | ||
|
|
||
| if err := SetOrg(ctx, *parentOrg, parentOrg.Id); err != nil { | ||
| log.Printf("[ERROR] Failed writing parent org '%s' while promoting '%s': %s", parentOrg.Id, subOrg.Id, err) | ||
| resp.WriteHeader(500) | ||
| resp.Write([]byte(`{"success": false, "reason": "Sub-organization promoted, but parent update failed"}`)) | ||
| return | ||
| } | ||
|
|
||
| if err := SetOrg(ctx, *subOrg, subOrg.Id); err != nil { | ||
| log.Printf("[ERROR] Failed writing promoted org '%s': %s", subOrg.Id, err) | ||
| resp.WriteHeader(500) | ||
| resp.Write([]byte(`{"success": false, "reason": "Failed updating sub-organization"}`)) | ||
| return | ||
| } | ||
|
|
||
| updatedWorkflows := 0 | ||
| subOrgUser := User{ | ||
| ActiveOrg: OrgMini{Id: subOrg.Id}, | ||
| Role: "admin", | ||
| } | ||
|
|
||
| workflowItems, err := GetAllWorkflowsByQuery(ctx, subOrgUser, 250, "") | ||
| if err != nil { | ||
| log.Printf("[WARNING] Failed loading workflows for promoted org '%s': %s", subOrg.Id, err) | ||
| } | ||
|
|
||
| for _, workflow := range workflowItems { | ||
| if workflow.ParentWorkflowId == "" { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't this be the same as "stealing" a workflow? Not sure.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We skip workflows where If a workflow has a parent org ID, it means the workflow is distributed, so we reset it to empty but we are making copy of workflow when distributed. That's why it is still accessible after promoting to parent org. |
||
| continue | ||
| } | ||
|
|
||
| workflow.ParentWorkflowId = "" | ||
| if err := SetWorkflow(ctx, workflow, workflow.ID); err != nil { | ||
| log.Printf("[WARNING] Failed updating workflow '%s' while promoting '%s': %s", workflow.ID, subOrg.Id, err) | ||
| continue | ||
| } | ||
|
|
||
| updatedWorkflows += 1 | ||
| } | ||
|
|
||
| clearPromoteSubOrgCaches(ctx, parentOrg.Id, subOrg.Id, parentOrg, subOrg) | ||
|
|
||
| log.Printf("[AUDIT] User %s (%s) promoted suborg %s (%s). Parent updated=%t. Workflows=%d", user.Username, user.Id, subOrg.Name, subOrg.Id, removedSuborg, updatedWorkflows) | ||
|
|
||
| resp.WriteHeader(200) | ||
| resp.Write([]byte(fmt.Sprintf(`{"success": true, "reason": "Sub-organization promoted successfully", "removed_from_parent": %t, "updated_workflows": %d}`, removedSuborg, updatedWorkflows))) | ||
| } | ||
|
|
||
| func getSignatureSample(org Org) PaymentSubscription { | ||
| if len(org.Subscriptions) > 0 { | ||
| for _, sub := range org.Subscriptions { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please document the API right away on the "API" docs. Otherwise we will forget it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(this is why I try to not make new APIs if we don't NEED to) :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, will write doc for it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have added doc for it in below PR.
Shuffle/shuffle-docs#227