diff --git a/packages/api-aco/__tests__/utils/useGraphQlHandler.ts b/packages/api-aco/__tests__/utils/useGraphQlHandler.ts index b28b3f4f86..4e06eec15d 100644 --- a/packages/api-aco/__tests__/utils/useGraphQlHandler.ts +++ b/packages/api-aco/__tests__/utils/useGraphQlHandler.ts @@ -66,7 +66,7 @@ import { FileManagerStorageOperations } from "@webiny/api-file-manager/types"; import { DecryptedWcpProjectLicense } from "@webiny/wcp/types"; import createAdminUsersApp from "@webiny/api-admin-users"; import { createWcpContext } from "@webiny/api-wcp"; -import { createTestWcpLicense } from "~tests/utils/createTestWcpLicense"; +import { createTestWcpLicense } from "@webiny/wcp/testing/createTestWcpLicense"; import { AdminUsersStorageOperations } from "@webiny/api-admin-users/types"; export interface UseGQLHandlerParams { diff --git a/packages/api-headless-cms-aco/__tests__/utils/createTestWcpLicense.ts b/packages/api-headless-cms-aco/__tests__/utils/createTestWcpLicense.ts deleted file mode 100644 index 0c92c342cf..0000000000 --- a/packages/api-headless-cms-aco/__tests__/utils/createTestWcpLicense.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - DecryptedWcpProjectLicense, - MT_OPTIONS_MAX_COUNT_TYPE, - PROJECT_PACKAGE_FEATURE_NAME -} from "@webiny/wcp/types"; - -export const createTestWcpLicense = (): DecryptedWcpProjectLicense => { - return { - orgId: "org-id", - projectId: "project-id", - package: { - features: { - [PROJECT_PACKAGE_FEATURE_NAME.AACL]: { - enabled: true, - options: { - teams: true, - folderLevelPermissions: true, - privateFiles: true - } - }, - [PROJECT_PACKAGE_FEATURE_NAME.MT]: { - enabled: true, - options: { - maxCount: { - type: MT_OPTIONS_MAX_COUNT_TYPE.SEAT_BASED - } - } - }, - [PROJECT_PACKAGE_FEATURE_NAME.APW]: { - enabled: false - }, - [PROJECT_PACKAGE_FEATURE_NAME.AUDIT_LOGS]: { - enabled: false - }, - [PROJECT_PACKAGE_FEATURE_NAME.SEATS]: { - enabled: true, - options: { - maxCount: 100 - } - } - } - } - }; -}; diff --git a/packages/api-headless-cms-aco/__tests__/utils/useGraphQlHandler.ts b/packages/api-headless-cms-aco/__tests__/utils/useGraphQlHandler.ts index 92e37b01c7..bb9ccc619a 100644 --- a/packages/api-headless-cms-aco/__tests__/utils/useGraphQlHandler.ts +++ b/packages/api-headless-cms-aco/__tests__/utils/useGraphQlHandler.ts @@ -18,7 +18,7 @@ import { getIntrospectionQuery } from "graphql"; import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; import { DecryptedWcpProjectLicense } from "@webiny/wcp/types"; import createAdminUsersApp from "@webiny/api-admin-users"; -import { createTestWcpLicense } from "~tests/utils/createTestWcpLicense"; +import { createTestWcpLicense } from "@webiny/wcp/testing/createTestWcpLicense"; import { createWcpContext } from "@webiny/api-wcp"; import { AdminUsersStorageOperations } from "@webiny/api-admin-users/types"; import { until } from "@webiny/project-utils/testing/helpers/until"; diff --git a/packages/api-page-builder-aco/__tests__/utils/createTestWcpLicense.ts b/packages/api-page-builder-aco/__tests__/utils/createTestWcpLicense.ts deleted file mode 100644 index 51eb5f98b2..0000000000 --- a/packages/api-page-builder-aco/__tests__/utils/createTestWcpLicense.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - DecryptedWcpProjectLicense, - MT_OPTIONS_MAX_COUNT_TYPE, - PROJECT_PACKAGE_FEATURE_NAME -} from "@webiny/wcp/types"; - -export const createTestWcpLicense = (): DecryptedWcpProjectLicense => { - return { - orgId: "org-id", - projectId: "project-id", - package: { - features: { - [PROJECT_PACKAGE_FEATURE_NAME.AACL]: { - enabled: true, - options: { - teams: true, - folderLevelPermissions: true, - privateFiles: true - } - }, - [PROJECT_PACKAGE_FEATURE_NAME.MT]: { - enabled: true, - options: { - maxCount: { - type: MT_OPTIONS_MAX_COUNT_TYPE.SEAT_BASED - } - } - }, - [PROJECT_PACKAGE_FEATURE_NAME.APW]: { - enabled: false - }, - [PROJECT_PACKAGE_FEATURE_NAME.AUDIT_LOGS]: { - enabled: false - }, - [PROJECT_PACKAGE_FEATURE_NAME.RECORD_LOCKING]: { - enabled: false - }, - [PROJECT_PACKAGE_FEATURE_NAME.SEATS]: { - enabled: true, - options: { - maxCount: 100 - } - } - } - } - }; -}; diff --git a/packages/api-page-builder-aco/__tests__/utils/useGraphQlHandler.ts b/packages/api-page-builder-aco/__tests__/utils/useGraphQlHandler.ts index 15d4eb3704..e382b3df79 100644 --- a/packages/api-page-builder-aco/__tests__/utils/useGraphQlHandler.ts +++ b/packages/api-page-builder-aco/__tests__/utils/useGraphQlHandler.ts @@ -41,7 +41,7 @@ import { getIntrospectionQuery } from "graphql"; import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; import { DecryptedWcpProjectLicense } from "@webiny/wcp/types"; import createAdminUsersApp from "@webiny/api-admin-users"; -import { createTestWcpLicense } from "~tests/utils/createTestWcpLicense"; +import { createTestWcpLicense } from "@webiny/wcp/testing/createTestWcpLicense"; import { createWcpContext } from "@webiny/api-wcp"; import { AdminUsersStorageOperations } from "@webiny/api-admin-users/types"; diff --git a/packages/api-security/__tests__/graphql/teams.ts b/packages/api-security/__tests__/graphql/teams.ts new file mode 100644 index 0000000000..c3a00d6981 --- /dev/null +++ b/packages/api-security/__tests__/graphql/teams.ts @@ -0,0 +1,75 @@ +const DATA_FIELD = (extra = "") => /* GraphQL */ ` + { + name + description + slug + groups { + id + name + } + ${extra} + } +`; + +const ERROR_FIELD = /* GraphQL */ ` + { + code + data + message + } +`; + +export const CREATE_SECURITY_TEAM = /* GraphQL */ ` + mutation CreateTeam($data: SecurityTeamCreateInput!) { + security { + createTeam(data: $data) { + data ${DATA_FIELD("id")} + error ${ERROR_FIELD} + } + } + } +`; + +export const UPDATE_SECURITY_TEAM = /* GraphQL */ ` + mutation UpdateTeam($id: ID!, $data: SecurityTeamUpdateInput!) { + security { + updateTeam(id: $id, data: $data) { + data ${DATA_FIELD()} + error ${ERROR_FIELD} + } + } + } +`; + +export const DELETE_SECURITY_TEAM = /* GraphQL */ ` + mutation DeleteTeam($id: ID!) { + security { + deleteTeam(id: $id) { + data + error ${ERROR_FIELD} + } + } + } +`; + +export const LIST_SECURITY_TEAMS = /* GraphQL */ ` + query ListTeams { + security { + listTeams { + data ${DATA_FIELD()} + error ${ERROR_FIELD} + } + } + } +`; + +export const GET_SECURITY_TEAM = /* GraphQL */ ` + query GetTeam($id: ID!) { + security { + getTeam(where: { id: $id }) { + data ${DATA_FIELD()} + error ${ERROR_FIELD} + } + } + } +`; diff --git a/packages/api-security/__tests__/groups.test.ts b/packages/api-security/__tests__/groups.test.ts index e6c27df453..6ff14a5e7e 100644 --- a/packages/api-security/__tests__/groups.test.ts +++ b/packages/api-security/__tests__/groups.test.ts @@ -1,8 +1,24 @@ import useGqlHandler from "./useGqlHandler"; import mocks from "./mocks/securityGroup"; +import { createSecurityRolePlugin } from "~/plugins/SecurityRolePlugin"; describe("Security Group CRUD Test", () => { - const { install, securityGroup } = useGqlHandler(); + const { install, securityGroup } = useGqlHandler({ + plugins: [ + createSecurityRolePlugin({ + id: "test-role-1", + name: "Test Role 1", + description: "1st test role defined via an extension.", + permissions: [{ name: "cms.*" }] + }), + createSecurityRolePlugin({ + id: "test-role-2", + name: "Test Role 2", + description: "2nd test role defined via an extension.", + permissions: [{ name: "pb.*" }] + }) + ] + }); beforeEach(async () => { await install.install(); @@ -23,19 +39,67 @@ describe("Security Group CRUD Test", () => { // Let's check whether both of the group exists const [listResponse] = await securityGroup.list(); - expect(listResponse.data.security.listGroups).toEqual( - expect.objectContaining({ - data: expect.arrayContaining([ - { - name: expect.any(String), - description: expect.any(String), - slug: expect.stringMatching(/anonymous|full-access|group-a|group-b/), - permissions: expect.any(Array) - } - ]), - error: null - }) - ); + expect(listResponse.data.security.listGroups).toEqual({ + error: null, + data: [ + { + name: "Test Role 1", + description: "1st test role defined via an extension.", + slug: "test-role-1", + permissions: [ + { + name: "cms.*" + } + ] + }, + { + name: "Test Role 2", + description: "2nd test role defined via an extension.", + slug: "test-role-2", + permissions: [ + { + name: "pb.*" + } + ] + }, + { + name: "Full Access", + description: "Grants full access to all apps.", + slug: "full-access", + permissions: [ + { + name: "*" + } + ] + }, + { + name: "Anonymous", + description: "Permissions for anonymous users (public access).", + slug: "anonymous", + permissions: [] + }, + { + name: "Group-A", + description: "A: Dolor odit et quia animi ipsum nostrum nesciunt.", + slug: "group-a", + permissions: [ + { + name: "security.*" + } + ] + }, + { + name: "Group-B", + description: "B: Dolor odit et quia animi ipsum nostrum nesciunt.", + slug: "group-b", + permissions: [ + { + name: "security.*" + } + ] + } + ] + }); // Let's update the "groupB" name const updatedName = "Group B - updated"; @@ -131,4 +195,49 @@ describe("Security Group CRUD Test", () => { } }); }); + + test("should not allow update of a group created via a plugin", async () => { + // Creating a group with same "slug" should not be allowed + const [response] = await securityGroup.update({ + id: "test-role-1", + data: { + name: "Test Role 1 - updated" + } + }); + + expect(response).toEqual({ + data: { + security: { + updateGroup: { + data: null, + error: { + code: "CANNOT_UPDATE_PLUGIN_GROUPS", + data: null, + message: "Cannot update groups created via plugins." + } + } + } + } + }); + }); + + test("should not allow deletion of a group created via a plugin", async () => { + // Creating a group with same "slug" should not be allowed + const [response] = await securityGroup.delete({ id: "" }); + + expect(response).toEqual({ + data: { + security: { + deleteGroup: { + data: null, + error: { + code: "CANNOT_DELETE_PLUGIN_GROUPS", + data: null, + message: "Cannot delete groups created via plugins." + } + } + } + } + }); + }); }); diff --git a/packages/api-security/__tests__/mocks/securityTeam.ts b/packages/api-security/__tests__/mocks/securityTeam.ts new file mode 100644 index 0000000000..9358b2632a --- /dev/null +++ b/packages/api-security/__tests__/mocks/securityTeam.ts @@ -0,0 +1,16 @@ +const mocks = { + teamA: { + name: "Team-A", + slug: "team-a", + description: "A: Dolor odit et quia animi ipsum nostrum nesciunt.", + groups: [] + }, + teamB: { + name: "Team-B", + slug: "team-b", + description: "B: Dolor odit et quia animi ipsum nostrum nesciunt.", + groups: [] + } +}; + +export default mocks; diff --git a/packages/api-security/__tests__/teams.test.ts b/packages/api-security/__tests__/teams.test.ts new file mode 100644 index 0000000000..0e2dfdb06c --- /dev/null +++ b/packages/api-security/__tests__/teams.test.ts @@ -0,0 +1,235 @@ +import useGqlHandler from "./useGqlHandler"; +import mocks from "./mocks/securityTeam"; +import { createSecurityRolePlugin } from "~/plugins/SecurityRolePlugin"; +import { createSecurityTeamPlugin } from "~/plugins/SecurityTeamPlugin"; +import { createTestWcpLicense } from "@webiny/wcp/testing/createTestWcpLicense"; + +describe("Security Team CRUD Test", () => { + const { install, securityTeam } = useGqlHandler({ + wcpLicense: createTestWcpLicense(), + plugins: [ + createSecurityRolePlugin({ + id: "test-team-1", + name: "Test Team 1", + description: "1st test team defined via an extension.", + permissions: [{ name: "cms.*" }] + }), + createSecurityRolePlugin({ + id: "test-team-2", + name: "Test Team 2", + description: "2nd test team defined via an extension.", + permissions: [{ name: "pb.*" }] + }), + createSecurityTeamPlugin({ + id: "test-team-2", + name: "Test Team 2", + description: "2nd test team defined via an extension.", + roles: ["test-team-1"] + }), + createSecurityTeamPlugin({ + id: "test-team-1", + name: "Test Team 1", + description: "1st test team defined via an extension.", + roles: ["test-team-2"] + }) + ] + }); + + beforeEach(async () => { + await install.install(); + }); + + test("should able to create, read, update and delete `Security Teams`", async () => { + const [responseA] = await securityTeam.create({ data: mocks.teamA }); + + // Let's create two teams. + const teamA = responseA.data.security.createTeam.data; + expect(teamA).toEqual({ id: teamA.id, ...mocks.teamA }); + + const [responseB] = await securityTeam.create({ data: mocks.teamB }); + + const teamB = responseB.data.security.createTeam.data; + expect(teamB).toEqual({ id: teamB.id, ...mocks.teamB }); + + // Let's check whether both of the team exists + const [listResponse] = await securityTeam.list(); + + expect(listResponse.data.security.listTeams).toEqual({ + data: [ + { + name: "Test Team 2", + description: "2nd test team defined via an extension.", + slug: "test-team-2", + groups: [ + { + id: "test-team-1", + name: "Test Team 1" + } + ] + }, + { + name: "Test Team 1", + description: "1st test team defined via an extension.", + slug: "test-team-1", + groups: [ + { + id: "test-team-2", + name: "Test Team 2" + } + ] + }, + { + name: "Team-A", + description: "A: Dolor odit et quia animi ipsum nostrum nesciunt.", + slug: "team-a", + groups: [] + }, + { + name: "Team-B", + description: "B: Dolor odit et quia animi ipsum nostrum nesciunt.", + slug: "team-b", + groups: [] + } + ], + error: null + }); + + // Let's update the "teamB" name + const updatedName = "Team B - updated"; + const [updateB] = await securityTeam.update({ + id: teamB.id, + data: { + name: updatedName + } + }); + + expect(updateB).toEqual({ + data: { + security: { + updateTeam: { + data: { + ...mocks.teamB, + name: updatedName + }, + error: null + } + } + } + }); + + // Let's delete "teamB" + const [deleteB] = await securityTeam.delete({ + id: teamB.id + }); + + expect(deleteB).toEqual({ + data: { + security: { + deleteTeam: { + data: true, + error: null + } + } + } + }); + + // Should not contain "teamB" + const [getB] = await securityTeam.get({ id: teamB.id }); + + expect(getB).toMatchObject({ + data: { + security: { + getTeam: { + data: null, + error: { + code: "NOT_FOUND", + data: null + } + } + } + } + }); + + // Should contain "teamA" by slug + const [getA] = await securityTeam.get({ id: teamA.id }); + + expect(getA).toEqual({ + data: { + security: { + getTeam: { + data: mocks.teamA, + error: null + } + } + } + }); + }); + + test('should not allow creating a team with same "slug"', async () => { + // Creating a team + await securityTeam.create({ data: mocks.teamA }); + + // Creating a team with same "slug" should not be allowed + const [response] = await securityTeam.create({ data: mocks.teamA }); + + expect(response).toEqual({ + data: { + security: { + createTeam: { + data: null, + error: { + code: "TEAM_EXISTS", + message: `Team with slug "${mocks.teamA.slug}" already exists.`, + data: null + } + } + } + } + }); + }); + + test("should not allow update of a team created via a plugin", async () => { + // Creating a team with same "slug" should not be allowed + const [response] = await securityTeam.update({ + id: "test-team-1", + data: { + name: "Test Team 1 - updated" + } + }); + + expect(response).toEqual({ + data: { + security: { + updateTeam: { + data: null, + error: { + code: "CANNOT_UPDATE_PLUGIN_TEAMS", + data: null, + message: "Cannot update teams created via plugins." + } + } + } + } + }); + }); + + test("should not allow deletion of a team created via a plugin", async () => { + // Creating a team with same "slug" should not be allowed + const [response] = await securityTeam.delete({ id: "" }); + + expect(response).toEqual({ + data: { + security: { + deleteTeam: { + data: null, + error: { + code: "CANNOT_DELETE_PLUGIN_TEAMS", + data: null, + message: "Cannot delete teams created via plugins." + } + } + } + } + }); + }); +}); diff --git a/packages/api-security/__tests__/useGqlHandler.ts b/packages/api-security/__tests__/useGqlHandler.ts index 157aaaf5dc..2bf8f363e2 100644 --- a/packages/api-security/__tests__/useGqlHandler.ts +++ b/packages/api-security/__tests__/useGqlHandler.ts @@ -14,6 +14,14 @@ import { UPDATE_SECURITY_GROUP } from "./graphql/groups"; +import { + CREATE_SECURITY_TEAM, + DELETE_SECURITY_TEAM, + GET_SECURITY_TEAM, + LIST_SECURITY_TEAMS, + UPDATE_SECURITY_TEAM +} from "./graphql/teams"; + import { CREATE_API_KEY, DELETE_API_KEY, @@ -32,9 +40,12 @@ import { TenancyStorageOperations } from "@webiny/api-tenancy/types"; import { getStorageOps } from "@webiny/project-utils/testing/environment"; import { SecurityStorageOperations } from "~/types"; import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; +import { DecryptedWcpProjectLicense } from "@webiny/wcp/types"; +import { createWcpContext } from "@webiny/api-wcp"; type UseGqlHandlerParams = { plugins?: PluginCollection; + wcpLicense?: DecryptedWcpProjectLicense; }; export default (opts: UseGqlHandlerParams = {}) => { @@ -48,6 +59,7 @@ export default (opts: UseGqlHandlerParams = {}) => { const handler = createHandler({ plugins: [ graphqlHandlerPlugins(), + createWcpContext({ testProjectLicense: opts.wcpLicense }), createTenancyContext({ storageOperations: tenancyStorage.storageOperations }), @@ -101,6 +113,23 @@ export default (opts: UseGqlHandlerParams = {}) => { return invoke({ body: { query: GET_SECURITY_GROUP, variables } }); } }; + const securityTeam = { + async create(variables = {}) { + return invoke({ body: { query: CREATE_SECURITY_TEAM, variables } }); + }, + async update(variables = {}) { + return invoke({ body: { query: UPDATE_SECURITY_TEAM, variables } }); + }, + async delete(variables = {}) { + return invoke({ body: { query: DELETE_SECURITY_TEAM, variables } }); + }, + async list(variables = {}, headers = {}) { + return invoke({ body: { query: LIST_SECURITY_TEAMS, variables }, headers }); + }, + async get(variables = {}) { + return invoke({ body: { query: GET_SECURITY_TEAM, variables } }); + } + }; const securityApiKeys = { async list(variables = {}) { @@ -144,6 +173,7 @@ export default (opts: UseGqlHandlerParams = {}) => { invoke, securityIdentity, securityGroup, + securityTeam, securityApiKeys, install }; diff --git a/packages/api-security/src/createSecurity/createGroupsMethods.ts b/packages/api-security/src/createSecurity/createGroupsMethods.ts index 4151c2c8cc..d86b801760 100644 --- a/packages/api-security/src/createSecurity/createGroupsMethods.ts +++ b/packages/api-security/src/createSecurity/createGroupsMethods.ts @@ -28,6 +28,14 @@ import { } from "~/types"; import NotAuthorizedError from "../NotAuthorizedError"; import { SecurityConfig } from "~/types"; +import { + listGroupsFromProvider as baseListGroupsFromPlugins, + type ListGroupsFromPluginsParams +} from "./groupsTeamsPlugins/listGroupsFromProvider"; +import { + getGroupFromProvider as baseGetGroupFromPlugins, + type GetGroupFromPluginsParams +} from "./groupsTeamsPlugins/getGroupFromProvider"; const CreateDataModel = withFields({ tenant: string({ validation: validation.create("required") }), @@ -150,7 +158,8 @@ async function updateTenantLinks( export const createGroupsMethods = ({ getTenant: initialGetTenant, - storageOperations + storageOperations, + groupsProvider }: SecurityConfig) => { const getTenant = () => { const tenant = initialGetTenant(); @@ -159,6 +168,25 @@ export const createGroupsMethods = ({ } return tenant; }; + + const listGroupsFromPlugins = ( + params: Pick + ): Promise => { + return baseListGroupsFromPlugins({ + ...params, + groupsProvider + }); + }; + + const getGroupFromPlugins = ( + params: Pick + ): Promise => { + return baseGetGroupFromPlugins({ + ...params, + groupsProvider + }); + }; + return { onGroupBeforeCreate: createTopic("security.onGroupBeforeCreate"), onGroupAfterCreate: createTopic("security.onGroupAfterCreate"), @@ -174,9 +202,14 @@ export const createGroupsMethods = ({ let group: Group | null = null; try { - group = await storageOperations.getGroup({ - where: { ...where, tenant: where.tenant || getTenant() } - }); + const whereWithTenant = { ...where, tenant: where.tenant || getTenant() }; + const groupFromPlugins = await getGroupFromPlugins({ where: whereWithTenant }); + + if (groupFromPlugins) { + group = groupFromPlugins; + } else { + group = await storageOperations.getGroup({ where: whereWithTenant }); + } } catch (ex) { throw new WebinyError( ex.message || "Could not get group.", @@ -193,17 +226,23 @@ export const createGroupsMethods = ({ async listGroups(this: Security, { where }: ListGroupsParams = {}) { await checkPermission(this); try { - return await storageOperations.listGroups({ - where: { - ...where, - tenant: getTenant() - }, + const whereWithTenant = { ...where, tenant: getTenant() }; + + const groupsFromDatabase = await storageOperations.listGroups({ + where: whereWithTenant, sort: ["createdOn_ASC"] }); + + const groupsFromPlugins = await listGroupsFromPlugins({ where: whereWithTenant }); + + // We don't have to do any extra sorting because, as we can see above, `createdOn_ASC` is + // hardcoded, and groups coming from plugins don't have `createdOn`, meaning they should + // always be at the top of the list. + return [...groupsFromPlugins, ...groupsFromDatabase]; } catch (ex) { throw new WebinyError( - ex.message || "Could not list groups.", - ex.code || "LIST_GROUPS_ERROR" + ex.message || "Could not list security groups.", + ex.code || "LIST_SECURITY_GROUP_ERROR" ); } }, @@ -269,13 +308,30 @@ export const createGroupsMethods = ({ const model = await new UpdateDataModel().populate(input); await model.validate(); - const original = await storageOperations.getGroup({ + const original = await this.getGroup({ where: { tenant: getTenant(), id } }); if (!original) { throw new NotFoundError(`Group "${id}" was not found!`); } + // We can't proceed with the update if one of the following is true: + // 1. The group is system group. + // 2. The group is created via a plugin. + if (original.system) { + throw new WebinyError( + `Cannot update system groups.`, + "CANNOT_UPDATE_SYSTEM_GROUPS" + ); + } + + if (original.plugin) { + throw new WebinyError( + `Cannot update groups created via plugins.`, + "CANNOT_UPDATE_PLUGIN_GROUPS" + ); + } + const data = await model.toJSON({ onlyDirty: true }); const permissionsChanged = !deepEqual(data.permissions, original.permissions); @@ -307,17 +363,16 @@ export const createGroupsMethods = ({ async deleteGroup(this: Security, id: string): Promise { await checkPermission(this); - const group = await storageOperations.getGroup({ where: { tenant: getTenant(), id } }); + const group = await this.getGroup({ where: { tenant: getTenant(), id } }); if (!group) { throw new NotFoundError(`Group "${id}" was not found!`); } // We can't proceed with the deletion if one of the following is true: // 1. The group is system group. - // 2. The group is being used by one or more tenant links. - // 3. The group is being used by one or more teams. - - // 1. Is system group? + // 2. The group is created via a plugin. + // 3. The group is being used by one or more tenant links. + // 4. The group is being used by one or more teams. if (group.system) { throw new WebinyError( `Cannot delete system groups.`, @@ -325,6 +380,13 @@ export const createGroupsMethods = ({ ); } + if (group.plugin) { + throw new WebinyError( + `Cannot delete groups created via plugins.`, + "CANNOT_DELETE_PLUGIN_GROUPS" + ); + } + // 2. Is being used by one or more tenant links? const usagesInTenantLinks = await storageOperations .listTenantLinksByType({ diff --git a/packages/api-security/src/createSecurity/createTeamsMethods.ts b/packages/api-security/src/createSecurity/createTeamsMethods.ts index a82b2fc2d9..b45794c4d8 100644 --- a/packages/api-security/src/createSecurity/createTeamsMethods.ts +++ b/packages/api-security/src/createSecurity/createTeamsMethods.ts @@ -20,10 +20,18 @@ import { TeamInput, PermissionsTenantLink, Security, - ListGroupsParams + ListTeamsParams } from "~/types"; import NotAuthorizedError from "../NotAuthorizedError"; import { SecurityConfig } from "~/types"; +import { + listTeamsFromProvider as baseListTeamsFromPlugins, + type ListTeamsFromPluginsParams +} from "./groupsTeamsPlugins/listTeamsFromProvider"; +import { + getTeamFromProvider as baseGetTeamFromPlugins, + type GetTeamFromPluginsParams +} from "./groupsTeamsPlugins/getTeamFromProvider"; const CreateDataModel = withFields({ tenant: string({ validation: validation.create("required") }), @@ -113,7 +121,8 @@ async function updateTenantLinks( export const createTeamsMethods = ({ getTenant: initialGetTenant, - storageOperations + storageOperations, + teamsProvider }: SecurityConfig) => { const getTenant = () => { const tenant = initialGetTenant(); @@ -122,6 +131,22 @@ export const createTeamsMethods = ({ } return tenant; }; + + const listTeamsFromPlugins = ( + params: Pick + ): Promise => { + return baseListTeamsFromPlugins({ + ...params, + teamsProvider + }); + }; + const getTeamFromPlugins = (params: Pick): Promise => { + return baseGetTeamFromPlugins({ + ...params, + teamsProvider + }); + }; + return { onTeamBeforeCreate: createTopic("security.onTeamBeforeCreate"), onTeamAfterCreate: createTopic("security.onTeamAfterCreate"), @@ -137,9 +162,14 @@ export const createTeamsMethods = ({ let team: Team | null = null; try { - team = await storageOperations.getTeam({ - where: { ...where, tenant: where.tenant || getTenant() } - }); + const whereWithTenant = { ...where, tenant: where.tenant || getTenant() }; + const teamFromPlugins = await getTeamFromPlugins({ where: whereWithTenant }); + + if (teamFromPlugins) { + team = teamFromPlugins; + } else { + team = await storageOperations.getTeam({ where: whereWithTenant }); + } } catch (ex) { throw new WebinyError( ex.message || "Could not get team.", @@ -153,20 +183,26 @@ export const createTeamsMethods = ({ return team; }, - async listTeams(this: Security, { where }: ListGroupsParams = {}) { + async listTeams(this: Security, { where }: ListTeamsParams = {}) { await checkPermission(this); try { - return await storageOperations.listTeams({ - where: { - ...where, - tenant: getTenant() - }, + const whereWithTenant = { ...where, tenant: getTenant() }; + + const teamsFromDatabase = await storageOperations.listTeams({ + where: whereWithTenant, sort: ["createdOn_ASC"] }); + + const teamsFromPlugins = await listTeamsFromPlugins({ where: whereWithTenant }); + + // We don't have to do any extra sorting because, as we can see above, `createdOn_ASC` is + // hardcoded, and teams coming from plugins don't have `createdOn`, meaning they should + // always be at the top of the list. + return [...teamsFromPlugins, ...teamsFromDatabase]; } catch (ex) { throw new WebinyError( - ex.message || "Could not list API keys.", - ex.code || "LIST_API_KEY_ERROR" + ex.message || "Could not list teams.", + ex.code || "LIST_TEAM_ERROR" ); } }, @@ -232,13 +268,28 @@ export const createTeamsMethods = ({ const model = await new UpdateDataModel().populate(input); await model.validate(); - const original = await storageOperations.getTeam({ + const original = await this.getTeam({ where: { tenant: getTenant(), id } }); + if (!original) { throw new NotFoundError(`Team "${id}" was not found!`); } + // We can't proceed with the update if one of the following is true: + // 1. The group is system group. + // 2. The group is created via a plugin. + if (original.system) { + throw new WebinyError(`Cannot update system teams.`, "CANNOT_UPDATE_SYSTEM_TEAMS"); + } + + if (original.plugin) { + throw new WebinyError( + `Cannot update teams created via plugins.`, + "CANNOT_UPDATE_PLUGIN_TEAMS" + ); + } + const data = await model.toJSON({ onlyDirty: true }); const groupsChanged = !deepEqual(data.groups, original.groups); @@ -270,11 +321,27 @@ export const createTeamsMethods = ({ async deleteTeam(this: Security, id: string): Promise { await checkPermission(this); - const team = await storageOperations.getTeam({ where: { tenant: getTenant(), id } }); + const team = await this.getTeam({ where: { tenant: getTenant(), id } }); if (!team) { throw new NotFoundError(`Team "${id}" was not found!`); } + // We can't proceed with the deletion if one of the following is true: + // 1. The group is system group. + // 2. The group is created via a plugin. + // 3. The group is being used by one or more tenant links. + // 4. The group is being used by one or more teams. + if (team.system) { + throw new WebinyError(`Cannot delete system teams.`, "CANNOT_DELETE_SYSTEM_TEAMS"); + } + + if (team.plugin) { + throw new WebinyError( + `Cannot delete teams created via plugins.`, + "CANNOT_DELETE_PLUGIN_TEAMS" + ); + } + const usagesInTenantLinks = await storageOperations .listTenantLinksByType({ tenant: getTenant(), diff --git a/packages/api-security/src/createSecurity/groupsTeamsPlugins/getGroupFromProvider.ts b/packages/api-security/src/createSecurity/groupsTeamsPlugins/getGroupFromProvider.ts new file mode 100644 index 0000000000..c3d4b56161 --- /dev/null +++ b/packages/api-security/src/createSecurity/groupsTeamsPlugins/getGroupFromProvider.ts @@ -0,0 +1,25 @@ +import { SecurityConfig } from "~/types"; +import { listGroupsFromProvider } from "./listGroupsFromProvider"; + +export interface GetGroupFromPluginsParams { + groupsProvider?: SecurityConfig["groupsProvider"]; + where: { + tenant: string; + id?: string; + slug?: string; + }; +} + +export const getGroupFromProvider = async (params: GetGroupFromPluginsParams) => { + const { groupsProvider, where } = params; + const [group] = await listGroupsFromProvider({ + groupsProvider, + where: { + tenant: where.tenant, + id_in: where.id ? [where.id] : undefined, + slug_in: where.slug ? [where.slug] : undefined + } + }); + + return group; +}; diff --git a/packages/api-security/src/createSecurity/groupsTeamsPlugins/getTeamFromProvider.ts b/packages/api-security/src/createSecurity/groupsTeamsPlugins/getTeamFromProvider.ts new file mode 100644 index 0000000000..8e9e78cd06 --- /dev/null +++ b/packages/api-security/src/createSecurity/groupsTeamsPlugins/getTeamFromProvider.ts @@ -0,0 +1,25 @@ +import { SecurityConfig } from "~/types"; +import { listTeamsFromProvider } from "./listTeamsFromProvider"; + +export interface GetTeamFromPluginsParams { + teamsProvider?: SecurityConfig["teamsProvider"]; + where: { + tenant: string; + id?: string; + slug?: string; + }; +} + +export const getTeamFromProvider = async (params: GetTeamFromPluginsParams) => { + const { teamsProvider, where } = params; + const [team] = await listTeamsFromProvider({ + teamsProvider, + where: { + tenant: where.tenant, + id_in: where.id ? [where.id] : undefined, + slug_in: where.slug ? [where.slug] : undefined + } + }); + + return team; +}; diff --git a/packages/api-security/src/createSecurity/groupsTeamsPlugins/listGroupsFromProvider.ts b/packages/api-security/src/createSecurity/groupsTeamsPlugins/listGroupsFromProvider.ts new file mode 100644 index 0000000000..ca0f9aa81a --- /dev/null +++ b/packages/api-security/src/createSecurity/groupsTeamsPlugins/listGroupsFromProvider.ts @@ -0,0 +1,43 @@ +import { SecurityConfig } from "~/types"; + +export interface ListGroupsFromPluginsParams { + groupsProvider?: SecurityConfig["groupsProvider"]; + where: { + tenant: string; + id_in?: string[]; + slug_in?: string[]; + }; +} + +export const listGroupsFromProvider = async (params: ListGroupsFromPluginsParams) => { + const { groupsProvider, where } = params; + if (!groupsProvider) { + return []; + } + + const groups = await groupsProvider(); + + return groups.filter(group => { + // First we ensure the group belongs to the correct tenant. + if (group.tenant) { + if (group.tenant !== where.tenant) { + return false; + } + } + + const { id_in, slug_in } = where; + if (id_in) { + if (!id_in.includes(group.id)) { + return false; + } + } + + if (slug_in) { + if (!slug_in.includes(group.id)) { + return false; + } + } + + return group; + }); +}; diff --git a/packages/api-security/src/createSecurity/groupsTeamsPlugins/listTeamsFromProvider.ts b/packages/api-security/src/createSecurity/groupsTeamsPlugins/listTeamsFromProvider.ts new file mode 100644 index 0000000000..6da6112c20 --- /dev/null +++ b/packages/api-security/src/createSecurity/groupsTeamsPlugins/listTeamsFromProvider.ts @@ -0,0 +1,43 @@ +import { SecurityConfig } from "~/types"; + +export interface ListTeamsFromPluginsParams { + teamsProvider?: SecurityConfig["teamsProvider"]; + where: { + tenant: string; + id_in?: string[]; + slug_in?: string[]; + }; +} + +export const listTeamsFromProvider = async (params: ListTeamsFromPluginsParams) => { + const { teamsProvider, where } = params; + if (!teamsProvider) { + return []; + } + + const teams = await teamsProvider(); + + return teams.filter(team => { + // First we ensure the team belongs to the correct tenant. + if (team.tenant) { + if (team.tenant !== where.tenant) { + return false; + } + } + + const { id_in, slug_in } = where; + if (id_in) { + if (!id_in.includes(team.id)) { + return false; + } + } + + if (slug_in) { + if (!slug_in.includes(team.id)) { + return false; + } + } + + return team; + }); +}; diff --git a/packages/api-security/src/graphql/group.gql.ts b/packages/api-security/src/graphql/group.gql.ts index a745cf6f65..e0aed27a91 100644 --- a/packages/api-security/src/graphql/group.gql.ts +++ b/packages/api-security/src/graphql/group.gql.ts @@ -17,6 +17,7 @@ export default new GraphQLSchemaPlugin({ description: String permissions: [JSON] system: Boolean! + plugin: Boolean } input SecurityGroupCreateInput { diff --git a/packages/api-security/src/graphql/team.gql.ts b/packages/api-security/src/graphql/team.gql.ts index f34e61e4a3..d1235b138a 100644 --- a/packages/api-security/src/graphql/team.gql.ts +++ b/packages/api-security/src/graphql/team.gql.ts @@ -17,6 +17,7 @@ export default new GraphQLSchemaPlugin({ description: String groups: [SecurityGroup] system: Boolean! + plugin: Boolean } input SecurityTeamCreateInput { diff --git a/packages/api-security/src/index.ts b/packages/api-security/src/index.ts index dd25368f09..1365fe0c88 100644 --- a/packages/api-security/src/index.ts +++ b/packages/api-security/src/index.ts @@ -16,6 +16,8 @@ import { MultiTenancyAppConfig, MultiTenancyGraphQLConfig } from "~/enterprise/multiTenancy"; +import { SecurityRolePlugin } from "~/plugins/SecurityRolePlugin"; +import { SecurityTeamPlugin } from "~/plugins/SecurityTeamPlugin"; export { default as NotAuthorizedResponse } from "./NotAuthorizedResponse"; export { default as NotAuthorizedError } from "./NotAuthorizedError"; @@ -42,7 +44,15 @@ export const createSecurityContext = ({ storageOperations }: SecurityConfig) => const tenant = context.tenancy.getCurrentTenant(); return tenant ? tenant.id : undefined; }, - storageOperations + storageOperations, + groupsProvider: async () => + context.plugins + .byType(SecurityRolePlugin.type) + .map(plugin => plugin.securityRole), + teamsProvider: async () => + context.plugins + .byType(SecurityTeamPlugin.type) + .map(plugin => plugin.securityTeam) }); attachGroupInstaller(context.security); @@ -77,3 +87,6 @@ export const createSecurityGraphQL = (config: MultiTenancyGraphQLConfig = {}) => } }); }; + +export { createSecurityRolePlugin } from "./plugins/SecurityRolePlugin"; +export { createSecurityTeamPlugin } from "./plugins/SecurityTeamPlugin"; diff --git a/packages/api-security/src/plugins/SecurityRolePlugin.ts b/packages/api-security/src/plugins/SecurityRolePlugin.ts new file mode 100644 index 0000000000..8a05a6a0ed --- /dev/null +++ b/packages/api-security/src/plugins/SecurityRolePlugin.ts @@ -0,0 +1,44 @@ +import { Plugin } from "@webiny/plugins"; +import { SecurityPermission, SecurityRole } from "~/types"; + +export interface SecurityRolePluginParams { + id: string; + name: string; + slug?: string; + description?: string; + permissions?: SecurityPermission[]; + tenant?: string; +} + +export class SecurityRolePlugin extends Plugin { + public static override readonly type: string = "security-role"; + public readonly securityRole: SecurityRole; + + constructor(params: SecurityRolePluginParams) { + super(); + + const { id, name, slug = id, description = "", permissions = [], tenant = null } = params; + + this.securityRole = { + id, + name, + slug, + description, + permissions, + tenant, + + // Internal properties. + system: false, + plugin: true, + createdBy: null, + createdOn: null, + webinyVersion: null + }; + } +} + +export const createSecurityRolePlugin = ( + securityRole: SecurityRolePluginParams +): SecurityRolePlugin => { + return new SecurityRolePlugin(securityRole); +}; diff --git a/packages/api-security/src/plugins/SecurityTeamPlugin.ts b/packages/api-security/src/plugins/SecurityTeamPlugin.ts new file mode 100644 index 0000000000..36e97229d3 --- /dev/null +++ b/packages/api-security/src/plugins/SecurityTeamPlugin.ts @@ -0,0 +1,44 @@ +import { Plugin } from "@webiny/plugins"; +import { SecurityTeam } from "~/types"; + +export interface SecurityTeamPluginParams { + id: string; + name: string; + slug?: string; + description?: string; + roles?: string[]; + tenant?: string; +} + +export class SecurityTeamPlugin extends Plugin { + public static override readonly type: string = "security-team"; + public readonly securityTeam: SecurityTeam; + + constructor(params: SecurityTeamPluginParams) { + super(); + + const { id, name, slug = id, description = "", roles = [], tenant = null } = params; + + this.securityTeam = { + id, + name, + slug, + description, + groups: roles, + tenant, + + // Internal properties. + system: false, + plugin: true, + createdBy: null, + createdOn: null, + webinyVersion: null + }; + } +} + +export const createSecurityTeamPlugin = ( + securityTeam: SecurityTeamPluginParams +): SecurityTeamPlugin => { + return new SecurityTeamPlugin(securityTeam); +}; diff --git a/packages/api-security/src/types.ts b/packages/api-security/src/types.ts index 8ff40c9817..83a9d1bfe9 100644 --- a/packages/api-security/src/types.ts +++ b/packages/api-security/src/types.ts @@ -34,6 +34,8 @@ export interface SecurityConfig { advancedAccessControlLayer?: ProjectPackageFeatures["advancedAccessControlLayer"]; getTenant: GetTenant; storageOperations: SecurityStorageOperations; + groupsProvider?: () => Promise; + teamsProvider?: () => Promise; } export interface ErrorEvent extends InstallEvent { @@ -280,18 +282,30 @@ export interface CreatedBy { } export interface Group { - tenant: string; - createdOn: string; + // Groups defined via plugins might not have `tenant` specified (meaning they are global). + tenant: string | null; + + // Groups defined via plugins don't have `createdOn` and `createdBy` specified. + createdOn: string | null; createdBy: CreatedBy | null; + id: string; name: string; slug: string; description: string; system: boolean; permissions: SecurityPermission[]; - webinyVersion: string; + + // Groups defined via plugins don't have `webinyVersion` specified. + webinyVersion: string | null; + + // Set to `true` when a group is defined via a plugin. + plugin?: boolean; } +export type SecurityRole = Group; +export type SecurityTeam = Team; + export type GroupInput = Pick & { system?: boolean; }; @@ -326,16 +340,25 @@ export interface DeleteGroupParams { } export interface Team { - tenant: string; - createdOn: string; + // Teams defined via plugins might not have `tenant` specified (meaning they are global). + tenant: string | null; + + // Teams defined via plugins don't have `createdOn` and `createdBy` specified. + createdOn: string | null; createdBy: CreatedBy | null; + id: string; name: string; slug: string; description: string; system: boolean; groups: string[]; - webinyVersion: string; + + // Teams defined via plugins don't have `webinyVersion` specified. + webinyVersion: string | null; + + // Set to `true` when a group is defined via a plugin. + plugin?: boolean; } export type TeamInput = Pick & { diff --git a/packages/api-serverless-cms/src/index.ts b/packages/api-serverless-cms/src/index.ts index f1d6e92782..d9f4d03942 100644 --- a/packages/api-serverless-cms/src/index.ts +++ b/packages/api-serverless-cms/src/index.ts @@ -15,6 +15,7 @@ import { createGraphQLSchemaPlugin as baseCreateGraphQLSchemaPlugin, GraphQLSchemaPluginConfig } from "@webiny/handler-graphql"; +import { createSecurityRolePlugin, createSecurityTeamPlugin } from "@webiny/api-security"; export interface Context extends ClientContext, @@ -42,4 +43,6 @@ export const createGraphQLSchemaPlugin = ( return baseCreateGraphQLSchemaPlugin(config); }; +export { createSecurityRolePlugin, createSecurityTeamPlugin }; + export * from "./tenancy/InstallTenant"; diff --git a/packages/app-security-access-management/package.json b/packages/app-security-access-management/package.json index 6f2c63aff4..283c76f54c 100644 --- a/packages/app-security-access-management/package.json +++ b/packages/app-security-access-management/package.json @@ -19,7 +19,6 @@ "@webiny/app": "0.0.0", "@webiny/app-admin": "0.0.0", "@webiny/app-security": "0.0.0", - "@webiny/feature-flags": "0.0.0", "@webiny/form": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/react-router": "0.0.0", diff --git a/packages/app-security-access-management/src/types.ts b/packages/app-security-access-management/src/types.ts index b985c2bf68..52a9b8c107 100644 --- a/packages/app-security-access-management/src/types.ts +++ b/packages/app-security-access-management/src/types.ts @@ -6,6 +6,7 @@ export interface Group { description: string; slug: string; system?: boolean; + plugin: boolean | null; permissions: SecurityPermission[]; createdOn: string; } @@ -16,6 +17,7 @@ export interface Team { description: string; slug: string; system?: boolean; + plugin: boolean | null; createdOn: string; } diff --git a/packages/app-security-access-management/src/ui/views/ApiKeys/ApiKeyForm.tsx b/packages/app-security-access-management/src/ui/views/ApiKeys/ApiKeyForm.tsx index 223ae9ff9d..912ba56228 100644 --- a/packages/app-security-access-management/src/ui/views/ApiKeys/ApiKeyForm.tsx +++ b/packages/app-security-access-management/src/ui/views/ApiKeys/ApiKeyForm.tsx @@ -35,7 +35,6 @@ import { ReactComponent as CopyIcon } from "@material-design-icons/svg/outlined/ import styled from "@emotion/styled"; import { ApiKey } from "~/types"; import { Tooltip } from "@webiny/ui/Tooltip"; -import { featureFlags } from "@webiny/feature-flags"; const t = i18n.ns("app-security-admin-users/admin/api-keys/form"); @@ -212,25 +211,20 @@ export const ApiKeyForm = () => { {t`Permissions`} - {featureFlags.copyPermissionsButton && ( - - } - onClick={() => { - navigator.clipboard.writeText( - JSON.stringify( - data.permissions, - null, - 2 - ) - ); - showSnackbar( - "JSON data copied to clipboard." - ); - }} - /> - - )} + + } + onClick={() => { + navigator.clipboard.writeText( + JSON.stringify(data.permissions, null, 2) + ); + showSnackbar("JSON data copied to clipboard."); + }} + /> + diff --git a/packages/app-security-access-management/src/ui/views/Groups/GroupsDataList.tsx b/packages/app-security-access-management/src/ui/views/Groups/GroupsDataList.tsx index 6d93cff94d..6d4ce515a4 100644 --- a/packages/app-security-access-management/src/ui/views/Groups/GroupsDataList.tsx +++ b/packages/app-security-access-management/src/ui/views/Groups/GroupsDataList.tsx @@ -53,6 +53,7 @@ export interface GroupsDataListProps { // TODO @ts-refactor delete and go up the tree and sort it out [key: string]: any; } + export const GroupsDataList = () => { const [filter, setFilter] = useState(""); const [sort, setSort] = useState(SORTERS[0].sorter); @@ -178,18 +179,24 @@ export const GroupsDataList = () => { - {!item.system ? ( - deleteItem(item)} - data-testid={"default-data-list.delete"} - /> - ) : ( + {item.system || item.plugin ? ( {t`You can't delete this group.`}} + content={ + + {item.system + ? t`Cannot delete system roles.` + : t`Cannot delete roles registered via extensions.`} + + } > + ) : ( + deleteItem(item)} + data-testid={"default-data-list.delete"} + /> )} diff --git a/packages/app-security-access-management/src/ui/views/Groups/GroupsForm.tsx b/packages/app-security-access-management/src/ui/views/Groups/GroupsForm.tsx index 19c983b7d9..68dfbd9d86 100644 --- a/packages/app-security-access-management/src/ui/views/Groups/GroupsForm.tsx +++ b/packages/app-security-access-management/src/ui/views/Groups/GroupsForm.tsx @@ -28,7 +28,6 @@ import EmptyView from "@webiny/app-admin/components/EmptyView"; import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18px.svg"; import { Tooltip } from "@webiny/ui/Tooltip"; import { ReactComponent as CopyIcon } from "@material-design-icons/svg/outlined/content_copy.svg"; -import { featureFlags } from "@webiny/feature-flags"; import { Group } from "~/types"; const t = i18n.ns("app-security/admin/roles/form"); @@ -126,7 +125,9 @@ export const GroupsForm = () => { const data: Group = loading ? {} : get(getQuery, "data.security.group.data", {}); - const systemGroup = data.slug === "full-access"; + const systemGroup = data.slug === "full-access" || data.system; + const pluginGroup = data.plugin; + const canModifyGroup = !systemGroup && !pluginGroup; const showEmptyView = !newGroup && !loading && isEmpty(data); // Render "No content" selected view. @@ -155,6 +156,26 @@ export const GroupsForm = () => { {loading && } + {systemGroup && ( + + + + This is a protected system role and you can't + modify its permissions. + + + + )} + {pluginGroup && ( + + + + This role is registered via an extension, and cannot be + modified. + + + + )} { > @@ -174,7 +195,7 @@ export const GroupsForm = () => { validators={validation.create("required,minLength:3")} > @@ -190,45 +211,30 @@ export const GroupsForm = () => { - {systemGroup && ( - - - - This is a protected system role and you can't - modify its permissions. - - - - )} - {!systemGroup && ( + {canModifyGroup && ( {t`Permissions`} - {featureFlags.copyPermissionsButton && ( - - } - onClick={() => { - navigator.clipboard.writeText( - JSON.stringify( - data.permissions, - null, - 2 - ) - ); - showSnackbar( - "JSON data copied to clipboard." - ); - }} - /> - - )} + + } + onClick={() => { + navigator.clipboard.writeText( + JSON.stringify(data.permissions, null, 2) + ); + showSnackbar("JSON data copied to clipboard."); + }} + /> + @@ -240,7 +246,7 @@ export const GroupsForm = () => { )} - {systemGroup ? null : ( + {canModifyGroup && ( { const [filter, setFilter] = useState(""); const [sort, setSort] = useState(SORTERS[0].sorter); @@ -177,21 +177,25 @@ export const TeamsDataList = () => { - - {!item.system ? ( - deleteItem(item)} - data-testid={"default-data-list.delete"} - /> - ) : ( - {t`You can't delete this team.`}} - > - - - )} - + {item.system || item.plugin ? ( + + {item.system + ? t`Cannot delete system teams.` + : t`Cannot delete teams created via extensions.`} + + } + > + + + ) : ( + deleteItem(item)} + data-testid={"default-data-list.delete"} + /> + )} ))} 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 86f08e26f8..605206e46a 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 @@ -24,6 +24,7 @@ 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 { Team } from "~/types"; +import { Alert } from "@webiny/ui/Alert"; const t = i18n.ns("app-security/admin/teams/form"); @@ -106,6 +107,10 @@ export const TeamsForm = () => { const data = loading ? {} : get(getQuery, "data.security.team.data", {}); + const systemTeam = data.system; + const pluginTeam = data.plugin; + const canModifyTeam = !systemTeam && !pluginTeam; + const showEmptyView = !newTeam && !loading && isEmpty(data); // Render "No content" selected view. if (showEmptyView) { @@ -133,6 +138,26 @@ export const TeamsForm = () => { {loading && } + {systemTeam && ( + + + + This is a protected system team and you can't + modify its permissions. + + + + )} + {pluginTeam && ( + + + + This team is registered via an extension, and cannot be + modified. + + + + )} { validators={validation.create("required,minLength:3")} > @@ -151,7 +177,7 @@ export const TeamsForm = () => { validators={validation.create("required,minLength:3")} > @@ -165,6 +191,7 @@ export const TeamsForm = () => { validators={validation.create("maxLength:500")} > { @@ -183,19 +211,21 @@ export const TeamsForm = () => { - - - history.push("/access-management/teams")} - >{t`Cancel`} - { - form.submit(ev); - }} - >{t`Save team`} - - + {canModifyTeam && ( + + + history.push("/access-management/teams")} + >{t`Cancel`} + { + form.submit(ev); + }} + >{t`Save team`} + + + )} ); }} diff --git a/packages/app-security-access-management/src/ui/views/Teams/graphql.ts b/packages/app-security-access-management/src/ui/views/Teams/graphql.ts index a150f50d61..3cae8997b1 100644 --- a/packages/app-security-access-management/src/ui/views/Teams/graphql.ts +++ b/packages/app-security-access-management/src/ui/views/Teams/graphql.ts @@ -12,6 +12,7 @@ const fields = ` name } system + plugin createdOn `; diff --git a/packages/app-security-access-management/tsconfig.build.json b/packages/app-security-access-management/tsconfig.build.json index 01c5d0dfa4..f14235ad0f 100644 --- a/packages/app-security-access-management/tsconfig.build.json +++ b/packages/app-security-access-management/tsconfig.build.json @@ -5,7 +5,6 @@ { "path": "../app/tsconfig.build.json" }, { "path": "../app-admin/tsconfig.build.json" }, { "path": "../app-security/tsconfig.build.json" }, - { "path": "../feature-flags/tsconfig.build.json" }, { "path": "../form/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, { "path": "../react-router/tsconfig.build.json" }, diff --git a/packages/app-security-access-management/tsconfig.json b/packages/app-security-access-management/tsconfig.json index 6c336f09a1..03fc0bb2e5 100644 --- a/packages/app-security-access-management/tsconfig.json +++ b/packages/app-security-access-management/tsconfig.json @@ -5,7 +5,6 @@ { "path": "../app" }, { "path": "../app-admin" }, { "path": "../app-security" }, - { "path": "../feature-flags" }, { "path": "../form" }, { "path": "../plugins" }, { "path": "../react-router" }, @@ -25,8 +24,6 @@ "@webiny/app-admin": ["../app-admin/src"], "@webiny/app-security/*": ["../app-security/src/*"], "@webiny/app-security": ["../app-security/src"], - "@webiny/feature-flags/*": ["../feature-flags/src/*"], - "@webiny/feature-flags": ["../feature-flags/src"], "@webiny/form/*": ["../form/src/*"], "@webiny/form": ["../form/src"], "@webiny/plugins/*": ["../plugins/src/*"], diff --git a/packages/feature-flags/src/index.ts b/packages/feature-flags/src/index.ts index e7258787f8..6b32375150 100644 --- a/packages/feature-flags/src/index.ts +++ b/packages/feature-flags/src/index.ts @@ -1,5 +1,4 @@ export type FeatureFlags> = { - copyPermissionsButton?: boolean; experimentalAdminOmniSearch?: boolean; allowCmsLegacyRichTextInput?: boolean; newWatchCommand?: boolean; diff --git a/packages/api-aco/__tests__/utils/createTestWcpLicense.ts b/packages/wcp/src/testing/createTestWcpLicense.ts similarity index 98% rename from packages/api-aco/__tests__/utils/createTestWcpLicense.ts rename to packages/wcp/src/testing/createTestWcpLicense.ts index 51eb5f98b2..97bbe7695d 100644 --- a/packages/api-aco/__tests__/utils/createTestWcpLicense.ts +++ b/packages/wcp/src/testing/createTestWcpLicense.ts @@ -2,7 +2,7 @@ import { DecryptedWcpProjectLicense, MT_OPTIONS_MAX_COUNT_TYPE, PROJECT_PACKAGE_FEATURE_NAME -} from "@webiny/wcp/types"; +} from "~/types"; export const createTestWcpLicense = (): DecryptedWcpProjectLicense => { return { diff --git a/webiny.project.ts b/webiny.project.ts index 865fe882f1..bf70fe8b1e 100644 --- a/webiny.project.ts +++ b/webiny.project.ts @@ -53,7 +53,6 @@ export default { }, featureFlags: { - copyPermissionsButton: true, experimentalAdminOmniSearch: true, newWatchCommand: true } diff --git a/yarn.lock b/yarn.lock index 3d0cc30859..345230a9ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16491,7 +16491,6 @@ __metadata: "@webiny/app-admin": 0.0.0 "@webiny/app-security": 0.0.0 "@webiny/cli": 0.0.0 - "@webiny/feature-flags": 0.0.0 "@webiny/form": 0.0.0 "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0