Просмотр исходного кода

fix(server): Fix broken permissions guard implementation, add e2e tests

Moved to the global guard & metadata approach as described in the Nest docs (https://docs.nestjs.com/guards)
Includes the beginnings of e2e tests, so closes #18
Michael Bromley 7 лет назад
Родитель
Сommit
08877e3ec1
36 измененных файлов с 519 добавлено и 177 удалено
  1. 2 2
      README.md
  2. 0 0
      schema.json
  3. 201 0
      server/e2e/auth.e2e-spec.ts
  4. 1 2
      server/e2e/product.e2e-spec.ts
  5. 9 4
      server/e2e/test-client.ts
  6. 1 2
      server/e2e/test-server.ts
  7. 1 5
      server/mock-data/get-default-channel-token.ts
  8. 2 26
      server/mock-data/mock-data.service.ts
  9. 4 5
      server/mock-data/populate.ts
  10. 75 9
      server/mock-data/simple-graphql-client.ts
  11. 2 2
      server/package.json
  12. 6 4
      server/src/api/administrator/administrator.api.graphql
  13. 11 4
      server/src/api/administrator/administrator.resolver.ts
  14. 16 1
      server/src/api/api.module.ts
  15. 10 5
      server/src/api/auth-guard.ts
  16. 2 2
      server/src/api/auth/auth.resolver.ts
  17. 2 2
      server/src/api/channel/channel.resolver.ts
  18. 10 3
      server/src/api/common/request-context.service.ts
  19. 6 6
      server/src/api/customer/customer.resolver.ts
  20. 7 7
      server/src/api/facet/facet.resolver.ts
  21. 6 6
      server/src/api/product-option/product-option.resolver.ts
  22. 10 10
      server/src/api/product/product.resolver.ts
  23. 4 4
      server/src/api/role/role.resolver.ts
  24. 33 48
      server/src/api/roles-guard.ts
  25. 2 0
      server/src/common/constants.ts
  26. 13 0
      server/src/common/utils.ts
  27. 1 0
      server/src/config/config.service.mock.ts
  28. 4 0
      server/src/config/config.service.ts
  29. 7 0
      server/src/config/vendure-config.ts
  30. 2 1
      server/src/entity/customer/customer.entity.ts
  31. 6 6
      server/src/entity/customer/customer.graphql
  32. 52 4
      server/src/service/administrator.service.ts
  33. 2 4
      server/src/service/product-variant.service.ts
  34. 2 2
      server/src/service/product.service.ts
  35. 1 0
      server/src/service/role.service.ts
  36. 6 1
      server/src/service/service.module.ts

+ 2 - 2
README.md

@@ -35,7 +35,7 @@ Vendure uses [TypeORM](http://typeorm.io), so it compatible will any database wh
 
 * `cd admin-ui && yarn`
 * `yarn start`
-* Go to http://localhost:4200 and log in with "admin@test.com", "test"
+* Go to http://localhost:4200 and log in with "superadmin", "superadmin"
 
 ### Code Generation
 
@@ -64,7 +64,7 @@ The tests are run with the following scripts in the `server` directory:
 * `yarn test:e2e` - Run e2e tests once
 * `yarn test:e2e:watch` - Run e2e tests in watch mode
 
-The e2e tests are located in [`/server/e2e`](./server/e2e/). Each test suite (file) has the suffix `.e2e-spec.ts`. The first time the e2e tests are run,
+The e2e tests are located in [`/server/e2e`](./server/e2e). Each test suite (file) has the suffix `.e2e-spec.ts`. The first time the e2e tests are run,
 sqlite files will be generated in the `__data__` directory. These files are used to speed up subsequent runs of the e2e tests. They can be freely deleted
 and will be re-created the next time the e2e tests are run.
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema.json


+ 201 - 0
server/e2e/auth.e2e-spec.ts

@@ -0,0 +1,201 @@
+import { DocumentNode } from 'graphql';
+import gql from 'graphql-tag';
+import {
+    AssignRoleToAdministrator,
+    AssignRoleToAdministratorVariables,
+    AttemptLoginVariables,
+    CreateAdministrator,
+    CreateAdministratorVariables,
+    CreateProductVariables,
+    CreateRole,
+    CreateRoleVariables,
+    GetProductList,
+    UpdateProductVariables,
+} from 'shared/generated-types';
+
+import {
+    ASSIGN_ROLE_TO_ADMINISTRATOR,
+    CREATE_ADMINISTRATOR,
+    CREATE_ROLE,
+} from '../../admin-ui/src/app/data/definitions/administrator-definitions';
+import { ATTEMPT_LOGIN } from '../../admin-ui/src/app/data/definitions/auth-definitions';
+import {
+    CREATE_PRODUCT,
+    GET_PRODUCT_LIST,
+    UPDATE_PRODUCT,
+} from '../../admin-ui/src/app/data/definitions/product-definitions';
+import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '../src/common/constants';
+import { Permission } from '../src/entity/role/permission';
+
+import { TestClient } from './test-client';
+import { TestServer } from './test-server';
+
+describe('Authorization & permissions', () => {
+    const client = new TestClient();
+    const server = new TestServer();
+
+    beforeAll(async () => {
+        const token = await server.init({
+            productCount: 1,
+            customerCount: 1,
+        });
+        await client.init();
+    }, 30000);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('Anonymous user', () => {
+        beforeAll(() => {
+            client.asAnonymousUser();
+        });
+
+        it('can attempt login', async () => {
+            await assertRequestAllowed<AttemptLoginVariables>(ATTEMPT_LOGIN, {
+                username: SUPER_ADMIN_USER_IDENTIFIER,
+                password: SUPER_ADMIN_USER_PASSWORD,
+            });
+        });
+    });
+
+    describe('ReadCatalog', () => {
+        beforeAll(async () => {
+            await client.asSuperAdmin();
+            const { identifier, password } = await createAdministratorWithPermissions('ReadCatalog', [
+                Permission.ReadCatalog,
+            ]);
+            await client.asUserWithCredentials(identifier, password);
+        });
+
+        it('can read', async () => {
+            await assertRequestAllowed(GET_PRODUCT_LIST);
+        });
+
+        it('cannot uppdate', async () => {
+            await assertRequestForbidden<UpdateProductVariables>(UPDATE_PRODUCT, {
+                input: {
+                    id: '1',
+                    translations: [],
+                },
+            });
+        });
+
+        it('cannot create', async () => {
+            await assertRequestForbidden<CreateProductVariables>(CREATE_PRODUCT, {
+                input: {
+                    translations: [],
+                },
+            });
+        });
+    });
+
+    describe('CRUD on Customers', () => {
+        beforeAll(async () => {
+            await client.asSuperAdmin();
+            const { identifier, password } = await createAdministratorWithPermissions('CRUDCustomer', [
+                Permission.CreateCustomer,
+                Permission.ReadCustomer,
+                Permission.UpdateCustomer,
+                Permission.DeleteCustomer,
+            ]);
+            await client.asUserWithCredentials(identifier, password);
+        });
+
+        it('can create', async () => {
+            await assertRequestAllowed(
+                gql`
+                    mutation CreateCustomer($input: CreateCustomerInput!) {
+                        createCustomer(input: $input) {
+                            id
+                        }
+                    }
+                `,
+                { input: { emailAddress: '', firstName: '', lastName: '' } },
+            );
+        });
+
+        it('can read', async () => {
+            await assertRequestAllowed(gql`
+                query {
+                    customers {
+                        totalItems
+                    }
+                }
+            `);
+        });
+    });
+
+    async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
+        try {
+            const status = await client.queryStatus(operation, variables);
+            expect(status).toBe(200);
+        } catch (e) {
+            const status = getErrorStatusCode(e);
+            if (!status) {
+                fail(`Unexpected failure: ${e}`);
+            } else {
+                fail(`Operation should be allowed, got status ${getErrorStatusCode(e)}`);
+            }
+        }
+    }
+
+    async function assertRequestForbidden<V>(operation: DocumentNode, variables: V) {
+        try {
+            const status = await client.queryStatus(operation, variables);
+            fail(`Should have thrown with 403 error, got ${status}`);
+        } catch (e) {
+            expect(getErrorStatusCode(e)).toBe(403);
+        }
+    }
+
+    function getErrorStatusCode(err: any): number {
+        return err.response.errors[0].message.statusCode;
+    }
+
+    async function createAdministratorWithPermissions(
+        code: string,
+        permissions: Permission[],
+    ): Promise<{ identifier: string; password: string }> {
+        const roleResult = await client.query<CreateRole, CreateRoleVariables>(CREATE_ROLE, {
+            input: {
+                code,
+                description: '',
+                permissions,
+            },
+        });
+
+        const role = roleResult.createRole;
+
+        const identifier = `${code}@${Math.random()
+            .toString(16)
+            .substr(2, 8)}`;
+        const password = `test`;
+
+        const adminResult = await client.query<CreateAdministrator, CreateAdministratorVariables>(
+            CREATE_ADMINISTRATOR,
+            {
+                input: {
+                    emailAddress: identifier,
+                    firstName: code,
+                    lastName: 'Admin',
+                    password,
+                },
+            },
+        );
+        const admin = adminResult.createAdministrator;
+
+        await client.query<AssignRoleToAdministrator, AssignRoleToAdministratorVariables>(
+            ASSIGN_ROLE_TO_ADMINISTRATOR,
+            {
+                administratorId: admin.id,
+                roleId: role.id,
+            },
+        );
+
+        return {
+            identifier,
+            password,
+        };
+    }
+});

+ 1 - 2
server/e2e/product.e2e-spec.ts

@@ -38,14 +38,13 @@ import {
 
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
-
+// tslint:disable:quotemark
 describe('Product resolver', () => {
     const client = new TestClient();
     const server = new TestServer();
 
     beforeAll(async () => {
         const token = await server.init({
-            disableAuth: true,
             productCount: 20,
             customerCount: 1,
         });

+ 9 - 4
server/e2e/test-client.ts

@@ -1,19 +1,24 @@
+import { AttemptLogin, AttemptLoginVariables } from 'shared/generated-types';
+
+import { ATTEMPT_LOGIN } from '../../admin-ui/src/app/data/definitions/auth-definitions';
 import { getDefaultChannelToken } from '../mock-data/get-default-channel-token';
 import { SimpleGraphQLClient } from '../mock-data/simple-graphql-client';
+import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '../src/common/constants';
 
 import { testConfig } from './config/test-config';
 
+// tslint:disable:no-console
 /**
  * A GraphQL client for use in e2e tests configured to use the test server endpoint.
  */
 export class TestClient extends SimpleGraphQLClient {
     constructor() {
-        super();
+        super(`http://localhost:${testConfig.port}/${testConfig.apiPath}`);
     }
 
     async init() {
-        const testingConfig = testConfig;
-        const token = await getDefaultChannelToken(testingConfig.dbConnectionOptions);
-        super.apiUrl = `http://localhost:${testConfig.port}/${testConfig.apiPath}?token=${token}`;
+        const token = await getDefaultChannelToken();
+        this.setChannelToken(token);
+        await this.asSuperAdmin();
     }
 }

+ 1 - 2
server/e2e/test-server.ts

@@ -26,11 +26,10 @@ export class TestServer {
      * The populated data is saved into an .sqlite file for each test file. On subsequent runs, this file
      * is loaded so that the populate step can be skipped, which speeds up the tests significantly.
      */
-    async init(options: PopulateOptions & { disableAuth?: boolean }): Promise<void> {
+    async init(options: PopulateOptions): Promise<void> {
         setTestEnvironment();
         const testingConfig = testConfig;
         const dbFilePath = this.getDbFilePath();
-        testingConfig.disableAuth = options.disableAuth;
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).location = dbFilePath;
         if (!fs.existsSync(dbFilePath)) {
             console.log(`Test data not found. Populating database and saving to "${dbFilePath}"`);

+ 1 - 5
server/mock-data/get-default-channel-token.ts

@@ -8,11 +8,7 @@ import { Channel } from '../src/entity/channel/channel.entity';
 /**
  * Queries the database for the default Channel and returns its token.
  */
-export async function getDefaultChannelToken(
-    connectionOptions: ConnectionOptions,
-    logging = true,
-): Promise<string> {
-    (connectionOptions as any).entities = [__dirname + '/../src/**/*.entity.ts'];
+export async function getDefaultChannelToken(logging = true): Promise<string> {
     const connection = await getConnection();
     let defaultChannel: Channel | undefined;
     try {

+ 2 - 26
server/mock-data/mock-data.service.ts

@@ -29,7 +29,7 @@ import { Channel } from '../src/entity/channel/channel.entity';
 import { CreateCustomerDto } from '../src/entity/customer/customer.dto';
 import { Customer } from '../src/entity/customer/customer.entity';
 
-import { GraphQlClient } from './simple-graphql-client';
+import { SimpleGraphQLClient } from './simple-graphql-client';
 
 // tslint:disable:no-console
 /**
@@ -38,7 +38,7 @@ import { GraphQlClient } from './simple-graphql-client';
 export class MockDataService {
     apiUrl: string;
 
-    constructor(private client: GraphQlClient, private logging = true) {
+    constructor(private client: SimpleGraphQLClient, private logging = true) {
         // make the generated results deterministic
         faker.seed(1);
     }
@@ -94,30 +94,6 @@ export class MockDataService {
             );
     }
 
-    async populateAdmins(): Promise<any> {
-        const query = gql`
-            mutation($input: CreateAdministratorInput!) {
-                createAdministrator(input: $input) {
-                    id
-                    emailAddress
-                }
-            }
-        `;
-
-        const variables = {
-            input: {
-                firstName: 'Super',
-                lastName: 'Admin',
-                emailAddress: 'admin@test.com',
-                password: 'test',
-            } as CreateAdministratorDto,
-        };
-
-        await this.client
-            .query(query, variables)
-            .then(data => this.log('Created Administrator:', data), err => this.log(err));
-    }
-
     async populateCustomers(count: number = 5): Promise<any> {
         for (let i = 0; i < count; i++) {
             const firstName = faker.name.firstName();

+ 4 - 5
server/mock-data/populate.ts

@@ -30,10 +30,10 @@ export async function populate(
     setConfig(config);
     await clearAllTables(config.dbConnectionOptions, logging);
     const app = await bootstrapFn(config);
-    const defaultChannelToken = await getDefaultChannelToken(config.dbConnectionOptions, logging);
-    const client = new SimpleGraphQLClient(
-        `http://localhost:${config.port}/${config.apiPath}?token=${defaultChannelToken}`,
-    );
+    const defaultChannelToken = await getDefaultChannelToken(logging);
+    const client = new SimpleGraphQLClient(`http://localhost:${config.port}/${config.apiPath}`);
+    client.setChannelToken(defaultChannelToken);
+    await client.asSuperAdmin();
     const mockDataService = new MockDataService(client, logging);
     let channels: Channel[] = [];
     if (options.channels) {
@@ -43,6 +43,5 @@ export async function populate(
     await mockDataService.populateProducts(options.productCount);
     await mockDataService.populateCustomers(options.customerCount);
     await mockDataService.populateFacets();
-    await mockDataService.populateAdmins();
     return app;
 }

+ 75 - 9
server/mock-data/simple-graphql-client.ts

@@ -1,19 +1,85 @@
 import { DocumentNode } from 'graphql';
-import { request } from 'graphql-request';
+import { GraphQLClient } from 'graphql-request';
+import { GraphQLError } from 'graphql-request/dist/src/types';
 import { print } from 'graphql/language/printer';
+import { AttemptLogin, AttemptLoginVariables } from 'shared/generated-types';
 
-export interface GraphQlClient {
-    query<T, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<T>;
-}
+import { ATTEMPT_LOGIN } from '../../admin-ui/src/app/data/definitions/auth-definitions';
+import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '../src/common/constants';
+import { getConfig } from '../src/config/vendure-config';
 
+// tslint:disable:no-console
 /**
- * A minimalistic GraphQL client for populating test data.
+ * A minimalistic GraphQL client for populating and querying test data.
  */
-export class SimpleGraphQLClient implements GraphQlClient {
-    constructor(public apiUrl: string = '') {}
+export class SimpleGraphQLClient {
+    private client: GraphQLClient;
+    private authToken: string;
+    private channelToken: string;
+
+    constructor(apiUrl: string = '') {
+        this.client = new GraphQLClient(apiUrl);
+    }
 
-    query<T, V = Record<string, any>>(query: DocumentNode, variables: V): Promise<T> {
+    setAuthToken(token: string) {
+        this.authToken = token;
+        this.setHeaders();
+    }
+
+    setChannelToken(token: string) {
+        this.channelToken = token;
+        this.setHeaders();
+    }
+
+    query<T = any, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<T> {
         const queryString = print(query);
-        return request(this.apiUrl, queryString, variables);
+        return this.client.request(queryString, variables);
+    }
+
+    queryRaw<T = any, V = Record<string, any>>(
+        query: DocumentNode,
+        variables?: V,
+    ): Promise<{
+        data?: T;
+        extensions?: any;
+        headers: Record<string, string>;
+        status: number;
+        errors?: GraphQLError[];
+    }> {
+        const queryString = print(query);
+        return this.client.rawRequest<T>(queryString, variables);
+    }
+
+    async queryStatus<T = any, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<number> {
+        const queryString = print(query);
+        const result = await this.client.rawRequest<T>(queryString, variables);
+        return result.status;
+    }
+
+    async asUserWithCredentials(username: string, password: string) {
+        const result = await this.query<AttemptLogin, AttemptLoginVariables>(ATTEMPT_LOGIN, {
+            username,
+            password,
+        });
+        if (result.login) {
+            this.setAuthToken(result.login.authToken);
+        } else {
+            console.error(result);
+        }
+    }
+
+    async asSuperAdmin() {
+        await this.asUserWithCredentials(SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD);
+    }
+
+    asAnonymousUser() {
+        this.setAuthToken('');
+    }
+
+    private setHeaders() {
+        this.client.setHeaders({
+            Authorization: `Bearer ${this.authToken}`,
+            [getConfig().channelTokenKey]: this.channelToken,
+        });
     }
 }

+ 2 - 2
server/package.json

@@ -11,8 +11,8 @@
     "test": "jest",
     "test:watch": "jest --watch",
     "test:cov": "jest --coverage",
-    "test:e2e": "jest --config ./e2e/config/jest-e2e.json",
-    "test:e2e:watch": "jest --config ./e2e/config/jest-e2e.json --watch",
+    "test:e2e": "jest --config ./e2e/config/jest-e2e.json --runInBand",
+    "test:e2e:watch": "jest --config ./e2e/config/jest-e2e.json --watch --runInBand",
     "build": "rimraf dist && tsc -p tsconfig.build.json && gulp"
   },
   "main": "dist/index.js",

+ 6 - 4
server/src/api/administrator/administrator.api.graphql

@@ -1,11 +1,13 @@
 type Query {
-  administrators(options: AdministratorListOptions): AdministratorList!
-  administrator(id: ID!): Administrator
+    administrators(options: AdministratorListOptions): AdministratorList!
+    administrator(id: ID!): Administrator
 }
 
 type Mutation {
-  "Create a new Administrator"
-  createAdministrator(input: CreateAdministratorInput!): Administrator!
+    "Create a new Administrator"
+    createAdministrator(input: CreateAdministratorInput!): Administrator!
+    "Assign a Role to an Administrator"
+    assignRoleToAdministrator(administratorId: ID!, roleId: ID!): Administrator!
 }
 
 type AdministratorList implements PaginatedList {

+ 11 - 4
server/src/api/administrator/administrator.resolver.ts

@@ -5,31 +5,38 @@ import { Administrator } from '../../entity/administrator/administrator.entity';
 import { Permission } from '../../entity/role/permission';
 import { AdministratorService } from '../../service/administrator.service';
 import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
-import { RolesGuard } from '../roles-guard';
+import { Allow } from '../roles-guard';
 
 @Resolver('Administrator')
 export class AdministratorResolver {
     constructor(private administratorService: AdministratorService) {}
 
     @Query()
-    @RolesGuard([Permission.ReadAdministrator])
+    @Allow(Permission.ReadAdministrator)
     @ApplyIdCodec()
     administrators(@Args() args: any): Promise<PaginatedList<Administrator>> {
         return this.administratorService.findAll(args.options);
     }
 
     @Query()
-    @RolesGuard([Permission.ReadAdministrator])
+    @Allow(Permission.ReadAdministrator)
     @ApplyIdCodec()
     administrator(@Args() args: any): Promise<Administrator | undefined> {
         return this.administratorService.findOne(args.id);
     }
 
     @Mutation()
-    @RolesGuard([Permission.CreateAdministrator])
+    @Allow(Permission.CreateAdministrator)
     @ApplyIdCodec()
     createAdministrator(_, args): Promise<Administrator> {
         const { input } = args;
         return this.administratorService.create(input);
     }
+
+    @Mutation()
+    @Allow(Permission.UpdateAdministrator)
+    @ApplyIdCodec()
+    assignRoleToAdministrator(@Args() args): Promise<Administrator> {
+        return this.administratorService.assignRole(args.administratorId, args.roleId);
+    }
 }

+ 16 - 1
server/src/api/api.module.ts

@@ -1,4 +1,5 @@
 import { Module } from '@nestjs/common';
+import { APP_GUARD } from '@nestjs/core';
 import { GraphQLModule } from '@nestjs/graphql';
 
 import { ConfigModule } from '../config/config.module';
@@ -6,6 +7,7 @@ import { I18nModule } from '../i18n/i18n.module';
 import { ServiceModule } from '../service/service.module';
 
 import { AdministratorResolver } from './administrator/administrator.resolver';
+import { AuthGuard } from './auth-guard';
 import { AuthResolver } from './auth/auth.resolver';
 import { ChannelResolver } from './channel/channel.resolver';
 import { RequestContextService } from './common/request-context.service';
@@ -17,6 +19,7 @@ import { JwtStrategy } from './jwt.strategy';
 import { ProductOptionResolver } from './product-option/product-option.resolver';
 import { ProductResolver } from './product/product.resolver';
 import { RoleResolver } from './role/role.resolver';
+import { RolesGuard } from './roles-guard';
 
 const exportedProviders = [
     AdministratorResolver,
@@ -43,7 +46,19 @@ const exportedProviders = [
             imports: [ConfigModule, I18nModule],
         }),
     ],
-    providers: [...exportedProviders, JwtStrategy, RequestContextService],
+    providers: [
+        ...exportedProviders,
+        JwtStrategy,
+        RequestContextService,
+        {
+            provide: APP_GUARD,
+            useClass: AuthGuard,
+        },
+        {
+            provide: APP_GUARD,
+            useClass: RolesGuard,
+        },
+    ],
     exports: exportedProviders,
 })
 export class ApiModule {}

+ 10 - 5
server/src/api/auth-guard.ts

@@ -1,14 +1,14 @@
 import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
 import { GqlExecutionContext } from '@nestjs/graphql';
-import * as passport from 'passport';
 import { ExtractJwt, Strategy } from 'passport-jwt';
-import { Observable } from 'rxjs';
 
 import { JwtPayload } from '../common/types/auth-types';
 import { ConfigService } from '../config/config.service';
-import { User } from '../entity/user/user.entity';
 import { AuthService } from '../service/auth.service';
 
+import { PERMISSIONS_METADATA_KEY } from './roles-guard';
+
 /**
  * A guard which uses passport.js & the passport-jwt strategy to authenticate incoming GraphQL requests.
  * At this time, the Nest AuthGuard is not working with Apollo Server 2, see https://github.com/nestjs/graphql/issues/48
@@ -19,7 +19,11 @@ import { AuthService } from '../service/auth.service';
 export class AuthGuard implements CanActivate {
     strategy: any;
 
-    constructor(private configService: ConfigService, private authService: AuthService) {
+    constructor(
+        private reflector: Reflector,
+        private configService: ConfigService,
+        private authService: AuthService,
+    ) {
         this.strategy = new Strategy(
             {
                 secretOrKey: configService.jwtSecret,
@@ -34,7 +38,8 @@ export class AuthGuard implements CanActivate {
     }
 
     async canActivate(context: ExecutionContext): Promise<boolean> {
-        if (this.configService.disableAuth) {
+        const permissions = this.reflector.get<string[]>(PERMISSIONS_METADATA_KEY, context.getHandler());
+        if (this.configService.disableAuth || !permissions) {
             return true;
         }
         const ctx = GqlExecutionContext.create(context).getContext();

+ 2 - 2
server/src/api/auth/auth.resolver.ts

@@ -4,7 +4,7 @@ import { Permission } from '../../entity/role/permission';
 import { User } from '../../entity/user/user.entity';
 import { AuthService } from '../../service/auth.service';
 import { ChannelService } from '../../service/channel.service';
-import { RolesGuard } from '../roles-guard';
+import { Allow } from '../roles-guard';
 
 @Resolver('Auth')
 export class AuthResolver {
@@ -30,7 +30,7 @@ export class AuthResolver {
      * Returns information about the current authenticated user.
      */
     @Query()
-    @RolesGuard([Permission.Authenticated])
+    @Allow(Permission.Authenticated)
     async me(@Context('req') request: any) {
         const user = await this.authService.validateUser(request.user.identifier);
         return user ? this.publiclyAccessibleUser(user) : null;

+ 2 - 2
server/src/api/channel/channel.resolver.ts

@@ -3,14 +3,14 @@ import { Args, Mutation, Resolver } from '@nestjs/graphql';
 import { Channel } from '../../entity/channel/channel.entity';
 import { Permission } from '../../entity/role/permission';
 import { ChannelService } from '../../service/channel.service';
-import { RolesGuard } from '../roles-guard';
+import { Allow } from '../roles-guard';
 
 @Resolver('Channel')
 export class ChannelResolver {
     constructor(private channelService: ChannelService) {}
 
     @Mutation()
-    @RolesGuard([Permission.SuperAdmin])
+    @Allow(Permission.SuperAdmin)
     createChannel(@Args() args): Promise<Channel> {
         return this.channelService.create(args.code);
     }

+ 10 - 3
server/src/api/common/request-context.service.ts

@@ -1,5 +1,6 @@
 import { Injectable } from '@nestjs/common';
 
+import { ConfigService } from '../../config/config.service';
 import { I18nError } from '../../i18n/i18n-error';
 import { ChannelService } from '../../service/channel.service';
 
@@ -10,14 +11,20 @@ import { RequestContext } from './request-context';
  */
 @Injectable()
 export class RequestContextService {
-    constructor(private channelService: ChannelService) {}
+    constructor(private channelService: ChannelService, private configService: ConfigService) {}
 
     /**
      * Creates a new RequestContext based on an Express request object.
      */
     fromRequest(req: any): RequestContext {
-        if (req && req.query) {
-            const token = req.query.token;
+        const tokenKey = this.configService.channelTokenKey;
+        let token = '';
+        if (req && req.query && req.query[tokenKey]) {
+            token = req.query[tokenKey];
+        } else if (req && req.headers && req.headers[tokenKey]) {
+            token = req.headers[tokenKey];
+        }
+        if (token) {
             const channel = this.channelService.getChannelFromToken(token);
             return new RequestContext(channel);
         }

+ 6 - 6
server/src/api/customer/customer.resolver.ts

@@ -6,35 +6,35 @@ import { Customer } from '../../entity/customer/customer.entity';
 import { Permission } from '../../entity/role/permission';
 import { CustomerService } from '../../service/customer.service';
 import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
-import { RolesGuard } from '../roles-guard';
+import { Allow } from '../roles-guard';
 
 @Resolver('Customer')
 export class CustomerResolver {
     constructor(private customerService: CustomerService) {}
 
     @Query()
-    @RolesGuard([Permission.ReadCustomer])
+    @Allow(Permission.ReadCustomer)
     @ApplyIdCodec()
     async customers(obj, args): Promise<PaginatedList<Customer>> {
         return this.customerService.findAll(args.options);
     }
 
     @Query()
-    @RolesGuard([Permission.ReadCustomer])
+    @Allow(Permission.ReadCustomer)
     @ApplyIdCodec()
     async customer(obj, args): Promise<Customer | undefined> {
         return this.customerService.findOne(args.id);
     }
 
     @ResolveProperty()
-    @RolesGuard([Permission.ReadCustomer])
+    @Allow(Permission.ReadCustomer)
     @ApplyIdCodec()
     async addresses(customer: Customer): Promise<Address[]> {
         return this.customerService.findAddressesByCustomerId(customer.id);
     }
 
     @Mutation()
-    @RolesGuard([Permission.CreateCustomer])
+    @Allow(Permission.CreateCustomer)
     @ApplyIdCodec()
     async createCustomer(_, args): Promise<Customer> {
         const { input, password } = args;
@@ -42,7 +42,7 @@ export class CustomerResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.CreateCustomer])
+    @Allow(Permission.CreateCustomer)
     @ApplyIdCodec()
     async createCustomerAddress(_, args): Promise<Address> {
         const { customerId, input } = args;

+ 7 - 7
server/src/api/facet/facet.resolver.ts

@@ -16,28 +16,28 @@ import { I18nError } from '../../i18n/i18n-error';
 import { FacetValueService } from '../../service/facet-value.service';
 import { FacetService } from '../../service/facet.service';
 import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
-import { RolesGuard } from '../roles-guard';
+import { Allow } from '../roles-guard';
 
 @Resolver('Facet')
 export class FacetResolver {
     constructor(private facetService: FacetService, private facetValueService: FacetValueService) {}
 
     @Query()
-    @RolesGuard([Permission.ReadCatalog])
+    @Allow(Permission.ReadCatalog)
     @ApplyIdCodec()
     facets(obj, args): Promise<PaginatedList<Translated<Facet>>> {
         return this.facetService.findAll(args.languageCode, args.options);
     }
 
     @Query()
-    @RolesGuard([Permission.ReadCatalog])
+    @Allow(Permission.ReadCatalog)
     @ApplyIdCodec()
     async facet(obj, args): Promise<Translated<Facet> | undefined> {
         return this.facetService.findOne(args.id, args.languageCode);
     }
 
     @Mutation()
-    @RolesGuard([Permission.CreateCatalog])
+    @Allow(Permission.CreateCatalog)
     @ApplyIdCodec()
     async createFacet(_, args: CreateFacetVariables): Promise<Translated<Facet>> {
         const { input } = args;
@@ -53,7 +53,7 @@ export class FacetResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.UpdateCatalog])
+    @Allow(Permission.UpdateCatalog)
     @ApplyIdCodec()
     async updateFacet(_, args: UpdateFacetVariables): Promise<Translated<Facet>> {
         const { input } = args;
@@ -61,7 +61,7 @@ export class FacetResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.CreateCatalog])
+    @Allow(Permission.CreateCatalog)
     @ApplyIdCodec()
     async createFacetValues(_, args: CreateFacetValuesVariables): Promise<Array<Translated<FacetValue>>> {
         const { input } = args;
@@ -74,7 +74,7 @@ export class FacetResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.UpdateCatalog])
+    @Allow(Permission.UpdateCatalog)
     @ApplyIdCodec()
     async updateFacetValues(_, args: UpdateFacetValuesVariables): Promise<Array<Translated<FacetValue>>> {
         const { input } = args;

+ 6 - 6
server/src/api/product-option/product-option.resolver.ts

@@ -8,7 +8,7 @@ import { Permission } from '../../entity/role/permission';
 import { ProductOptionGroupService } from '../../service/product-option-group.service';
 import { ProductOptionService } from '../../service/product-option.service';
 import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
-import { RolesGuard } from '../roles-guard';
+import { Allow } from '../roles-guard';
 
 @Resolver('ProductOptionGroup')
 export class ProductOptionResolver {
@@ -18,21 +18,21 @@ export class ProductOptionResolver {
     ) {}
 
     @Query()
-    @RolesGuard([Permission.ReadCatalog])
+    @Allow(Permission.ReadCatalog)
     @ApplyIdCodec()
     productOptionGroups(obj, args): Promise<Array<Translated<ProductOptionGroup>>> {
         return this.productOptionGroupService.findAll(args.languageCode, args.filterTerm);
     }
 
     @Query()
-    @RolesGuard([Permission.ReadCatalog])
+    @Allow(Permission.ReadCatalog)
     @ApplyIdCodec()
     productOptionGroup(obj, args): Promise<Translated<ProductOptionGroup> | undefined> {
         return this.productOptionGroupService.findOne(args.id, args.languageCode);
     }
 
     @ResolveProperty()
-    @RolesGuard([Permission.ReadCatalog])
+    @Allow(Permission.ReadCatalog)
     @ApplyIdCodec()
     async options(optionGroup: Translated<ProductOptionGroup>): Promise<Array<Translated<ProductOption>>> {
         if (optionGroup.options) {
@@ -43,7 +43,7 @@ export class ProductOptionResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.CreateCatalog])
+    @Allow(Permission.CreateCatalog)
     @ApplyIdCodec()
     async createProductOptionGroup(
         _,
@@ -62,7 +62,7 @@ export class ProductOptionResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.UpdateCatalog])
+    @Allow(Permission.UpdateCatalog)
     @ApplyIdCodec()
     async updateProductOptionGroup(_, args): Promise<Translated<ProductOptionGroup>> {
         const { input } = args;

+ 10 - 10
server/src/api/product/product.resolver.ts

@@ -25,7 +25,7 @@ import { ProductService } from '../../service/product.service';
 import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
 import { RequestContext } from '../common/request-context';
 import { RequestContextPipe } from '../common/request-context.pipe';
-import { RolesGuard } from '../roles-guard';
+import { Allow } from '../roles-guard';
 
 @Resolver('Product')
 export class ProductResolver {
@@ -36,7 +36,7 @@ export class ProductResolver {
     ) {}
 
     @Query()
-    @RolesGuard([Permission.ReadCatalog])
+    @Allow(Permission.ReadCatalog)
     @ApplyIdCodec()
     async products(
         @Context(RequestContextPipe) ctx: RequestContext,
@@ -47,7 +47,7 @@ export class ProductResolver {
     }
 
     @Query()
-    @RolesGuard([Permission.ReadCatalog])
+    @Allow(Permission.ReadCatalog)
     @ApplyIdCodec()
     async product(
         @Context(RequestContextPipe) ctx: RequestContext,
@@ -58,7 +58,7 @@ export class ProductResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.CreateCatalog])
+    @Allow(Permission.CreateCatalog)
     @ApplyIdCodec()
     async createProduct(
         @Context(RequestContextPipe) ctx: RequestContext,
@@ -69,7 +69,7 @@ export class ProductResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.UpdateCatalog])
+    @Allow(Permission.UpdateCatalog)
     @ApplyIdCodec()
     async updateProduct(
         @Context(RequestContextPipe) ctx: RequestContext,
@@ -80,7 +80,7 @@ export class ProductResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.UpdateCatalog])
+    @Allow(Permission.UpdateCatalog)
     @ApplyIdCodec(['productId', 'optionGroupId'])
     async addOptionGroupToProduct(
         @Context(RequestContextPipe) ctx: RequestContext,
@@ -91,7 +91,7 @@ export class ProductResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.UpdateCatalog])
+    @Allow(Permission.UpdateCatalog)
     @ApplyIdCodec(['productId', 'optionGroupId'])
     async removeOptionGroupFromProduct(
         @Context(RequestContextPipe) ctx: RequestContext,
@@ -102,7 +102,7 @@ export class ProductResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.CreateCatalog])
+    @Allow(Permission.CreateCatalog)
     @ApplyIdCodec()
     async generateVariantsForProduct(
         @Context(RequestContextPipe) ctx: RequestContext,
@@ -114,7 +114,7 @@ export class ProductResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.UpdateCatalog])
+    @Allow(Permission.UpdateCatalog)
     @ApplyIdCodec()
     async updateProductVariants(
         @Context(RequestContextPipe) ctx: RequestContext,
@@ -125,7 +125,7 @@ export class ProductResolver {
     }
 
     @Mutation()
-    @RolesGuard([Permission.UpdateCatalog])
+    @Allow(Permission.UpdateCatalog)
     @ApplyIdCodec()
     async applyFacetValuesToProductVariants(
         @Context(RequestContextPipe) ctx: RequestContext,

+ 4 - 4
server/src/api/role/role.resolver.ts

@@ -5,28 +5,28 @@ import { Permission } from '../../entity/role/permission';
 import { Role } from '../../entity/role/role.entity';
 import { RoleService } from '../../service/role.service';
 import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
-import { RolesGuard } from '../roles-guard';
+import { Allow } from '../roles-guard';
 
 @Resolver('Roles')
 export class RoleResolver {
     constructor(private roleService: RoleService) {}
 
     @Query()
-    @RolesGuard([Permission.ReadAdministrator])
+    @Allow(Permission.ReadAdministrator)
     @ApplyIdCodec()
     roles(@Args() args: any): Promise<PaginatedList<Role>> {
         return this.roleService.findAll(args.options);
     }
 
     @Query()
-    @RolesGuard([Permission.ReadAdministrator])
+    @Allow(Permission.ReadAdministrator)
     @ApplyIdCodec()
     role(@Args() args: any): Promise<Role | undefined> {
         return this.roleService.findOne(args.id);
     }
 
     @Mutation()
-    @RolesGuard([Permission.CreateAdministrator])
+    @Allow(Permission.CreateAdministrator)
     @ApplyIdCodec()
     createRole(_, args): Promise<Role> {
         const { input } = args;

+ 33 - 48
server/src/api/roles-guard.ts

@@ -1,70 +1,55 @@
-import { CanActivate, ExecutionContext, Injectable, UseGuards } from '@nestjs/common';
+import { ExecutionContext, Injectable, ReflectMetadata } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
 import { ExtractJwt, Strategy } from 'passport-jwt';
 
+import { idsAreEqual } from '../common/utils';
 import { ConfigService } from '../config/config.service';
 import { Permission } from '../entity/role/permission';
 import { User } from '../entity/user/user.entity';
 
-import { AuthGuard } from './auth-guard';
 import { RequestContextService } from './common/request-context.service';
 
+export const PERMISSIONS_METADATA_KEY = '__permissions__';
+
 /**
- * A guard which combines the JWT passport auth method with restrictions based on
- * the authenticated user's roles.
+ * Attatches metadata to the resolver defining which permissions are required to execute the
+ * operation.
  *
  * @example
  * ```
- *  @RolesGuard([Permission.SuperAdmin])
- *  @Query('administrators')
+ *  @Allow(Permission.SuperAdmin)
+ *  @Query()
  *  getAdministrators() {
  *      // ...
  *  }
  * ```
  */
-export function RolesGuard(permissions: Permission[]) {
-    // tslint:disable-next-line:ban-types
-    const guards: Array<CanActivate | Function> = [AuthGuard];
-
-    if (permissions.length && !authenticatedOnly(permissions)) {
-        guards.push(forPermissions(permissions));
-    }
-
-    return UseGuards(...guards);
-}
-
-function authenticatedOnly(permissions: Permission[]): boolean {
-    return permissions.length === 1 && permissions[0] === Permission.Authenticated;
-}
-
-/**
- * A guard which specifies which permissions are authorized to access a given
- * route or property in a Resolver.
- */
-function forPermissions(permissions: Permission[]) {
-    @Injectable()
-    class RoleGuard implements CanActivate {
-        constructor(
-            private requestContextService: RequestContextService,
-            private configService: ConfigService,
-        ) {}
-
-        canActivate(context: ExecutionContext) {
-            if (this.configService.disableAuth) {
-                return true;
-            }
-            const req = context.getArgByIndex(2).req;
-            const ctx = this.requestContextService.fromRequest(req);
-            const user: User = req.user;
-            if (!user) {
-                return false;
-            }
-            const permissionsOnChannel = user.roles
-                .filter(role => role.channels.find(c => c.id === ctx.channel.id))
-                .reduce((output, role) => [...output, ...role.permissions], [] as Permission[]);
-            return arraysIntersect(permissions, permissionsOnChannel);
+export const Allow = (...permissions: Permission[]) => ReflectMetadata(PERMISSIONS_METADATA_KEY, permissions);
+
+@Injectable()
+export class RolesGuard {
+    constructor(
+        private readonly reflector: Reflector,
+        private requestContextService: RequestContextService,
+        private configService: ConfigService,
+    ) {}
+
+    canActivate(context: ExecutionContext): boolean {
+        const permissions = this.reflector.get<string[]>(PERMISSIONS_METADATA_KEY, context.getHandler());
+        if (this.configService.disableAuth || !permissions) {
+            return true;
+        }
+        const req = context.getArgByIndex(2).req;
+        const ctx = this.requestContextService.fromRequest(req);
+        const user: User = req.user;
+        if (!user) {
+            return false;
         }
+        const permissionsOnChannel = user.roles
+            .filter(role => role.channels.find(c => idsAreEqual(c.id, ctx.channel.id)))
+            .reduce((output, role) => [...output, ...role.permissions], [] as Permission[]);
+        return arraysIntersect(permissions, permissionsOnChannel);
     }
-    return RoleGuard;
 }
 
 /**

+ 2 - 0
server/src/common/constants.ts

@@ -4,5 +4,7 @@ export const DEFAULT_LANGUAGE_CODE = LanguageCode.en;
 export const DEFAULT_CHANNEL_CODE = '__default_channel__';
 export const SUPER_ADMIN_ROLE_CODE = '__super_admin_role__';
 export const SUPER_ADMIN_ROLE_DESCRIPTION = 'SuperAdmin';
+export const SUPER_ADMIN_USER_IDENTIFIER = 'superadmin';
+export const SUPER_ADMIN_USER_PASSWORD = 'superadmin';
 export const CUSTOMER_ROLE_CODE = '__customer_role__';
 export const CUSTOMER_ROLE_DESCRIPTION = 'Customer';

+ 13 - 0
server/src/common/utils.ts

@@ -1,3 +1,5 @@
+import { ID } from 'shared/shared-types';
+
 /**
  * Takes a predicate function and returns a negated version.
  */
@@ -22,3 +24,14 @@ export function foundIn<T>(set: T[], compareBy: keyof T) {
 export function assertFound<T>(promise: Promise<T | undefined>): Promise<T> {
     return promise as Promise<T>;
 }
+
+/**
+ * Compare ID values for equality, taking into account the fact that they may not be of matching types
+ * (string or number).
+ */
+export function idsAreEqual(id1?: ID, id2?: ID): boolean {
+    if (id1 === undefined || id2 === undefined) {
+        return false;
+    }
+    return id1.toString() === id2.toString();
+}

+ 1 - 0
server/src/config/config.service.mock.ts

@@ -6,6 +6,7 @@ import { EntityIdStrategy, PrimaryKeyType } from './entity-id-strategy/entity-id
 
 export class MockConfigService implements MockClass<ConfigService> {
     disableAuth = false;
+    channelTokenKey: 'vendure-token';
     apiPath = 'api';
     port = 3000;
     cors = false;

+ 4 - 0
server/src/config/config.service.ts

@@ -14,6 +14,10 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.disableAuth;
     }
 
+    get channelTokenKey(): string {
+        return this.activeConfig.channelTokenKey;
+    }
+
     get defaultLanguageCode(): LanguageCode {
         return this.activeConfig.defaultLanguageCode;
     }

+ 7 - 0
server/src/config/vendure-config.ts

@@ -17,6 +17,12 @@ export interface VendureConfig {
      * only to aid the ease of development.
      */
     disableAuth?: boolean;
+    /**
+     * The name of the property which contains the token of the
+     * active channel. This property can be included either in
+     * the request header or as a query string.
+     */
+    channelTokenKey?: string;
     /**
      * The default languageCode of the app.
      */
@@ -60,6 +66,7 @@ export interface VendureConfig {
 
 const defaultConfig: ReadOnlyRequired<VendureConfig> = {
     disableAuth: false,
+    channelTokenKey: 'vendure-token',
     defaultLanguageCode: LanguageCode.en,
     port: API_PORT,
     cors: false,

+ 2 - 1
server/src/entity/customer/customer.entity.ts

@@ -17,7 +17,8 @@ export class Customer extends VendureEntity implements HasCustomFields {
 
     @Column() lastName: string;
 
-    @Column() phoneNumber: string;
+    @Column({ nullable: true })
+    phoneNumber: string;
 
     @Column({ unique: true })
     emailAddress: string;

+ 6 - 6
server/src/entity/customer/customer.graphql

@@ -2,17 +2,17 @@ type Customer implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
-    firstName: String
-    lastName: String
+    firstName: String!
+    lastName: String!
     phoneNumber: String
-    emailAddress: String
-    addresses: [Address]
+    emailAddress: String!
+    addresses: [Address!]
     user: User
 }
 
 input CreateCustomerInput {
-    firstName: String
-    lastName: String
+    firstName: String!
+    lastName: String!
     phoneNumber: String
     emailAddress: String!
 }

+ 52 - 4
server/src/service/administrator.service.ts

@@ -3,10 +3,12 @@ import { InjectConnection } from '@nestjs/typeorm';
 import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
+import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '../common/constants';
 import { ListQueryOptions } from '../common/types/common-types';
 import { CreateAdministratorDto } from '../entity/administrator/administrator.dto';
 import { Administrator } from '../entity/administrator/administrator.entity';
 import { User } from '../entity/user/user.entity';
+import { I18nError } from '../i18n/i18n-error';
 
 import { buildListQuery } from './helpers/build-list-query';
 import { PasswordService } from './password.service';
@@ -20,6 +22,10 @@ export class AdministratorService {
         private roleService: RoleService,
     ) {}
 
+    async initAdministrators() {
+        await this.ensureSuperAdminExists();
+    }
+
     findAll(options: ListQueryOptions<Administrator>): Promise<PaginatedList<Administrator>> {
         return buildListQuery(this.connection, Administrator, options, ['user', 'user.roles'])
             .getManyAndCount()
@@ -30,7 +36,9 @@ export class AdministratorService {
     }
 
     findOne(administratorId: ID): Promise<Administrator | undefined> {
-        return this.connection.manager.findOne(Administrator, administratorId);
+        return this.connection.manager.findOne(Administrator, administratorId, {
+            relations: ['user', 'user.roles'],
+        });
     }
 
     async create(createAdministratorDto: CreateAdministratorDto): Promise<Administrator> {
@@ -39,13 +47,53 @@ export class AdministratorService {
         const user = new User();
         user.passwordHash = await this.passwordService.hash(createAdministratorDto.password);
         user.identifier = createAdministratorDto.emailAddress;
-        // TODO: for now all Admins are added to the SuperAdmin role.
-        // It should be possible to add them to other roles.
-        user.roles = [await this.roleService.getSuperAdminRole()];
+        user.roles = [];
 
         const createdUser = await this.connection.manager.save(user);
         administrator.user = createdUser;
 
         return this.connection.manager.save(administrator);
     }
+
+    /**
+     * Assigns a Role to the Administrator's User entity.
+     */
+    async assignRole(administratorId: ID, roleId: ID): Promise<Administrator> {
+        const administrator = await this.findOne(administratorId);
+        if (!administrator) {
+            throw new I18nError(`error.entity-with-id-not-found`, {
+                id: administratorId,
+                entityName: 'Administrator',
+            });
+        }
+        const role = await this.roleService.findOne(roleId);
+        if (!role) {
+            throw new I18nError(`error.entity-with-id-not-found`, { id: roleId, entityName: 'Role' });
+        }
+        administrator.user.roles.push(role);
+        await this.connection.manager.save(administrator.user);
+        return administrator;
+    }
+
+    /**
+     * There must always exist a SuperAdmin, otherwise full administration via API will
+     * no longer be possible.
+     */
+    private async ensureSuperAdminExists() {
+        const superAdminUser = await this.connection.getRepository(User).findOne({
+            where: {
+                identifier: SUPER_ADMIN_USER_IDENTIFIER,
+            },
+        });
+
+        if (!superAdminUser) {
+            const administrator = await this.create({
+                emailAddress: SUPER_ADMIN_USER_IDENTIFIER,
+                password: SUPER_ADMIN_USER_PASSWORD,
+                firstName: 'Super',
+                lastName: 'Admin',
+            });
+            await this.assignRole(administrator.id, (await this.roleService.getSuperAdminRole()).id);
+        }
+    }
 }

+ 2 - 4
server/src/service/product-variant.service.ts

@@ -8,7 +8,7 @@ import { Connection } from 'typeorm';
 import { RequestContext } from '../api/common/request-context';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { Translated } from '../common/types/locale-types';
-import { assertFound } from '../common/utils';
+import { assertFound, idsAreEqual } from '../common/utils';
 import { FacetValue } from '../entity/facet-value/facet-value.entity';
 import { ProductOption } from '../entity/product-option/product-option.entity';
 import { CreateProductVariantDto } from '../entity/product-variant/create-product-variant.dto';
@@ -112,9 +112,7 @@ export class ProductVariantService {
             relations: ['options', 'facetValues'],
         });
 
-        const notFoundIds = productVariantIds.filter(
-            id => !variants.find(v => v.id.toString() === id.toString()),
-        );
+        const notFoundIds = productVariantIds.filter(id => !variants.find(v => idsAreEqual(v.id, id)));
         if (notFoundIds.length) {
             throw new I18nError('error.entity-with-id-not-found', {
                 entityName: 'ProductVariant',

+ 2 - 2
server/src/service/product.service.ts

@@ -7,7 +7,7 @@ import { Connection } from 'typeorm';
 import { RequestContext } from '../api/common/request-context';
 import { ListQueryOptions } from '../common/types/common-types';
 import { Translated } from '../common/types/locale-types';
-import { assertFound } from '../common/utils';
+import { assertFound, idsAreEqual } from '../common/utils';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
 import { ProductTranslation } from '../entity/product/product-translation.entity';
 import { Product } from '../entity/product/product.entity';
@@ -127,7 +127,7 @@ export class ProductService {
 
     private applyChannelPriceToVariants<T extends Product>(product: T, ctx: RequestContext): T {
         product.variants.forEach(v => {
-            const channelPrice = v.productVariantPrices.find(p => p.channelId === ctx.channelId);
+            const channelPrice = v.productVariantPrices.find(p => idsAreEqual(p.channelId, ctx.channelId));
             if (!channelPrice) {
                 throw new I18nError(`error.no-price-found-for-channel`);
             }

+ 1 - 0
server/src/service/role.service.ts

@@ -17,6 +17,7 @@ import { ChannelService } from './channel.service';
 import { buildListQuery } from './helpers/build-list-query';
 import { ActiveConnection } from './helpers/connection.decorator';
 
+// TODO: replace with generated Input interface
 export interface CreateRoleDto {
     code: string;
     description: string;

+ 6 - 1
server/src/service/service.module.ts

@@ -45,10 +45,15 @@ const exportedProviders = [
     exports: exportedProviders,
 })
 export class ServiceModule implements OnModuleInit {
-    constructor(private channelService: ChannelService, private roleService: RoleService) {}
+    constructor(
+        private channelService: ChannelService,
+        private roleService: RoleService,
+        private administratorService: AdministratorService,
+    ) {}
 
     async onModuleInit() {
         await this.channelService.initChannels();
         await this.roleService.initRoles();
+        await this.administratorService.initAdministrators();
     }
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов