From 03f7fb37ed5381ca59b51e45d1570c906c69694e Mon Sep 17 00:00:00 2001
From: jvcalderon <jose@budibase.com>
Date: Thu, 18 Jan 2024 11:14:25 +0100
Subject: [PATCH] Calculate creators count when group role changes

---
 packages/account-portal                       |  2 +-
 packages/backend-core/src/index.ts            |  1 +
 packages/backend-core/src/users/db.ts         | 15 +++--
 .../backend-core/src/users/test/utils.spec.ts | 67 +++++++++++++++++++
 packages/backend-core/src/users/users.ts      |  3 +-
 packages/backend-core/src/users/utils.ts      | 35 +++++++++-
 packages/pro                                  |  2 +-
 packages/server/src/middleware/authorized.ts  |  2 +-
 .../shared-core/src/sdk/documents/users.ts    |  2 +-
 9 files changed, 117 insertions(+), 12 deletions(-)
 create mode 100644 packages/backend-core/src/users/test/utils.spec.ts

diff --git a/packages/account-portal b/packages/account-portal
index 1bc0128714..b23fb3b179 160000
--- a/packages/account-portal
+++ b/packages/account-portal
@@ -1 +1 @@
-Subproject commit 1bc012871496ff55e376931b620075b565e34d09
+Subproject commit b23fb3b17961fb04badd9487913a683fcf26dbe6
diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts
index 7bf26f3688..8946e37486 100644
--- a/packages/backend-core/src/index.ts
+++ b/packages/backend-core/src/index.ts
@@ -2,6 +2,7 @@ export * as configs from "./configs"
 export * as events from "./events"
 export * as migrations from "./migrations"
 export * as users from "./users"
+export * as usersUtils from "./users/utils"
 export * as roles from "./security/roles"
 export * as permissions from "./security/permissions"
 export * as accounts from "./accounts"
diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts
index 4d0d216603..136cb4b8ad 100644
--- a/packages/backend-core/src/users/db.ts
+++ b/packages/backend-core/src/users/db.ts
@@ -251,7 +251,8 @@ export class UserDB {
     }
 
     const change = dbUser ? 0 : 1 // no change if there is existing user
-    const creatorsChange = isCreator(dbUser) !== isCreator(user) ? 1 : 0
+    const creatorsChange =
+      (await isCreator(dbUser)) !== (await isCreator(user)) ? 1 : 0
     return UserDB.quotas.addUsers(change, creatorsChange, async () => {
       await validateUniqueUser(email, tenantId)
 
@@ -335,7 +336,7 @@ export class UserDB {
       }
       newUser.userGroups = groups || []
       newUsers.push(newUser)
-      if (isCreator(newUser)) {
+      if (await isCreator(newUser)) {
         newCreators.push(newUser)
       }
     }
@@ -432,12 +433,16 @@ export class UserDB {
       _deleted: true,
     }))
     const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
-    const creatorsToDelete = usersToDelete.filter(isCreator)
+
+    const creatorsEval = await Promise.all(usersToDelete.map(isCreator))
+    const creatorsToDeleteCount = creatorsEval.filter(
+      creator => !!creator
+    ).length
 
     for (let user of usersToDelete) {
       await bulkDeleteProcessing(user)
     }
-    await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length)
+    await UserDB.quotas.removeUsers(toDelete.length, creatorsToDeleteCount)
 
     // Build Response
     // index users by id
