From b6d0bfeaf779908c53bd8a9c25c636a87868368b Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Mon, 16 Sep 2024 10:21:16 +0200 Subject: [PATCH] feat: allow assignment of multiple teams (#4198) --- packages/api-aco/src/createAcoContext.ts | 13 +-- .../src/utils/FolderLevelPermissions.ts | 35 +++---- .../src/createExternalIdpAdminUserHooks.ts | 60 ++++++++++++ .../api-admin-users/src/graphql/user.gql.ts | 19 ++-- packages/api-admin-users/src/types.ts | 8 +- packages/api-authentication/src/types.ts | 8 ++ packages/api-security-auth0/package.json | 1 - .../src/createAdminUsersHooks.ts | 50 +--------- .../api-security-auth0/tsconfig.build.json | 1 - packages/api-security-auth0/tsconfig.json | 3 - .../__tests__/graphql/users.ts | 2 +- .../__tests__/users.test.ts | 66 +++++++------ packages/api-security-cognito/package.json | 3 +- .../src/createAdminUsersHooks.ts | 82 +++++++++------- .../src/graphql/user.gql.ts | 8 +- .../src/createAdminUsersHooks.ts | 50 +--------- packages/api-security-so-ddb/src/index.ts | 26 +++-- .../mocks/mockCreateGetWcpProjectLicense.ts | 9 +- .../src/createSecurity/createGroupsMethods.ts | 4 +- .../src/createSecurity/createTeamsMethods.ts | 12 ++- packages/api-security/src/index.ts | 7 +- packages/api-security/src/types.ts | 17 +++- .../GroupsMultiAutocompleteElement.tsx | 36 +++++++ .../TeamsMultiAutocompleteElement.tsx | 36 +++++++ .../src/ui/views/Users/UsersFormView.tsx | 26 ++--- .../src/ui/views/Users/graphql.ts | 4 +- .../GroupsMultiAutocomplete/index.tsx | 2 +- .../TeamsMultiAutocomplete/graphql.ts | 22 +++++ .../TeamsMultiAutocomplete/index.tsx | 22 +++++ .../GroupsMultiAutocompleteElement.tsx | 4 +- .../src/ui/views/Teams/TeamsForm.tsx | 4 +- .../cli-plugin-deploy-pulumi/package.json | 5 - .../migrations/5.41.0/001/001.data.ts | 93 ++++++++++++++++++ .../migrations/5.41.0/001/001.test.ts | 83 ++++++++++++++++ packages/migrations/src/ddb-es.ts | 81 +--------------- packages/migrations/src/ddb.ts | 55 +---------- .../5.41.0/001/createTenantEntity.ts | 39 ++++++++ .../migrations/5.41.0/001/createUserEntity.ts | 48 ++++++++++ .../src/migrations/5.41.0/001/index.ts | 94 +++++++++++++++++++ yarn.lock | 2 +- 40 files changed, 767 insertions(+), 373 deletions(-) create mode 100644 packages/api-admin-users/src/createExternalIdpAdminUserHooks.ts create mode 100644 packages/app-admin-users-cognito/src/ui/elements/GroupsMultiAutocompleteElement.tsx create mode 100644 packages/app-admin-users-cognito/src/ui/elements/TeamsMultiAutocompleteElement.tsx create mode 100644 packages/app-security-access-management/src/components/TeamsMultiAutocomplete/graphql.ts create mode 100644 packages/app-security-access-management/src/components/TeamsMultiAutocomplete/index.tsx create mode 100644 packages/migrations/__tests__/migrations/5.41.0/001/001.data.ts create mode 100644 packages/migrations/__tests__/migrations/5.41.0/001/001.test.ts create mode 100644 packages/migrations/src/migrations/5.41.0/001/createTenantEntity.ts create mode 100644 packages/migrations/src/migrations/5.41.0/001/createUserEntity.ts create mode 100644 packages/migrations/src/migrations/5.41.0/001/index.ts diff --git a/packages/api-aco/src/createAcoContext.ts b/packages/api-aco/src/createAcoContext.ts index b62974c21ef..eb747724b8c 100644 --- a/packages/api-aco/src/createAcoContext.ts +++ b/packages/api-aco/src/createAcoContext.ts @@ -58,23 +58,24 @@ const setupAcoContext = async ( const folderLevelPermissions = new FolderLevelPermissions({ getIdentity: () => security.getIdentity(), - getIdentityTeam: async () => { + listIdentityTeams: async () => { return security.withoutAuthorization(async () => { const identity = security.getIdentity(); if (!identity) { - return null; + return []; } const adminUser = await context.adminUsers.getUser({ where: { id: identity.id } }); if (!adminUser) { - return null; + return []; } - if (!adminUser.team) { - return null; + const hasTeams = adminUser.teams && adminUser.teams.length > 0; + if (hasTeams) { + return context.security.listTeams({ where: { id_in: adminUser.teams } }); } - return context.security.getTeam({ where: { id: adminUser.team } }); + return []; }); }, listPermissions: () => security.listPermissions(), diff --git a/packages/api-aco/src/utils/FolderLevelPermissions.ts b/packages/api-aco/src/utils/FolderLevelPermissions.ts index acdd1516839..13347f8654f 100644 --- a/packages/api-aco/src/utils/FolderLevelPermissions.ts +++ b/packages/api-aco/src/utils/FolderLevelPermissions.ts @@ -45,7 +45,7 @@ interface ListFolderPermissionsParams { export interface FolderLevelPermissionsParams { getIdentity: Authentication["getIdentity"]; - getIdentityTeam: () => Promise; + listIdentityTeams: () => Promise; listPermissions: () => Promise; listAllFolders: (folderType: string) => Promise; canUseTeams: () => boolean; @@ -57,7 +57,7 @@ export class FolderLevelPermissions { canUseFolderLevelPermissions: () => boolean; private readonly getIdentity: Authentication["getIdentity"]; - private readonly getIdentityTeam: () => Promise; + private readonly listIdentityTeams: () => Promise; private readonly listPermissions: () => Promise; private readonly listAllFoldersCallback: (folderType: string) => Promise; private readonly canUseTeams: () => boolean; @@ -67,7 +67,7 @@ export class FolderLevelPermissions { constructor(params: FolderLevelPermissionsParams) { this.getIdentity = params.getIdentity; - this.getIdentityTeam = params.getIdentityTeam; + this.listIdentityTeams = params.listIdentityTeams; this.listPermissions = params.listPermissions; this.listAllFoldersCallback = params.listAllFolders; this.canUseTeams = params.canUseTeams; @@ -152,7 +152,6 @@ export class FolderLevelPermissions { if (!this.canUseFolderLevelPermissions() || !this.isAuthorizationEnabled()) { resolve([]); return; - // return []; } const { folderType, foldersList } = params; @@ -161,9 +160,9 @@ export class FolderLevelPermissions { const identity = this.getIdentity(); const permissions = await this.listPermissions(); - let identityTeam: Team | null; + let identityTeams: Team[]; if (this.canUseTeams()) { - identityTeam = await this.getIdentityTeam(); + identityTeams = await this.listIdentityTeams(); } const processedFolderPermissions: FolderPermissionsListItem[] = []; @@ -285,18 +284,20 @@ export class FolderLevelPermissions { level: "owner", inheritedFrom: "role:full-access" }; - } else if (identityTeam) { - // 2. Check the team user belongs to grants access to the folder. - const teamPermission = currentFolderPermissions.permissions.find( - p => p.target === `team:${identityTeam!.id}` - ); + } else if (identityTeams.length) { + // 2. Check the teams user belongs to and that grant access to the folder. + for (const identityTeam of identityTeams) { + const teamPermission = currentFolderPermissions.permissions.find( + p => p.target === `team:${identityTeam!.id}` + ); - if (teamPermission) { - currentIdentityPermission = { - target: `admin:${identity.id}`, - level: teamPermission.level, - inheritedFrom: "team:" + identityTeam!.id - }; + if (teamPermission) { + currentIdentityPermission = { + target: `admin:${identity.id}`, + level: teamPermission.level, + inheritedFrom: "team:" + identityTeam!.id + }; + } } } diff --git a/packages/api-admin-users/src/createExternalIdpAdminUserHooks.ts b/packages/api-admin-users/src/createExternalIdpAdminUserHooks.ts new file mode 100644 index 00000000000..098a1abb60f --- /dev/null +++ b/packages/api-admin-users/src/createExternalIdpAdminUserHooks.ts @@ -0,0 +1,60 @@ +import { ContextPlugin } from "@webiny/api"; +import { AdminUsersContext } from "~/types"; + +export const createExternalIdpAdminUserHooks = () => { + return new ContextPlugin(async context => { + const { security, adminUsers } = context; + + security.onLogin.subscribe(async ({ identity }) => { + await security.withoutAuthorization(async () => { + const user = await adminUsers.getUser({ where: { id: identity.id } }); + + const id = identity.id; + const email = identity.email || `id:${id}`; + const displayName = identity.displayName || "Missing display name"; + + const data = { + displayName, + email, + groups: [] as string[], + teams: [] as string[] + }; + + let groupSlugs: string[] = []; + if (identity.group) { + groupSlugs = [identity.group]; + } + + if (Array.isArray(identity.groups)) { + groupSlugs = groupSlugs.concat(identity.groups); + } + + let teamSlugs: string[] = []; + if (identity.team) { + teamSlugs = [identity.team]; + } + + if (Array.isArray(identity.teams)) { + teamSlugs = teamSlugs.concat(identity.teams); + } + + if (groupSlugs.length > 0) { + const groups = await security.listGroups({ where: { slug_in: groupSlugs } }); + data.groups = groups.map(group => group.id); + } + + if (teamSlugs.length > 0) { + const teams = await security.listTeams({ where: { slug_in: teamSlugs } }); + data.teams = teams.map(team => team.id); + } + + if (user) { + await adminUsers.updateUser(identity.id, data); + return; + } + + await adminUsers.createUser({ id, ...data }); + }); + }); + }); +}; diff --git a/packages/api-admin-users/src/graphql/user.gql.ts b/packages/api-admin-users/src/graphql/user.gql.ts index a1de549de48..0e6756029e9 100644 --- a/packages/api-admin-users/src/graphql/user.gql.ts +++ b/packages/api-admin-users/src/graphql/user.gql.ts @@ -32,7 +32,7 @@ export default (params: CreateUserGraphQlPluginsParams) => { displayName: String! email: String! - group: SecurityGroup + groups: [SecurityGroup] firstName: String lastName: String avatar: JSON @@ -103,12 +103,12 @@ export default (params: CreateUserGraphQlPluginsParams) => { gravatar(user: AdminUser) { return "https://www.gravatar.com/avatar/" + md5(user.email); }, - group(user, _, context) { - if (!user.group) { + groups(user: AdminUser, _, context) { + if (!user.groups) { return null; } - return context.security.getGroup({ where: { id: user.group } }); + return context.security.listGroups({ where: { id_in: user.groups } }); } }, AdminUsersQuery: { @@ -164,17 +164,18 @@ export default (params: CreateUserGraphQlPluginsParams) => { new GraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` extend type AdminUser { - team: SecurityTeam + teams: [SecurityTeam] } `, resolvers: { AdminUser: { - team(user, _, context) { - if (!user.team) { - return null; + teams(user: AdminUser, _, context) { + const hasTeams = Array.isArray(user.teams) && user.teams.length > 0; + if (!hasTeams) { + return []; } - return context.security.getTeam({ where: { id: user.team } }); + return context.security.listTeams({ where: { id_in: user.teams } }); } } } diff --git a/packages/api-admin-users/src/types.ts b/packages/api-admin-users/src/types.ts index 0cd6d7155a8..602f8236fb6 100644 --- a/packages/api-admin-users/src/types.ts +++ b/packages/api-admin-users/src/types.ts @@ -22,9 +22,10 @@ export interface BaseUserAttributes { // Check `api-security-okta` package for an example. email: string; + groups?: string[]; + teams?: string[]; + // Optional fields. - group?: string | null; - team?: string | null; firstName?: string; lastName?: string; avatar?: Record; @@ -157,7 +158,8 @@ export interface InstallParams { lastName: string; email: string; password: string; - group?: string; + groups?: string[]; + teams?: string[]; } export interface AdminUsers { diff --git a/packages/api-authentication/src/types.ts b/packages/api-authentication/src/types.ts index 153807fdbe8..6ef0a7e7a1a 100644 --- a/packages/api-authentication/src/types.ts +++ b/packages/api-authentication/src/types.ts @@ -29,8 +29,16 @@ export interface Identity { // the group and team information is retrieved from the IdP and the verified auth token. See: // - https://www.webiny.com/docs/enterprise/okta-integration#3-configure-okta-in-the-graph-ql-api // - https://www.webiny.com/docs/enterprise/auth0-integration#3-configure-auth0-in-the-graph-ql-api + + // @deprecated Use `groups` instead. group?: string; + + // @deprecated Use `teams` instead. team?: string; + // Using these properties assigning multiple groups or teams. + groups?: string[]; + teams?: string[]; + [key: string]: any; } diff --git a/packages/api-security-auth0/package.json b/packages/api-security-auth0/package.json index 40dbfcb6c21..ca4cb49e98c 100644 --- a/packages/api-security-auth0/package.json +++ b/packages/api-security-auth0/package.json @@ -10,7 +10,6 @@ "author": "Webiny Ltd.", "license": "Webiny Enterprise", "dependencies": { - "@webiny/api": "0.0.0", "@webiny/api-admin-users": "0.0.0", "@webiny/api-i18n": "0.0.0", "@webiny/api-security": "0.0.0", diff --git a/packages/api-security-auth0/src/createAdminUsersHooks.ts b/packages/api-security-auth0/src/createAdminUsersHooks.ts index 1939755b904..e93d7ec2fda 100644 --- a/packages/api-security-auth0/src/createAdminUsersHooks.ts +++ b/packages/api-security-auth0/src/createAdminUsersHooks.ts @@ -1,49 +1,3 @@ -import { ContextPlugin } from "@webiny/api"; -import { AdminUsersContext } from "@webiny/api-admin-users/types"; +import { createExternalIdpAdminUserHooks } from "@webiny/api-admin-users/createExternalIdpAdminUserHooks"; -export const createAdminUsersHooks = () => { - return new ContextPlugin(async context => { - const { security, adminUsers } = context; - - security.onLogin.subscribe(async ({ identity }) => { - await security.withoutAuthorization(async () => { - const user = await adminUsers.getUser({ where: { id: identity.id } }); - - const id = identity.id; - const email = identity.email || `id:${id}`; - const displayName = identity.displayName || "Missing display name"; - - let groupId: string | null = null; - let teamId: string | null = null; - - if (identity.group) { - const group = await security.getGroup({ where: { slug: identity.group } }); - if (group) { - groupId = group.id; - } - } - - if (identity.team) { - const team = await security.getTeam({ where: { slug: identity.team } }); - if (team) { - teamId = team.id; - } - } - - const data = { - displayName, - email, - group: groupId, - team: teamId - }; - - if (user) { - await adminUsers.updateUser(identity.id, data); - return; - } - - await adminUsers.createUser({ id, ...data }); - }); - }); - }); -}; +export const createAdminUsersHooks = createExternalIdpAdminUserHooks; diff --git a/packages/api-security-auth0/tsconfig.build.json b/packages/api-security-auth0/tsconfig.build.json index 0c9f6db7f03..98f530fc1af 100644 --- a/packages/api-security-auth0/tsconfig.build.json +++ b/packages/api-security-auth0/tsconfig.build.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.build.json", "include": ["src"], "references": [ - { "path": "../api/tsconfig.build.json" }, { "path": "../api-admin-users/tsconfig.build.json" }, { "path": "../api-i18n/tsconfig.build.json" }, { "path": "../api-security/tsconfig.build.json" }, diff --git a/packages/api-security-auth0/tsconfig.json b/packages/api-security-auth0/tsconfig.json index 2511e3847ff..b109b596615 100644 --- a/packages/api-security-auth0/tsconfig.json +++ b/packages/api-security-auth0/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.json", "include": ["src", "__tests__"], "references": [ - { "path": "../api" }, { "path": "../api-admin-users" }, { "path": "../api-i18n" }, { "path": "../api-security" }, @@ -19,8 +18,6 @@ "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"], - "@webiny/api/*": ["../api/src/*"], - "@webiny/api": ["../api/src"], "@webiny/api-admin-users/*": ["../api-admin-users/src/*"], "@webiny/api-admin-users": ["../api-admin-users/src"], "@webiny/api-i18n/*": ["../api-i18n/src/*"], diff --git a/packages/api-security-cognito/__tests__/graphql/users.ts b/packages/api-security-cognito/__tests__/graphql/users.ts index c7121c1ff9f..656628acdc7 100644 --- a/packages/api-security-cognito/__tests__/graphql/users.ts +++ b/packages/api-security-cognito/__tests__/graphql/users.ts @@ -6,7 +6,7 @@ const DATA_FIELD = /* GraphQL */ ` lastName avatar gravatar - group { + groups { id slug name diff --git a/packages/api-security-cognito/__tests__/users.test.ts b/packages/api-security-cognito/__tests__/users.test.ts index bc52044f779..24a24fb1889 100644 --- a/packages/api-security-cognito/__tests__/users.test.ts +++ b/packages/api-security-cognito/__tests__/users.test.ts @@ -25,7 +25,7 @@ describe("Security User CRUD Test", () => { await adminUsers.create({ data: { ...adminData, - group: fullAccessGroup.id + groups: [fullAccessGroup.id] } }); }); @@ -42,7 +42,7 @@ describe("Security User CRUD Test", () => { data: { ...mocks.userA, password: "12345678", - group: fullAccessGroup.id + groups: [fullAccessGroup.id] } }); @@ -56,11 +56,13 @@ describe("Security User CRUD Test", () => { ...mocks.userA, id: expect.any(String), gravatar: createGravatar(mocks.userA.email), - group: { - id: fullAccessGroup.id, - slug: fullAccessGroup.slug, - name: fullAccessGroup.name - } + groups: [ + { + id: fullAccessGroup.id, + slug: fullAccessGroup.slug, + name: fullAccessGroup.name + } + ] }, error: null } @@ -72,7 +74,7 @@ describe("Security User CRUD Test", () => { data: { ...mocks.userB, password: "12345678", - group: fullAccessGroup.id + groups: [fullAccessGroup.id] } }); @@ -84,11 +86,13 @@ describe("Security User CRUD Test", () => { ...mocks.userB, id: expect.any(String), gravatar: createGravatar(mocks.userB.email), - group: { - id: fullAccessGroup.id, - name: fullAccessGroup.name, - slug: fullAccessGroup.slug - } + groups: [ + { + id: fullAccessGroup.id, + name: fullAccessGroup.name, + slug: fullAccessGroup.slug + } + ] }, error: null } @@ -110,9 +114,11 @@ describe("Security User CRUD Test", () => { firstName: "John", lastName: "Doe", email: "admin@webiny.com", - group: { - slug: "full-access" - } + groups: [ + { + slug: "full-access" + } + ] }, userA, userB @@ -195,7 +201,7 @@ describe("Security User CRUD Test", () => { // Update user's group const [updateUserAResponse] = await adminUsers.update({ id: userA.id, - data: { group: anonymousGroup.id } + data: { groups: [anonymousGroup.id] } }); expect(updateUserAResponse).toEqual({ @@ -204,11 +210,13 @@ describe("Security User CRUD Test", () => { updateUser: { data: { ...userA, - group: { - id: anonymousGroup.id, - name: anonymousGroup.name, - slug: anonymousGroup.slug - } + groups: [ + { + id: anonymousGroup.id, + name: anonymousGroup.name, + slug: anonymousGroup.slug + } + ] }, error: null } @@ -226,7 +234,7 @@ describe("Security User CRUD Test", () => { data: { ...mocks.userA, email: "admin@webiny.com", - group: fullAccessGroup.id, + groups: [fullAccessGroup.id], password: "12345678" } }); @@ -265,11 +273,13 @@ describe("Security User CRUD Test", () => { id: expect.any(String), gravatar: createGravatar(adminData.email), avatar: null, - group: { - id: fullAccessGroup.id, - name: fullAccessGroup.name, - slug: fullAccessGroup.slug - } + groups: [ + { + id: fullAccessGroup.id, + name: fullAccessGroup.name, + slug: fullAccessGroup.slug + } + ] }, error: null } diff --git a/packages/api-security-cognito/package.json b/packages/api-security-cognito/package.json index fcbb4583a9a..59426a51ef9 100644 --- a/packages/api-security-cognito/package.json +++ b/packages/api-security-cognito/package.json @@ -20,7 +20,8 @@ "@webiny/api-tenancy": "0.0.0", "@webiny/aws-sdk": "0.0.0", "@webiny/error": "0.0.0", - "@webiny/handler-graphql": "0.0.0" + "@webiny/handler-graphql": "0.0.0", + "deep-equal": "^2.2.3" }, "devDependencies": { "@babel/cli": "^7.23.9", diff --git a/packages/api-security-cognito/src/createAdminUsersHooks.ts b/packages/api-security-cognito/src/createAdminUsersHooks.ts index b4c5971820a..59b05d44172 100644 --- a/packages/api-security-cognito/src/createAdminUsersHooks.ts +++ b/packages/api-security-cognito/src/createAdminUsersHooks.ts @@ -1,6 +1,12 @@ import { ContextPlugin } from "@webiny/api"; import { AdminUsersContext } from "@webiny/api-admin-users/types"; -import { PermissionsTenantLink } from "@webiny/api-security/types"; +import { PermissionsTenantLink, PermissionsTenantLinkTeam } from "@webiny/api-security/types"; + +/** + * Package deep-equal does not have types. + */ +// @ts-expect-error +import deepEqual from "deep-equal"; export const createAdminUsersHooks = () => { return new ContextPlugin(async context => { @@ -21,23 +27,28 @@ export const createAdminUsersHooks = () => { const data: PermissionsTenantLink["data"] = { groups: [], teams: [] }; - if (user.team) { - const team = await security.getTeam({ where: { id: user.team } }); - const teamGroups = await security.listGroups({ where: { id_in: team.groups } }); - data.teams = [ - { + const userTeams = user.teams || []; + if (userTeams.length > 0) { + const teams = await security.listTeams({ where: { id_in: userTeams } }); + for (const team of teams) { + const teamGroups = await security.listGroups({ where: { id_in: team.groups } }); + data.teams.push({ id: team.id, groups: teamGroups.map(group => ({ id: group.id, permissions: group.permissions })) - } - ]; + }); + } } - if (user.group) { - const group = await security.getGroup({ where: { id: user.group } }); - data.groups = [{ id: group.id, permissions: group.permissions }]; + const userGroups = user.groups || []; + + if (userGroups.length > 0) { + const groups = await security.listGroups({ where: { id_in: userGroups } }); + for (const group of groups) { + data.groups.push({ id: group.id, permissions: group.permissions }); + } } await security.createTenantLinks([ @@ -66,45 +77,52 @@ export const createAdminUsersHooks = () => { } // If group/team hasn't changed, we don't need to do anything. - const groupChanged = updatedUser.group !== originalUser.group; - const teamChanged = updatedUser.team !== originalUser.team; - if (!groupChanged && !teamChanged) { + const groupsChanged = !deepEqual(updatedUser.groups, originalUser.groups); + const teamsChanged = !deepEqual(updatedUser.teams, originalUser.teams); + if (!groupsChanged && !teamsChanged) { return; } const data: PermissionsTenantLink["data"] = { groups: [], teams: [] }; - if (updatedUser.team) { + const updatedUserTeams = updatedUser.teams || []; + if (updatedUserTeams.length > 0) { data.teams = await security - .getTeam({ where: { id: updatedUser.team } }) - .then(async team => { - if (!team) { + .listTeams({ where: { id_in: updatedUserTeams } }) + .then(async teams => { + if (!teams.length) { return []; } - const teamGroups = await security.listGroups({ - where: { id_in: team.groups } - }); - - return [ - { + const tenantLinkTeams: PermissionsTenantLinkTeam[] = []; + for (const team of teams) { + const teamGroups = await security.listGroups({ + where: { id_in: team.groups } + }); + tenantLinkTeams.push({ id: team.id, groups: teamGroups.map(group => { return { id: group.id, permissions: group.permissions }; }) - } - ]; + }); + } + + return tenantLinkTeams; }); } - if (updatedUser.group) { + const updatedUserGroups = updatedUser.groups || []; + if (updatedUserGroups.length > 0) { data.groups = await security - .getGroup({ where: { id: updatedUser.group } }) - .then(group => { - if (!group) { + .listGroups({ where: { id_in: updatedUserGroups } }) + .then(groups => { + if (!groups.length) { return []; } - return [{ id: group.id, permissions: group.permissions }]; + + return groups.map(group => { + return { id: group.id, permissions: group.permissions }; + }); }); } @@ -141,7 +159,7 @@ export const createAdminUsersHooks = () => { // Before install, load `full-access` group and assign it to the new user. adminUsers.onSystemBeforeInstall.subscribe(async ({ user }) => { const group = await security.getGroup({ where: { slug: "full-access" } }); - user.group = group.id; + user.groups = [group.id]; }); adminUsers.onInstall.subscribe(async ({ user }) => { diff --git a/packages/api-security-cognito/src/graphql/user.gql.ts b/packages/api-security-cognito/src/graphql/user.gql.ts index b08741b0e7a..cb0fc6848ab 100644 --- a/packages/api-security-cognito/src/graphql/user.gql.ts +++ b/packages/api-security-cognito/src/graphql/user.gql.ts @@ -19,7 +19,7 @@ export default (params: CreateUserGraphQlPluginsParams) => { lastName: String! password: String! avatar: JSON - ${params.teams ? "group: String" : "group: String!"} + ${params.teams ? "groups: [RefInput]" : "groups: [RefInput!]"} } """ @@ -31,7 +31,7 @@ export default (params: CreateUserGraphQlPluginsParams) => { lastName: String password: String avatar: JSON - group: String + groups: [RefInput] } """ @@ -119,11 +119,11 @@ export default (params: CreateUserGraphQlPluginsParams) => { new GraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` extend input AdminUsersCreateInput { - team: ID + teams: [RefInput] } extend input AdminUsersUpdateInput { - team: ID + teams: [RefInput] } ` }) diff --git a/packages/api-security-okta/src/createAdminUsersHooks.ts b/packages/api-security-okta/src/createAdminUsersHooks.ts index 1939755b904..e93d7ec2fda 100644 --- a/packages/api-security-okta/src/createAdminUsersHooks.ts +++ b/packages/api-security-okta/src/createAdminUsersHooks.ts @@ -1,49 +1,3 @@ -import { ContextPlugin } from "@webiny/api"; -import { AdminUsersContext } from "@webiny/api-admin-users/types"; +import { createExternalIdpAdminUserHooks } from "@webiny/api-admin-users/createExternalIdpAdminUserHooks"; -export const createAdminUsersHooks = () => { - return new ContextPlugin(async context => { - const { security, adminUsers } = context; - - security.onLogin.subscribe(async ({ identity }) => { - await security.withoutAuthorization(async () => { - const user = await adminUsers.getUser({ where: { id: identity.id } }); - - const id = identity.id; - const email = identity.email || `id:${id}`; - const displayName = identity.displayName || "Missing display name"; - - let groupId: string | null = null; - let teamId: string | null = null; - - if (identity.group) { - const group = await security.getGroup({ where: { slug: identity.group } }); - if (group) { - groupId = group.id; - } - } - - if (identity.team) { - const team = await security.getTeam({ where: { slug: identity.team } }); - if (team) { - teamId = team.id; - } - } - - const data = { - displayName, - email, - group: groupId, - team: teamId - }; - - if (user) { - await adminUsers.updateUser(identity.id, data); - return; - } - - await adminUsers.createUser({ id, ...data }); - }); - }); - }); -}; +export const createAdminUsersHooks = createExternalIdpAdminUserHooks; diff --git a/packages/api-security-so-ddb/src/index.ts b/packages/api-security-so-ddb/src/index.ts index 670d4868093..2796b745228 100644 --- a/packages/api-security-so-ddb/src/index.ts +++ b/packages/api-security-so-ddb/src/index.ts @@ -402,7 +402,7 @@ export const createStorageOperations = ( .map(item => cleanupItem(entities.apiKeys, item)) .filter(Boolean) as ApiKey[]; }, - async listGroups({ where: { tenant, id_in }, sort }): Promise { + async listGroups({ where: { tenant, id_in, slug_in }, sort }): Promise { let items: Group[]; try { items = await queryAll({ @@ -428,12 +428,17 @@ export const createStorageOperations = ( }) ); - if (!Array.isArray(id_in)) { - return items; + if (Array.isArray(id_in)) { + return items.filter(item => id_in.includes(item.id)); } - return items.filter(item => id_in.includes(item.id)); + + if (Array.isArray(slug_in)) { + return items.filter(item => slug_in.includes(item.slug)); + } + + return items; }, - async listTeams({ where: { tenant }, sort }): Promise { + async listTeams({ where: { tenant, id_in, slug_in }, sort }): Promise { let items: Team[]; try { items = await queryAll({ @@ -450,7 +455,7 @@ export const createStorageOperations = ( }); } - return cleanupItems( + items = cleanupItems( entities.teams, sortItems({ items, @@ -458,6 +463,15 @@ export const createStorageOperations = ( fields: [] }) ); + + if (Array.isArray(id_in)) { + return items.filter(item => id_in.includes(item.id)); + } + + if (Array.isArray(slug_in)) { + return items.filter(item => slug_in.includes(item.id)); + } + return items; }, async listTenantLinksByIdentity({ identity }): Promise { return await queryAllClean({ diff --git a/packages/api-security/__tests__/wcp/aacl/mocks/mockCreateGetWcpProjectLicense.ts b/packages/api-security/__tests__/wcp/aacl/mocks/mockCreateGetWcpProjectLicense.ts index 9a576f58ab3..7c2c66264b5 100644 --- a/packages/api-security/__tests__/wcp/aacl/mocks/mockCreateGetWcpProjectLicense.ts +++ b/packages/api-security/__tests__/wcp/aacl/mocks/mockCreateGetWcpProjectLicense.ts @@ -19,7 +19,14 @@ export const mockCreateGetWcpProjectLicense = ( options: { maxCount: { type: MT_OPTIONS_MAX_COUNT_TYPE.SEAT_BASED } } }, advancedPublishingWorkflow: { enabled: false }, - advancedAccessControlLayer: { enabled: false } + advancedAccessControlLayer: { + enabled: false, + options: { + teams: false, + folderLevelPermissions: false, + privateFiles: false + } + } } } }; diff --git a/packages/api-security/src/createSecurity/createGroupsMethods.ts b/packages/api-security/src/createSecurity/createGroupsMethods.ts index 3a0dd8e31a3..4151c2c8cc2 100644 --- a/packages/api-security/src/createSecurity/createGroupsMethods.ts +++ b/packages/api-security/src/createSecurity/createGroupsMethods.ts @@ -202,8 +202,8 @@ export const createGroupsMethods = ({ }); } catch (ex) { throw new WebinyError( - ex.message || "Could not list API keys.", - ex.code || "LIST_API_KEY_ERROR" + ex.message || "Could not list groups.", + ex.code || "LIST_GROUPS_ERROR" ); } }, diff --git a/packages/api-security/src/createSecurity/createTeamsMethods.ts b/packages/api-security/src/createSecurity/createTeamsMethods.ts index b9e620462e0..a82b2fc2d94 100644 --- a/packages/api-security/src/createSecurity/createTeamsMethods.ts +++ b/packages/api-security/src/createSecurity/createTeamsMethods.ts @@ -14,7 +14,14 @@ import { createTopic } from "@webiny/pubsub"; import { validation } from "@webiny/validation"; import WebinyError from "@webiny/error"; import { NotFoundError } from "@webiny/handler-graphql"; -import { GetTeamParams, Team, TeamInput, PermissionsTenantLink, Security } from "~/types"; +import { + GetTeamParams, + Team, + TeamInput, + PermissionsTenantLink, + Security, + ListGroupsParams +} from "~/types"; import NotAuthorizedError from "../NotAuthorizedError"; import { SecurityConfig } from "~/types"; @@ -146,11 +153,12 @@ export const createTeamsMethods = ({ return team; }, - async listTeams(this: Security) { + async listTeams(this: Security, { where }: ListGroupsParams = {}) { await checkPermission(this); try { return await storageOperations.listTeams({ where: { + ...where, tenant: getTenant() }, sort: ["createdOn_ASC"] diff --git a/packages/api-security/src/index.ts b/packages/api-security/src/index.ts index 42cfd7b7a84..dd25368f09d 100644 --- a/packages/api-security/src/index.ts +++ b/packages/api-security/src/index.ts @@ -70,12 +70,7 @@ export const createSecurityContext = ({ storageOperations }: SecurityConfig) => export const createSecurityGraphQL = (config: MultiTenancyGraphQLConfig = {}) => { return new ContextPlugin(context => { - const license = context.wcp.getProjectLicense(); - context.plugins.register( - graphqlPlugins({ - teams: license?.package?.features?.advancedAccessControlLayer?.options?.teams - }) - ); + context.plugins.register(graphqlPlugins({ teams: context.wcp.canUseTeams() })); if (context.tenancy.isMultiTenant()) { applyMultiTenancyGraphQLPlugins(config, context); diff --git a/packages/api-security/src/types.ts b/packages/api-security/src/types.ts index 89ac722d76e..8ff40c9817d 100644 --- a/packages/api-security/src/types.ts +++ b/packages/api-security/src/types.ts @@ -96,6 +96,7 @@ export interface Security extends Authentication(cb: () => Promise): Promise; + withIdentity(identity: Identity | undefined, cb: () => Promise): Promise; addAuthorizer(authorizer: Authorizer): void; @@ -302,6 +303,7 @@ export interface GetGroupParams { export interface ListGroupsParams { where?: { id_in?: string[]; + slug_in?: string[]; }; sort?: string[]; } @@ -347,6 +349,7 @@ export interface GetTeamParams { export interface ListTeamsParams { where?: { id_in?: string[]; + slug_in?: string[]; }; sort?: string[]; } @@ -433,9 +436,19 @@ export interface TenantLink { webinyVersion: string; } -export type PermissionsTenantLink = TenantLink<{ +export interface PermissionsTenantLinkGroup { + id: string; + permissions: SecurityPermission[]; +} + +export interface PermissionsTenantLinkTeam { + id: string; groups: Array<{ id: string; permissions: SecurityPermission[] }>; - teams: Array<{ id: string; groups: Array<{ id: string; permissions: SecurityPermission[] }> }>; +} + +export type PermissionsTenantLink = TenantLink<{ + groups: PermissionsTenantLinkGroup[]; + teams: PermissionsTenantLinkTeam[]; }>; export interface ApiKey { diff --git a/packages/app-admin-users-cognito/src/ui/elements/GroupsMultiAutocompleteElement.tsx b/packages/app-admin-users-cognito/src/ui/elements/GroupsMultiAutocompleteElement.tsx new file mode 100644 index 00000000000..38dcdda96bb --- /dev/null +++ b/packages/app-admin-users-cognito/src/ui/elements/GroupsMultiAutocompleteElement.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { FormRenderPropParams } from "@webiny/form"; +import { + InputElement, + InputElementRenderProps +} from "@webiny/app-admin/ui/elements/form/InputElement"; +import { GroupsMultiAutocomplete } from "@webiny/app-security-access-management/components/GroupsMultiAutocomplete"; + +export class GroupsMultiAutocompleteElement extends InputElement { + public override render( + this: GroupsMultiAutocompleteElement, + { formProps }: InputElementRenderProps + ): React.ReactElement { + const { Bind } = formProps as FormRenderPropParams; + + const validators = this.config.validators; + /** + * TODO @ts-refactor @bruno + * Figure out what can validators be. + */ + if (validators && typeof validators !== "function") { + console.log( + "packages/app-admin-users-cognito/src/ui/elements/GroupsMultiAutocompleteElement.tsx validators is set but not a function." + ); + console.log(validators); + } + return ( + + + + ); + } +} diff --git a/packages/app-admin-users-cognito/src/ui/elements/TeamsMultiAutocompleteElement.tsx b/packages/app-admin-users-cognito/src/ui/elements/TeamsMultiAutocompleteElement.tsx new file mode 100644 index 00000000000..99f0cdeffac --- /dev/null +++ b/packages/app-admin-users-cognito/src/ui/elements/TeamsMultiAutocompleteElement.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { FormRenderPropParams } from "@webiny/form"; +import { + InputElement, + InputElementRenderProps +} from "@webiny/app-admin/ui/elements/form/InputElement"; +import { TeamsMultiAutocomplete } from "@webiny/app-security-access-management/components/TeamsMultiAutocomplete"; + +export class TeamsMultiAutocompleteElement extends InputElement { + public override render( + this: TeamsMultiAutocompleteElement, + { formProps }: InputElementRenderProps + ): React.ReactElement { + const { Bind } = formProps as FormRenderPropParams; + + const validators = this.config.validators; + /** + * TODO @ts-refactor @bruno + * Figure out what can validators be. + */ + if (validators && typeof validators !== "function") { + console.log( + "packages/app-admin-users-cognito/src/ui/elements/TeamsMultiAutocompleteElement.tsx validators is set but not a function." + ); + console.log(validators); + } + return ( + + + + ); + } +} diff --git a/packages/app-admin-users-cognito/src/ui/views/Users/UsersFormView.tsx b/packages/app-admin-users-cognito/src/ui/views/Users/UsersFormView.tsx index 70cfaf3eecd..27e6e5f00f6 100644 --- a/packages/app-admin-users-cognito/src/ui/views/Users/UsersFormView.tsx +++ b/packages/app-admin-users-cognito/src/ui/views/Users/UsersFormView.tsx @@ -13,8 +13,8 @@ import { ReactComponent as SecurityIcon } from "~/assets/icons/security-24px.svg import { ReactComponent as SecurityTeamsIcon } from "~/assets/icons/security-teams-24px.svg"; import { ReactComponent as SettingsIcon } from "~/assets/icons/settings-24px.svg"; import AvatarImage from "../../components/AvatarImage"; -import { GroupAutocompleteElement } from "~/ui/elements/GroupAutocompleteElement"; -import { TeamAutocompleteElement } from "~/ui/elements/TeamAutocompleteElement"; +import { GroupsMultiAutocompleteElement } from "~/ui/elements/GroupsMultiAutocompleteElement"; +import { TeamsMultiAutocompleteElement } from "~/ui/elements/TeamsMultiAutocompleteElement"; import { UseUserForm, useUserForm } from "~/ui/views/Users/hooks/useUserForm"; import { FormView } from "@webiny/app-admin/ui/views/FormView"; import { FormElementRenderProps } from "@webiny/app-admin/ui/elements/form/FormElement"; @@ -111,7 +111,7 @@ export class UsersFormView extends UIView { { id: "groups", title: "Roles", - description: "Assign to security role", + description: "Assign to security roles", icon: , open: true } @@ -121,7 +121,7 @@ export class UsersFormView extends UIView { items.push({ id: "teams", title: "Teams", - description: "Assign to team", + description: "Assign to teams", icon: , open: true }); @@ -178,13 +178,13 @@ export class UsersFormView extends UIView { }) ); - const groupAccordion = accordion.getElement("groups"); + const groupsAccordion = accordion.getElement("groups"); - if (groupAccordion) { - groupAccordion.addElement( - new GroupAutocompleteElement("group", { - name: "group", - label: "Role", + if (groupsAccordion) { + groupsAccordion.addElement( + new GroupsMultiAutocompleteElement("groups", { + name: "groups", + label: "Roles", validators: () => { const validators = []; if (!this.teams) { @@ -200,9 +200,9 @@ export class UsersFormView extends UIView { if (teamAccordion) { teamAccordion.addElement( - new TeamAutocompleteElement("team", { - name: "team", - label: "Team" + new TeamsMultiAutocompleteElement("teams", { + name: "teams", + label: "Teams" }) ); } diff --git a/packages/app-admin-users-cognito/src/ui/views/Users/graphql.ts b/packages/app-admin-users-cognito/src/ui/views/Users/graphql.ts index b0c11d77ea7..37ae94c1158 100644 --- a/packages/app-admin-users-cognito/src/ui/views/Users/graphql.ts +++ b/packages/app-admin-users-cognito/src/ui/views/Users/graphql.ts @@ -20,7 +20,7 @@ const userFormFields = (params: { teams: boolean }) => { firstName lastName avatar - group { + groups { id slug name @@ -32,7 +32,7 @@ const userFormFields = (params: { teams: boolean }) => { return gql.replace( "TEAM", params.teams - ? `team { + ? `teams { id slug name diff --git a/packages/app-security-access-management/src/components/GroupsMultiAutocomplete/index.tsx b/packages/app-security-access-management/src/components/GroupsMultiAutocomplete/index.tsx index 18d71372135..8369d8ecccb 100644 --- a/packages/app-security-access-management/src/components/GroupsMultiAutocomplete/index.tsx +++ b/packages/app-security-access-management/src/components/GroupsMultiAutocomplete/index.tsx @@ -5,7 +5,7 @@ import { useQuery } from "@apollo/react-hooks"; type GroupsMultiAutocompleteProps = Partial; -export const GroupsMultiAutoComplete = (props: GroupsMultiAutocompleteProps) => { +export const GroupsMultiAutocomplete = (props: GroupsMultiAutocompleteProps) => { const { data, loading } = useQuery(LIST_GROUPS); const options = loading || !data ? [] : data.security.groups.data; diff --git a/packages/app-security-access-management/src/components/TeamsMultiAutocomplete/graphql.ts b/packages/app-security-access-management/src/components/TeamsMultiAutocomplete/graphql.ts new file mode 100644 index 00000000000..9e8e91c32df --- /dev/null +++ b/packages/app-security-access-management/src/components/TeamsMultiAutocomplete/graphql.ts @@ -0,0 +1,22 @@ +import gql from "graphql-tag"; + +export const LIST_TEAMS = gql` + query listTeams { + security { + teams: listTeams { + data { + id + slug + name + description + createdOn + } + error { + data + message + code + } + } + } + } +`; diff --git a/packages/app-security-access-management/src/components/TeamsMultiAutocomplete/index.tsx b/packages/app-security-access-management/src/components/TeamsMultiAutocomplete/index.tsx new file mode 100644 index 00000000000..1c2c9dbaa50 --- /dev/null +++ b/packages/app-security-access-management/src/components/TeamsMultiAutocomplete/index.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { MultiAutoComplete, MultiAutoCompleteProps } from "@webiny/ui/AutoComplete"; +import { LIST_TEAMS } from "./graphql"; +import { useQuery } from "@apollo/react-hooks"; + +type TeamsMultiAutocompleteProps = Partial; + +export const TeamsMultiAutocomplete = (props: TeamsMultiAutocompleteProps) => { + const { data, loading } = useQuery(LIST_TEAMS); + + const options = loading || !data ? [] : data.security.teams.data; + + return ( + + ); +}; diff --git a/packages/app-security-access-management/src/ui/elements/GroupsMultiAutocompleteElement.tsx b/packages/app-security-access-management/src/ui/elements/GroupsMultiAutocompleteElement.tsx index d757cd86c38..3222fd6af00 100644 --- a/packages/app-security-access-management/src/ui/elements/GroupsMultiAutocompleteElement.tsx +++ b/packages/app-security-access-management/src/ui/elements/GroupsMultiAutocompleteElement.tsx @@ -1,7 +1,7 @@ import React from "react"; import { FormRenderPropParams } from "@webiny/form"; import { InputElement } from "@webiny/app-admin/ui/elements/form/InputElement"; -import { GroupsMultiAutoComplete } from "~/components/GroupsMultiAutocomplete"; +import { GroupsMultiAutocomplete } from "~/components/GroupsMultiAutocomplete"; import { FormFieldElementRenderProps } from "@webiny/app-admin/ui/elements/form/FormFieldElement"; export class GroupsAutocompleteElement extends InputElement { @@ -24,7 +24,7 @@ export class GroupsAutocompleteElement extends InputElement { name={this.id} validators={typeof validators === "function" ? validators({ formProps }) : []} > - + ); } diff --git a/packages/app-security-access-management/src/ui/views/Teams/TeamsForm.tsx b/packages/app-security-access-management/src/ui/views/Teams/TeamsForm.tsx index 1fd4530cb2c..86f08e26f87 100644 --- a/packages/app-security-access-management/src/ui/views/Teams/TeamsForm.tsx +++ b/packages/app-security-access-management/src/ui/views/Teams/TeamsForm.tsx @@ -22,7 +22,7 @@ import { CREATE_TEAM, LIST_TEAMS, READ_TEAM, UPDATE_TEAM } from "./graphql"; import isEmpty from "lodash/isEmpty"; import EmptyView from "@webiny/app-admin/components/EmptyView"; import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18px.svg"; -import { GroupsMultiAutoComplete } from "~/components/GroupsMultiAutocomplete"; +import { GroupsMultiAutocomplete } from "~/components/GroupsMultiAutocomplete"; import { Team } from "~/types"; const t = i18n.ns("app-security/admin/teams/form"); @@ -175,7 +175,7 @@ export const TeamsForm = () => { - diff --git a/packages/cli-plugin-deploy-pulumi/package.json b/packages/cli-plugin-deploy-pulumi/package.json index 5a86b6215d5..abd5605da53 100644 --- a/packages/cli-plugin-deploy-pulumi/package.json +++ b/packages/cli-plugin-deploy-pulumi/package.json @@ -50,12 +50,7 @@ "commands/newWatch/handler" ], "ignore": { - "src": [ - "vm", - "inspector" - ], "dependencies": [ - "execa", "exit-hook" ] } diff --git a/packages/migrations/__tests__/migrations/5.41.0/001/001.data.ts b/packages/migrations/__tests__/migrations/5.41.0/001/001.data.ts new file mode 100644 index 00000000000..1c99b1ff1ba --- /dev/null +++ b/packages/migrations/__tests__/migrations/5.41.0/001/001.data.ts @@ -0,0 +1,93 @@ +export const testData = [ + { + PK: "T#root", + SK: "A", + createdOn: "2023-01-25T09:37:58.183Z", + description: "The top-level Webiny tenant.", + GSI1_PK: "TENANTS", + GSI1_SK: "T#null#2023-01-25T09:37:58.183Z", + id: "root", + name: "Root", + savedOn: "2023-01-25T09:37:58.183Z", + settings: { + domains: [] + }, + status: "active", + TYPE: "tenancy.tenant", + webinyVersion: "0.0.0", + _ct: "2023-01-25T09:37:58.220Z", + _et: "TenancyTenant", + _md: "2023-01-25T09:37:58.220Z" + }, + { + PK: "T#root#ADMIN_USER#e6ea2871-ba36-4494-87ac-afb73d4e7eb2", + SK: "A", + GSI1_PK: "T#root#ADMIN_USERS", + GSI1_SK: "admin@webiny.com", + id: "e6ea2871-ba36-4494-87ac-afb73d4e7eb2", + TYPE: "adminUsers.user", + _ct: "2023-01-25T09:38:16.764Z", + _et: "AdminUsers.User", + _md: "2023-01-25T09:38:16.764Z", + data: { + createdOn: "2023-01-25T09:38:16.226Z", + email: "admin@webiny.com", + firstName: "Pavel", + group: "63d0f879ce8f180008bb6051", + lastName: "Denisjuk", + tenant: "root", + webinyVersion: "0.0.0" + } + }, + { + PK: "T#root#ADMIN_USER#4e1adbe9-abf4-4360-b7f4-3f00bce096d9", + SK: "A", + GSI1_PK: "T#root#ADMIN_USERS", + GSI1_SK: "user1@webiny.com", + id: "4e1adbe9-abf4-4360-b7f4-3f00bce096d9", + TYPE: "adminUsers.user", + _ct: "2023-03-10T08:44:26.330Z", + _et: "AdminUsers.User", + _md: "2023-03-10T08:44:26.331Z", + data: { + createdBy: { + displayName: "Pavel Denisjuk", + id: "e6ea2871-ba36-4494-87ac-afb73d4e7eb2", + type: "admin" + }, + createdOn: "2023-03-10T08:44:24.401Z", + email: "user1@webiny.com", + firstName: "User 1", + group: "63d0f879ce8f180008bb6051", + lastName: "Last 1", + tenant: "root", + webinyVersion: "5.35.0-dev" + } + }, + { + PK: "T#root#ADMIN_USER#640af04d40bae30008a097c5", + SK: "A", + GSI1_PK: "T#root#ADMIN_USERS", + GSI1_SK: "user2@webiny.com", + id: "640af04d40bae30008a097c5", + TYPE: "adminUsers.user", + _ct: "2023-03-10T09:03:27.732Z", + _et: "AdminUsers.User", + _md: "2023-03-10T09:03:27.733Z", + data: { + createdBy: { + displayName: "Pavel Denisjuk", + id: "e6ea2871-ba36-4494-87ac-afb73d4e7eb2", + type: "admin" + }, + createdOn: "2023-03-10T08:54:37.618Z", + email: "user2@webiny.com", + firstName: "Modern", + group: "63d0f879ce8f180008bb6051", + team: "random-team-id", + lastName: "User", + tenant: "root", + webinyVersion: "5.35.0-dev" + } + } +]; diff --git a/packages/migrations/__tests__/migrations/5.41.0/001/001.test.ts b/packages/migrations/__tests__/migrations/5.41.0/001/001.test.ts new file mode 100644 index 00000000000..4178a0acaac --- /dev/null +++ b/packages/migrations/__tests__/migrations/5.41.0/001/001.test.ts @@ -0,0 +1,83 @@ +import { AdminUsers_5_41_0_001 } from "~/migrations/5.41.0/001"; +import { + assertNotError, + createDdbMigrationHandler, + getPrimaryDynamoDbTable, + groupMigrations, + insertDynamoDbTestData, + logTestNameBeforeEachTest, + scanTable +} from "~tests/utils"; +import { testData } from "./001.data"; + +jest.retryTimes(0); + +describe("5.41.0-001", () => { + const table = getPrimaryDynamoDbTable(); + + logTestNameBeforeEachTest(); + + it("should not run if system is not installed", async () => { + const handler = createDdbMigrationHandler({ table, migrations: [AdminUsers_5_41_0_001] }); + + const { data, error } = await handler(); + + assertNotError(error); + const grouped = groupMigrations(data.migrations); + + expect(grouped.executed.length).toBe(0); + expect(grouped.skipped.length).toBe(1); + expect(grouped.notApplicable.length).toBe(0); + }); + + it("should execute migration", async () => { + await insertDynamoDbTestData(table, testData); + const handler = createDdbMigrationHandler({ table, migrations: [AdminUsers_5_41_0_001] }); + const { data, error } = await handler(); + + assertNotError(error); + const grouped = groupMigrations(data.migrations); + + expect(grouped.executed.length).toBe(1); + expect(grouped.skipped.length).toBe(0); + expect(grouped.notApplicable.length).toBe(0); + + const allUsers = await scanTable(table, { + index: "GSI1", + filters: [{ attr: "GSI1_PK", eq: "T#root#ADMIN_USERS" }] + }); + + expect(allUsers.length).toEqual(3); + expect(allUsers[0].data.groups).toEqual([allUsers[0].data.group]); + expect(allUsers[0].data.teams).toEqual([]); + expect(allUsers[1].data.groups).toEqual([allUsers[1].data.group]); + expect(allUsers[1].data.teams).toEqual([]); + expect(allUsers[2].data.groups).toEqual([allUsers[2].data.group]); + expect(allUsers[2].data.teams).toEqual(["random-team-id"]); + }); + + it("should not run migration if data is already in the expected shape", async () => { + await insertDynamoDbTestData(table, testData); + const handler = createDdbMigrationHandler({ table, migrations: [AdminUsers_5_41_0_001] }); + + // Should run the migration + { + process.stdout.write("[First run]\n"); + const { data, error } = await handler(); + assertNotError(error); + const grouped = groupMigrations(data.migrations); + expect(grouped.executed.length).toBe(1); + } + + // Should skip the migration + { + process.stdout.write("[Second run]\n"); + const { data, error } = await handler(); + assertNotError(error); + const grouped = groupMigrations(data.migrations); + expect(grouped.executed.length).toBe(0); + expect(grouped.skipped.length).toBe(1); + expect(grouped.notApplicable.length).toBe(0); + } + }); +}); diff --git a/packages/migrations/src/ddb-es.ts b/packages/migrations/src/ddb-es.ts index 137fb090eb7..508e0be1d07 100644 --- a/packages/migrations/src/ddb-es.ts +++ b/packages/migrations/src/ddb-es.ts @@ -1,79 +1,8 @@ -// 5.35.0 -import { FileManager_5_35_0_001 } from "./migrations/5.35.0/001/ddb-es"; -import { PageBuilder_5_35_0_002 } from "~/migrations/5.35.0/002"; -import { AdminUsers_5_35_0_003 } from "~/migrations/5.35.0/003"; -import { Tenancy_5_35_0_004 } from "~/migrations/5.35.0/004"; -import { CmsModels_5_35_0_005 } from "~/migrations/5.35.0/005"; -import { AcoRecords_5_35_0_006 } from "~/migrations/5.35.0/006/ddb-es"; - -// 5.36.0 -import { AcoRecords_5_36_0_001 } from "~/migrations/5.36.0/001/ddb-es"; - -// 5.37.0 -import { TenantLinkRecords_5_37_0_001 } from "~/migrations/5.37.0/001"; -import { CmsEntriesRootFolder_5_37_0_002 } from "~/migrations/5.37.0/002/ddb-es"; -import { AcoFolders_5_37_0_003 } from "~/migrations/5.37.0/003/ddb-es"; -import { AcoRecords_5_37_0_004 } from "~/migrations/5.37.0/004/ddb-es"; -import { FileManager_5_37_0_005 } from "~/migrations/5.37.0/005/ddb-es"; - -// 5.38.0 -import { MultiStepForms_5_38_0_001 } from "~/migrations/5.38.0/001/ddb-es"; -import { MultiStepForms_5_38_0_002 } from "~/migrations/5.38.0/002/ddb-es"; -// Page Blocks storage is the same for both DDB abd DDB-ES projects. -import { PageBlocks_5_38_0_003 } from "~/migrations/5.38.0/003/ddb"; - -// 5.39.0 -// Because of the 5.39.6-001 migration, this one is no longer needed. -// import { CmsEntriesInitNewMetaFields_5_39_0_001 } from "~/migrations/5.39.0/001/ddb-es"; -import { FileManager_5_39_0_002 } from "~/migrations/5.39.0/002/ddb-es"; - -// 5.39.2 -// Because of the 5.39.6-001 migration, this one is no longer needed. -// import { CmsEntriesInitNewMetaFields_5_39_2_001 } from "~/migrations/5.39.2/001/ddb-es"; - -// 5.39.6 -import { CmsEntriesInitNewMetaFields_5_39_6_001 } from "~/migrations/5.39.6/001/ddb-es"; - -import { PbUniqueBlockElementIds_5_40_0_001 } from "~/migrations/5.40.0/001/ddb"; +// We only list current version migrations here. No need to have them all +// listed, as we only need the latest version migrations to be executed. +// This also helps with keeping the bundle size down / faster boot times. +import { AdminUsers_5_41_0_001 } from "~/migrations/5.41.0/001"; export const migrations = () => { - return [ - // 5.35.0 - FileManager_5_35_0_001, - PageBuilder_5_35_0_002, - AdminUsers_5_35_0_003, - Tenancy_5_35_0_004, - CmsModels_5_35_0_005, - AcoRecords_5_35_0_006, - - // 5.36.0 - AcoRecords_5_36_0_001, - - // 5.37.0 - TenantLinkRecords_5_37_0_001, - CmsEntriesRootFolder_5_37_0_002, - AcoFolders_5_37_0_003, - AcoRecords_5_37_0_004, - FileManager_5_37_0_005, - - // 5.38.0 - MultiStepForms_5_38_0_001, - MultiStepForms_5_38_0_002, - PageBlocks_5_38_0_003, - - // 5.39.0 - // Because of the 5.39.6-001 migration, this one is no longer needed. - // CmsEntriesInitNewMetaFields_5_39_0_001, - FileManager_5_39_0_002, - - // 5.39.2 - // Because of the 5.39.6-001 migration, this one is no longer needed. - // CmsEntriesInitNewMetaFields_5_39_2_001 - - // 5.39.6 - CmsEntriesInitNewMetaFields_5_39_6_001, - - // 5.40.0 - PbUniqueBlockElementIds_5_40_0_001 - ]; + return [AdminUsers_5_41_0_001]; }; diff --git a/packages/migrations/src/ddb.ts b/packages/migrations/src/ddb.ts index e2f29ce0e25..508e0be1d07 100644 --- a/packages/migrations/src/ddb.ts +++ b/packages/migrations/src/ddb.ts @@ -1,53 +1,8 @@ -// 5.35.0 -import { FileManager_5_35_0_001 } from "./migrations/5.35.0/001/ddb"; -import { PageBuilder_5_35_0_002 } from "~/migrations/5.35.0/002"; -import { AdminUsers_5_35_0_003 } from "~/migrations/5.35.0/003"; -import { Tenancy_5_35_0_004 } from "~/migrations/5.35.0/004"; -import { CmsModels_5_35_0_005 } from "~/migrations/5.35.0/005"; -import { AcoRecords_5_35_0_006 } from "~/migrations/5.35.0/006/ddb"; -// 5.36.0 -import { AcoRecords_5_36_0_001 } from "~/migrations/5.36.0/001/ddb"; -// 5.37.0 -import { TenantLinkRecords_5_37_0_001 } from "~/migrations/5.37.0/001"; -import { CmsEntriesRootFolder_5_37_0_002 } from "~/migrations/5.37.0/002/ddb"; -import { AcoFolders_5_37_0_003 } from "~/migrations/5.37.0/003/ddb"; -import { AcoRecords_5_37_0_004 } from "~/migrations/5.37.0/004/ddb"; -import { FileManager_5_37_0_005 } from "~/migrations/5.37.0/005/ddb"; -// 5.38.0 -import { MultiStepForms_5_38_0_001 } from "~/migrations/5.38.0/001/ddb"; -import { MultiStepForms_5_38_0_002 } from "~/migrations/5.38.0/002/ddb"; -import { PageBlocks_5_38_0_003 } from "~/migrations/5.38.0/003/ddb"; -// 5.39.0 -import { CmsEntriesInitNewMetaFields_5_39_0_001 } from "~/migrations/5.39.0/001/ddb"; -import { FileManager_5_39_0_002 } from "~/migrations/5.39.0/002/ddb"; -// Page Blocks storage is the same for both DDB abd DDB-ES projects. -import { PbUniqueBlockElementIds_5_40_0_001 } from "~/migrations/5.40.0/001/ddb"; +// We only list current version migrations here. No need to have them all +// listed, as we only need the latest version migrations to be executed. +// This also helps with keeping the bundle size down / faster boot times. +import { AdminUsers_5_41_0_001 } from "~/migrations/5.41.0/001"; export const migrations = () => { - return [ - // 5.35.0 - FileManager_5_35_0_001, - PageBuilder_5_35_0_002, - AdminUsers_5_35_0_003, - Tenancy_5_35_0_004, - CmsModels_5_35_0_005, - AcoRecords_5_35_0_006, - // 5.36.0 - AcoRecords_5_36_0_001, - // 5.37.0 - TenantLinkRecords_5_37_0_001, - CmsEntriesRootFolder_5_37_0_002, - AcoFolders_5_37_0_003, - AcoRecords_5_37_0_004, - FileManager_5_37_0_005, - // 5.38.0 - MultiStepForms_5_38_0_001, - MultiStepForms_5_38_0_002, - PageBlocks_5_38_0_003, - // 5.39.0 - CmsEntriesInitNewMetaFields_5_39_0_001, - FileManager_5_39_0_002, - // 5.40.0 - PbUniqueBlockElementIds_5_40_0_001 - ]; + return [AdminUsers_5_41_0_001]; }; diff --git a/packages/migrations/src/migrations/5.41.0/001/createTenantEntity.ts b/packages/migrations/src/migrations/5.41.0/001/createTenantEntity.ts new file mode 100644 index 00000000000..92d093f5100 --- /dev/null +++ b/packages/migrations/src/migrations/5.41.0/001/createTenantEntity.ts @@ -0,0 +1,39 @@ +import { Table } from "@webiny/db-dynamodb/toolbox"; +import { createLegacyEntity } from "~/utils"; + +export const createTenantEntity = (table: Table) => { + return createLegacyEntity(table, "TenancyTenant", { + id: { + type: "string" + }, + name: { + type: "string" + }, + description: { + type: "string" + }, + status: { + type: "string", + default: "active" + }, + createdOn: { + type: "string" + }, + savedOn: { + type: "string" + }, + createdBy: { + type: "map" + }, + parent: { + type: "string" + }, + webinyVersion: { + type: "string" + }, + settings: { + type: "map", + default: {} + } + }); +}; diff --git a/packages/migrations/src/migrations/5.41.0/001/createUserEntity.ts b/packages/migrations/src/migrations/5.41.0/001/createUserEntity.ts new file mode 100644 index 00000000000..03a3f6fb35d --- /dev/null +++ b/packages/migrations/src/migrations/5.41.0/001/createUserEntity.ts @@ -0,0 +1,48 @@ +import { Table } from "@webiny/db-dynamodb/toolbox"; +import { createLegacyEntity, createStandardEntity } from "~/utils"; + +const attributes: Parameters[2] = { + id: { + type: "string" + }, + tenant: { + type: "string" + }, + email: { + type: "string" + }, + firstName: { + type: "string" + }, + lastName: { + type: "string" + }, + avatar: { + type: "map" + }, + createdBy: { + type: "map" + }, + createdOn: { + type: "string" + }, + group: { + type: "string" + }, + team: { + type: "string" + }, + groups: { + type: "map" + }, + teams: { + type: "map" + }, + webinyVersion: { + type: "string" + } +}; + +export const createUserEntity = (table: Table) => { + return createStandardEntity(table, "AdminUsers.User", attributes); +}; diff --git a/packages/migrations/src/migrations/5.41.0/001/index.ts b/packages/migrations/src/migrations/5.41.0/001/index.ts new file mode 100644 index 00000000000..b31be7bc9c6 --- /dev/null +++ b/packages/migrations/src/migrations/5.41.0/001/index.ts @@ -0,0 +1,94 @@ +import { Table } from "@webiny/db-dynamodb/toolbox"; +import { DataMigrationContext, PrimaryDynamoTableSymbol } from "@webiny/data-migration"; +import { queryOne, queryAll, batchWriteAll } from "~/utils"; +import { createTenantEntity } from "./createTenantEntity"; +import { createUserEntity } from "./createUserEntity"; +import { makeInjectable, inject } from "@webiny/ioc"; +import { executeWithRetry } from "@webiny/utils"; + +export class AdminUsers_5_41_0_001 { + private readonly newUserEntity: ReturnType; + private readonly tenantEntity: ReturnType; + + constructor(table: Table) { + this.newUserEntity = createUserEntity(table); + this.tenantEntity = createTenantEntity(table); + } + + getId() { + return "5.41.0-001"; + } + + getDescription() { + return "Introduce 'groups' and 'teams` properties (old 'group' and 'team' are no longer in use)"; + } + + async shouldExecute({ logger }: DataMigrationContext): Promise { + const user = await queryOne<{ data: any }>({ + entity: this.newUserEntity, + partitionKey: `T#root#ADMIN_USERS`, + options: { + index: "GSI1", + gt: " " + } + }); + + if (!user) { + logger.info(`No users were found; skipping migration.`); + return false; + } + + if (Array.isArray(user.data.groups)) { + logger.info(`User records seems to be in order; skipping migration.`); + return false; + } + + return true; + } + + async execute({ logger }: DataMigrationContext): Promise { + const tenants = await queryAll<{ id: string; name: string }>({ + entity: this.tenantEntity, + partitionKey: "TENANTS", + options: { + index: "GSI1", + gt: " " + } + }); + + for (const tenant of tenants) { + const users = await queryAll<{ id: string; email: string; data?: any }>({ + entity: this.newUserEntity, + partitionKey: `T#${tenant.id}#ADMIN_USERS`, + options: { + index: "GSI1", + gt: " " + } + }); + + if (users.length === 0) { + logger.info(`No users found on tenant "${tenant.id}".`); + continue; + } + + const newUsers = users + .filter(user => !Array.isArray(user.data.groups)) + .map(user => { + return this.newUserEntity.putBatch({ + ...user, + data: { + ...user.data, + groups: [user.data.group].filter(Boolean), + teams: [user.data.team].filter(Boolean) + } + }); + }); + + await executeWithRetry(() => + batchWriteAll({ table: this.newUserEntity.table, items: newUsers }) + ); + } + } +} + +makeInjectable(AdminUsers_5_41_0_001, [inject(PrimaryDynamoTableSymbol)]); diff --git a/yarn.lock b/yarn.lock index a2847371559..bc3ccbb804d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15945,7 +15945,6 @@ __metadata: "@types/jsonwebtoken": ^9.0.2 "@types/jwk-to-pem": ^2.0.1 "@types/node-fetch": ^2.6.1 - "@webiny/api": 0.0.0 "@webiny/api-admin-users": 0.0.0 "@webiny/api-i18n": 0.0.0 "@webiny/api-security": 0.0.0 @@ -15987,6 +15986,7 @@ __metadata: "@webiny/handler-graphql": 0.0.0 "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 + deep-equal: ^2.2.3 md5: ^2.3.0 rimraf: ^5.0.5 typescript: 4.9.5