@@ -486,7 +491,7 @@ export class UserDB {
 
     await db.remove(userId, dbUser._rev)
 
-    const creatorsToDelete = isCreator(dbUser) ? 1 : 0
+    const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0
     await UserDB.quotas.removeUsers(1, creatorsToDelete)
     await eventHelpers.handleDeleteEvents(dbUser)
     await cache.user.invalidateUser(userId)
diff --git a/packages/backend-core/src/users/test/utils.spec.ts b/packages/backend-core/src/users/test/utils.spec.ts
new file mode 100644
index 0000000000..0fe27f57a6
--- /dev/null
+++ b/packages/backend-core/src/users/test/utils.spec.ts
@@ -0,0 +1,67 @@
+import { User, UserGroup } from "@budibase/types"
+import { generator, structures } from "../../../tests"
+import { DBTestConfiguration } from "../../../tests/extra"
+import { getGlobalDB } from "../../context"
+import { isCreator } from "../utils"
+
+const config = new DBTestConfiguration()
+
+describe("Users", () => {
+  it("User is a creator if it is configured as a global builder", async () => {
+    const user: User = structures.users.user({ builder: { global: true } })
+    expect(await isCreator(user)).toBe(true)
+  })
+
+  it("User is a creator if it is configured as a global admin", async () => {
+    const user: User = structures.users.user({ admin: { global: true } })
+    expect(await isCreator(user)).toBe(true)
+  })
+
+  it("User is a creator if it is configured with creator permission", async () => {
+    const user: User = structures.users.user({ builder: { creator: true } })
+    expect(await isCreator(user)).toBe(true)
+  })
+
+  it("User is a creator if it is a builder in some application", async () => {
+    const user: User = structures.users.user({ builder: { apps: ["app1"] } })
+    expect(await isCreator(user)).toBe(true)
+  })
+
+  it("User is a creator if it has CREATOR permission in some application", async () => {
+    const user: User = structures.users.user({ roles: { app1: "CREATOR" } })
+    expect(await isCreator(user)).toBe(true)
+  })
+
+  it("User is a creator if it has ADMIN permission in some application", async () => {
+    const user: User = structures.users.user({ roles: { app1: "ADMIN" } })
+    expect(await isCreator(user)).toBe(true)
+  })
+
+  it("User is a creator if it remains to a group with ADMIN permissions", async () => {
+    const usersInGroup = 10
+    const groupId = "gr_17abffe89e0b40268e755b952f101a59"
+    const group: UserGroup = {
+      ...structures.userGroups.userGroup(),
+      ...{ _id: groupId, roles: { app1: "ADMIN" } },
+    }
+    const users: User[] = []
+    for (const _ of Array.from({ length: usersInGroup })) {
+      const userId = `us_${generator.guid()}`
+      const user: User = structures.users.user({
+        _id: userId,
+        userGroups: [groupId],
+      })
+      users.push(user)
+    }
+
+    await config.doInTenant(async () => {
+      const db = getGlobalDB()
+      await db.put(group)
+      for (let user of users) {
+        await db.put(user)
+        const creator = await isCreator(user)
+        expect(creator).toBe(true)
+      }
+    })
+  })
+})
diff --git a/packages/backend-core/src/users/users.ts b/packages/backend-core/src/users/users.ts
index cc2b4fc27f..638da4a5b1 100644
--- a/packages/backend-core/src/users/users.ts
+++ b/packages/backend-core/src/users/users.ts
@@ -309,7 +309,8 @@ export async function getCreatorCount() {
   let creators = 0
   async function iterate(startPage?: string) {
     const page = await paginatedUsers({ bookmark: startPage })
-    creators += page.data.filter(isCreator).length
+    const creatorsEval = await Promise.all(page.data.map(isCreator))
+    creators += creatorsEval.filter(creator => !!creator).length
     if (page.hasNextPage) {
       await iterate(page.nextPage)
     }
diff --git a/packages/backend-core/src/users/utils.ts b/packages/backend-core/src/users/utils.ts
index 0ef4b77998..348ad1532f 100644
--- a/packages/backend-core/src/users/utils.ts
+++ b/packages/backend-core/src/users/utils.ts
@@ -1,4 +1,4 @@
-import { CloudAccount } from "@budibase/types"
+import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types"
 import * as accountSdk from "../accounts"
 import env from "../environment"
 import { getPlatformUser } from "./lookup"
@@ -6,17 +6,48 @@ import { EmailUnavailableError } from "../errors"
 import { getTenantId } from "../context"
 import { sdk } from "@budibase/shared-core"
 import { getAccountByTenantId } from "../accounts"
+import { BUILTIN_ROLE_IDS } from "../security/roles"
+import * as context from "../context"
 
 // extract from shared-core to make easily accessible from backend-core
 export const isBuilder = sdk.users.isBuilder
 export const isAdmin = sdk.users.isAdmin
-export const isCreator = sdk.users.isCreator
 export const isGlobalBuilder = sdk.users.isGlobalBuilder
 export const isAdminOrBuilder = sdk.users.isAdminOrBuilder
 export const hasAdminPermissions = sdk.users.hasAdminPermissions
 export const hasBuilderPermissions = sdk.users.hasBuilderPermissions
 export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions
 
+export async function isCreator(user?: User | ContextUser) {
+  const isCreatorByUserDefinition = sdk.users.isCreator(user)
+  if (!isCreatorByUserDefinition && user) {
+    return await isCreatorByGroupMembership(user)
+  }
+  return isCreatorByUserDefinition
+}
+
+async function isCreatorByGroupMembership(user?: User | ContextUser) {
+  const userGroups = user?.userGroups || []
+  if (userGroups.length > 0) {
+    const db = context.getGlobalDB()
+    const groups: UserGroup[] = []
+    for (let groupId of userGroups) {
+      try {
+        const group = await db.get<UserGroup>(groupId)
+        groups.push(group)
+      } catch (e: any) {
+        if (e.error !== "not_found") {
+          throw e
+        }
+      }
+    }
+    return groups.some(group =>
+      Object.values(group.roles || {}).includes(BUILTIN_ROLE_IDS.ADMIN)
+    )
+  }
+  return false
+}
+
 export async function validateUniqueUser(email: string, tenantId: string) {
   // check budibase users in other tenants
   if (env.MULTI_TENANCY) {
diff --git a/packages/pro b/packages/pro
index 9d80daaa5b..8c466d6ef2 160000
--- a/packages/pro
+++ b/packages/pro
@@ -1 +1 @@
-Subproject commit 9d80daaa5b79da68730d6c5f497f629c47a78ef8
+Subproject commit 8c466d6ef2a0c09b843ef63276793ab5af2e96f7
diff --git a/packages/server/src/middleware/authorized.ts b/packages/server/src/middleware/authorized.ts
index cba765a887..fe7f6bb6fe 100644
--- a/packages/server/src/middleware/authorized.ts
+++ b/packages/server/src/middleware/authorized.ts
@@ -34,7 +34,7 @@ const checkAuthorized = async (
   const isCreatorApi = permType === PermissionType.CREATOR
   const isBuilderApi = permType === PermissionType.BUILDER
   const isGlobalBuilder = users.isGlobalBuilder(ctx.user)
-  const isCreator = users.isCreator(ctx.user)
+  const isCreator = await users.isCreator(ctx.user)
   const isBuilder = appId
     ? users.isBuilder(ctx.user, appId)
     : users.hasBuilderPermissions(ctx.user)
diff --git a/packages/shared-core/src/sdk/documents/users.ts b/packages/shared-core/src/sdk/documents/users.ts
index 1aaf44ff7c..11e80dcf29 100644
--- a/packages/shared-core/src/sdk/documents/users.ts
+++ b/packages/shared-core/src/sdk/documents/users.ts
@@ -70,7 +70,7 @@ export function hasAppCreatorPermissions(user?: User | ContextUser): boolean {
   return _.flow(
     _.get("roles"),
     _.values,
-    _.find(x => x === "CREATOR"),
+    _.find(x => ["CREATOR", "ADMIN"].includes(x)),
     x => !!x
   )(user)
 }