Browse Source

feat(server): Rework roles and permissions to be channel aware

Michael Bromley 7 years ago
parent
commit
80a411c499
52 changed files with 522 additions and 160 deletions
  1. 12 9
      docs/diagrams/full-class-diagram.puml
  2. 0 0
      schema.json
  3. 1 0
      server/dev-config.ts
  4. 1 1
      server/e2e/config/jest-e2e.json
  5. 2 0
      server/e2e/config/test-config.ts
  6. 1 1
      server/e2e/config/tsconfig.e2e.json
  7. 3 1
      server/e2e/product.e2e-spec.ts
  8. 19 13
      server/e2e/test-server.ts
  9. 9 0
      server/e2e/test-utils.ts
  10. 20 4
      server/mock-data/clear-all-tables.ts
  11. 14 10
      server/mock-data/get-default-channel-token.ts
  12. 19 1
      server/mock-data/mock-data.service.ts
  13. 3 2
      server/mock-data/populate-cli.ts
  14. 12 6
      server/mock-data/populate.ts
  15. 1 1
      server/mock-data/simple-graphql-client.ts
  16. 4 1
      server/src/api/api.module.ts
  17. 9 1
      server/src/api/auth-guard.ts
  18. 14 11
      server/src/api/auth/auth.resolver.ts
  19. 3 0
      server/src/api/channel/channel.api.graphql
  20. 14 0
      server/src/api/channel/channel.resolver.ts
  21. 3 13
      server/src/api/common/request-context.pipe.ts
  22. 26 0
      server/src/api/common/request-context.service.ts
  23. 13 14
      server/src/api/common/request-context.ts
  24. 1 1
      server/src/api/jwt.strategy.ts
  25. 3 0
      server/src/api/product/product.resolver.ts
  26. 34 17
      server/src/api/roles-guard.ts
  27. 4 0
      server/src/common/constants.ts
  28. 2 2
      server/src/common/types/auth-types.ts
  29. 8 0
      server/src/common/types/common-types.ts
  30. 0 10
      server/src/common/types/role.ts
  31. 1 0
      server/src/config/config.service.mock.ts
  32. 10 0
      server/src/config/config.service.ts
  33. 7 0
      server/src/config/vendure-config.ts
  34. 3 0
      server/src/entity/channel/channel.entity.ts
  35. 7 0
      server/src/entity/channel/channel.graphql
  36. 2 0
      server/src/entity/entities.ts
  37. 8 3
      server/src/entity/product/product.entity.ts
  38. 20 0
      server/src/entity/role/permission.ts
  39. 26 0
      server/src/entity/role/role.entity.ts
  40. 6 0
      server/src/entity/role/role.graphql
  41. 6 5
      server/src/entity/user/user.entity.ts
  42. 8 4
      server/src/service/administrator.service.ts
  43. 10 9
      server/src/service/auth.service.ts
  44. 27 2
      server/src/service/channel.service.ts
  45. 4 3
      server/src/service/customer.service.ts
  46. 2 1
      server/src/service/facet-value.service.ts
  47. 9 0
      server/src/service/helpers/connection.decorator.ts
  48. 6 6
      server/src/service/product-variant.service.spec.ts
  49. 10 4
      server/src/service/product.service.spec.ts
  50. 5 3
      server/src/service/product.service.ts
  51. 86 0
      server/src/service/role.service.ts
  52. 4 1
      server/src/service/service.module.ts

+ 12 - 9
docs/diagrams/full-class-diagram.puml

@@ -20,15 +20,18 @@ class Customer {
 class Administrator {
     user: User
 }
-class UserRole {
-    role: Role
+class Role {
+    name: string
+    permissions: Permission[]
     user: User
     channel: Channel
 }
-enum Role {
-    Authenticated
-    Customer
-    Administrator
+enum Permission {
+    CreateCatalog
+    ReadCatalog
+    UpdateCatalog
+    DeleteCatalog
+    CreateUser
     ...etc
 }
 class Address {
@@ -79,9 +82,9 @@ class Category {
 
 Customer --  User
 Administrator -- User
-User o-- UserRole
-UserRole o-- "1..*" Role
-UserRole -- Channel
+User o-- Role
+Role o-- "1..*" Permission
+Role -- Channel
 Customer *-- "0..*" Address
 Product *-- "1..*" ProductVariant
 ProductOptionGroup *-- "1..*" ProductOption

File diff suppressed because it is too large
+ 0 - 0
schema.json


+ 1 - 0
server/dev-config.ts

@@ -6,6 +6,7 @@ import { VendureConfig } from './src/config/vendure-config';
  * Config settings used during development
  */
 export const devConfig: VendureConfig = {
+    disableAuth: true,
     port: API_PORT,
     apiPath: API_PATH,
     cors: true,

+ 1 - 1
server/e2e/config/jest-e2e.json

@@ -10,7 +10,7 @@
   },
   "globals": {
     "ts-jest": {
-      "skipBabel": true,
+      "skipBabel": false,
       "tsConfigFile": "<rootDir>/config/tsconfig.e2e.json",
       "enableTsDiagnostics": false
     }

+ 2 - 0
server/e2e/config/test-config.ts

@@ -2,6 +2,8 @@ import { API_PATH } from 'shared/shared-constants';
 
 import { VendureConfig } from '../../src/config/vendure-config';
 
+export const TEST_CONNECTION_NAME = undefined;
+
 /**
  * Config settings used for e2e tests
  */

+ 1 - 1
server/e2e/config/tsconfig.e2e.json

@@ -4,7 +4,7 @@
     "types": ["jest", "node"],
     "lib": ["es2015"],
     "skipLibCheck": false,
-    "sourceMap": true
+    "inlineSourceMap": true
   },
   "include": ["../**/*.e2e-spec.ts", "../**/*.d.ts"]
 }

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

@@ -46,7 +46,9 @@ describe('Product resolver', () => {
     const server = new TestServer();
 
     beforeAll(async () => {
-        await server.init({
+        const token = await server.init({
+            logging: true,
+            disableAuth: true,
             productCount: 20,
             customerCount: 1,
         });

+ 19 - 13
server/e2e/test-server.ts

@@ -10,6 +10,7 @@ import { Mutable } from '../src/common/types/common-types';
 import { VendureConfig } from '../src/config/vendure-config';
 
 import { testConfig } from './config/test-config';
+import { setTestEnvironment } from './test-utils';
 
 // tslint:disable:no-console
 /**
@@ -25,20 +26,17 @@ 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) {
+    async init(options: PopulateOptions & { disableAuth?: boolean }): Promise<void> {
+        setTestEnvironment();
         const testingConfig = testConfig;
-        const dbDataDir = '__data__';
-        // tslint:disable-next-line:no-non-null-assertion
-        const testFilePath = module!.parent!.filename;
-        const dbFileName = path.basename(testFilePath) + '.sqlite';
-        const dbFilePath = path.join(path.dirname(testFilePath), dbDataDir, dbFileName);
-
+        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}"`);
             await this.populateInitialData(testingConfig, options);
         }
-        console.log(`Loading test from "${dbFilePath}"`);
+        console.log(`Loading test data from "${dbFilePath}"`);
         this.app = await this.bootstrapForTesting(testingConfig);
     }
 
@@ -49,16 +47,25 @@ export class TestServer {
         await this.app.close();
     }
 
+    private getDbFilePath() {
+        const dbDataDir = '__data__';
+        // tslint:disable-next-line:no-non-null-assertion
+        const testFilePath = module!.parent!.filename;
+        const dbFileName = path.basename(testFilePath) + '.sqlite';
+        const dbFilePath = path.join(path.dirname(testFilePath), dbDataDir, dbFileName);
+        return dbFilePath;
+    }
+
     /**
      * Populates an .sqlite database file based on the PopulateOptions.
      */
-    private async populateInitialData(testingConfig: VendureConfig, options: PopulateOptions) {
+    private async populateInitialData(testingConfig: VendureConfig, options: PopulateOptions): Promise<void> {
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = true;
-        const populateApp = await populate(testingConfig, this.bootstrapForTesting, {
-            ...options,
+        const app = await populate(testingConfig, this.bootstrapForTesting, {
             logging: false,
+            ...options,
         });
-        await populateApp.close();
+        await app.close();
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = false;
     }
 
@@ -67,7 +74,6 @@ export class TestServer {
      */
     private async bootstrapForTesting(userConfig: Partial<VendureConfig>): Promise<INestApplication> {
         const config = await preBootstrapConfig(userConfig);
-
         const appModule = await import('../src/app.module');
         const app = await NestFactory.create(appModule.AppModule, { cors: config.cors });
         await app.listen(config.port);

+ 9 - 0
server/e2e/test-utils.ts

@@ -0,0 +1,9 @@
+export const E2E_TESTING_ENV_VARIABLE = 'isE2ETest';
+
+export function setTestEnvironment() {
+    process.env[E2E_TESTING_ENV_VARIABLE] = '1';
+}
+
+export function isTestEnvironment() {
+    return !!process.env[E2E_TESTING_ENV_VARIABLE];
+}

+ 20 - 4
server/mock-data/clear-all-tables.ts

@@ -1,4 +1,7 @@
-import { ConnectionOptions, createConnection } from 'typeorm';
+import { ConnectionOptions, createConnection, getSqljsManager } from 'typeorm';
+
+import { TEST_CONNECTION_NAME } from '../e2e/config/test-config';
+import { isTestEnvironment } from '../e2e/test-utils';
 
 // tslint:disable:no-console
 // tslint:disable:no-floating-promises
@@ -7,12 +10,25 @@ import { ConnectionOptions, createConnection } from 'typeorm';
  */
 export async function clearAllTables(connectionOptions: ConnectionOptions, logging = true) {
     (connectionOptions as any).entities = [__dirname + '/../src/**/*.entity.ts'];
-    const connection = await createConnection({ ...connectionOptions, name: 'clearAllTables' });
+    const name = isTestEnvironment() ? undefined : 'clearAllTables';
+    const connection = await createConnection({ ...connectionOptions, name });
     if (logging) {
         console.log('Clearing all tables...');
     }
-    await connection.synchronize(true);
-    await connection.close();
+    try {
+        await connection.synchronize(true);
+        if (connectionOptions.type === 'sqljs') {
+            console.log(
+                `tables in "${connection.options.name}": `,
+                await connection.query('SELECT * FROM sqlite_master'),
+            );
+        }
+    } catch (err) {
+        console.error('Error occurred when attempting to clear tables!');
+        console.error(err);
+    } finally {
+        await connection.close();
+    }
     if (logging) {
         console.log('Done!');
     }

+ 14 - 10
server/mock-data/get-default-channel-token.ts

@@ -1,4 +1,4 @@
-import { ConnectionOptions, createConnection } from 'typeorm';
+import { ConnectionOptions, getConnection } from 'typeorm';
 
 import { DEFAULT_CHANNEL_CODE } from '../src/common/constants';
 import { Channel } from '../src/entity/channel/channel.entity';
@@ -13,19 +13,23 @@ export async function getDefaultChannelToken(
     logging = true,
 ): Promise<string> {
     (connectionOptions as any).entities = [__dirname + '/../src/**/*.entity.ts'];
-    const connection = await createConnection({ ...connectionOptions, name: 'getDefaultChannelToken' });
-
-    const defaultChannel = await connection.manager.getRepository(Channel).findOne({
-        where: {
-            code: DEFAULT_CHANNEL_CODE,
-        },
-    });
-    await connection.close();
+    const connection = await getConnection();
+    let defaultChannel: Channel | undefined;
+    try {
+        defaultChannel = await connection.manager.getRepository(Channel).findOne({
+            where: {
+                code: DEFAULT_CHANNEL_CODE,
+            },
+        });
+    } catch (err) {
+        console.log(`Error occurred when attempting to get default Channel`);
+        console.error(err);
+    }
     if (!defaultChannel) {
         throw new Error(`No default channel could be found!`);
     }
     if (logging) {
-        console.log(`Got default channel token: ${defaultChannel.token}'`);
+        console.log(`Got default channel token: ${defaultChannel.token}`);
     }
     return defaultChannel.token;
 }

+ 19 - 1
server/mock-data/mock-data.service.ts

@@ -2,7 +2,6 @@ import * as faker from 'faker/locale/en_GB';
 import gql from 'graphql-tag';
 import {
     CreateFacet,
-    CreateFacetValueInput,
     CreateFacetValueWithFacetInput,
     CreateFacetVariables,
     CreateProduct,
@@ -26,6 +25,7 @@ import {
 } from '../../admin-ui/src/app/data/mutations/product-mutations';
 import { CreateAddressDto } from '../src/entity/address/address.dto';
 import { CreateAdministratorDto } from '../src/entity/administrator/administrator.dto';
+import { Channel } from '../src/entity/channel/channel.entity';
 import { CreateCustomerDto } from '../src/entity/customer/customer.dto';
 import { Customer } from '../src/entity/customer/customer.entity';
 
@@ -43,6 +43,24 @@ export class MockDataService {
         faker.seed(1);
     }
 
+    async populateChannels(channelCodes: string[]): Promise<Channel[]> {
+        const channels: Channel[] = [];
+        for (const code of channelCodes) {
+            const channel = await this.client.query<any>(gql`
+                mutation {
+                    createChannel(code: "${code}") {
+                        id
+                        code
+                        token
+                    }
+                }
+            `);
+            channels.push(channel.createChannel);
+            this.log(`Created Channel: ${channel.createChannel.code}`);
+        }
+        return channels;
+    }
+
     async populateOptions(): Promise<any> {
         await this.client
             .query<CreateProductOptionGroup, CreateProductOptionGroupVariables>(CREATE_PRODUCT_OPTION_GROUP, {

+ 3 - 2
server/mock-data/populate-cli.ts

@@ -16,8 +16,9 @@ if (require.main === module) {
     // tslint:disable
     populate(populateConfig, bootstrap, {
         logging: true,
-        customerCount: 100,
-        productCount: 200,
+        customerCount: 10,
+        productCount: 20,
+        channels: ['mobile-app'],
     })
         .then(app => app.close())
         .then(() => process.exit(0));

+ 12 - 6
server/mock-data/populate.ts

@@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
 
 import { VendureBootstrapFunction } from '../src/bootstrap';
 import { setConfig, VendureConfig } from '../src/config/vendure-config';
+import { Channel } from '../src/entity/channel/channel.entity';
 
 import { clearAllTables } from './clear-all-tables';
 import { getDefaultChannelToken } from './get-default-channel-token';
@@ -12,6 +13,7 @@ export interface PopulateOptions {
     logging?: boolean;
     productCount: number;
     customerCount: number;
+    channels?: string[];
 }
 
 // tslint:disable:no-floating-promises
@@ -32,11 +34,15 @@ export async function populate(
     const client = new SimpleGraphQLClient(
         `http://localhost:${config.port}/${config.apiPath}?token=${defaultChannelToken}`,
     );
-    const mockDataClientService = new MockDataService(client, logging);
-    await mockDataClientService.populateOptions();
-    await mockDataClientService.populateProducts(options.productCount);
-    await mockDataClientService.populateCustomers(options.customerCount);
-    await mockDataClientService.populateFacets();
-    await mockDataClientService.populateAdmins();
+    const mockDataService = new MockDataService(client, logging);
+    let channels: Channel[] = [];
+    if (options.channels) {
+        channels = await mockDataService.populateChannels(options.channels);
+    }
+    await mockDataService.populateOptions();
+    await mockDataService.populateProducts(options.productCount);
+    await mockDataService.populateCustomers(options.customerCount);
+    await mockDataService.populateFacets();
+    await mockDataService.populateAdmins();
     return app;
 }

+ 1 - 1
server/mock-data/simple-graphql-client.ts

@@ -3,7 +3,7 @@ import { request } from 'graphql-request';
 import { print } from 'graphql/language/printer';
 
 export interface GraphQlClient {
-    query<T, V = Record<string, any>>(query: DocumentNode, variables: V): Promise<T>;
+    query<T, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<T>;
 }
 
 /**

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

@@ -7,6 +7,8 @@ import { ServiceModule } from '../service/service.module';
 
 import { AdministratorResolver } from './administrator/administrator.resolver';
 import { AuthResolver } from './auth/auth.resolver';
+import { ChannelResolver } from './channel/channel.resolver';
+import { RequestContextService } from './common/request-context.service';
 import { ConfigResolver } from './config/config.resolver';
 import { CustomerResolver } from './customer/customer.resolver';
 import { FacetResolver } from './facet/facet.resolver';
@@ -18,6 +20,7 @@ import { ProductResolver } from './product/product.resolver';
 const exportedProviders = [
     AdministratorResolver,
     AuthResolver,
+    ChannelResolver,
     ConfigResolver,
     FacetResolver,
     CustomerResolver,
@@ -38,7 +41,7 @@ const exportedProviders = [
             imports: [ConfigModule, I18nModule],
         }),
     ],
-    providers: [...exportedProviders, JwtStrategy],
+    providers: [...exportedProviders, JwtStrategy, RequestContextService],
     exports: exportedProviders,
 })
 export class ApiModule {}

+ 9 - 1
server/src/api/auth-guard.ts

@@ -6,6 +6,7 @@ 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';
 
 /**
@@ -23,18 +24,25 @@ export class AuthGuard implements CanActivate {
             {
                 secretOrKey: configService.jwtSecret,
                 jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+                // TODO: make this configurable. See https://github.com/vendure-ecommerce/vendure/issues/16
+                jsonWebTokenOptions: {
+                    expiresIn: '2 days',
+                },
             },
             (payload: any, done: () => void) => this.validate(payload, done),
         );
     }
 
     async canActivate(context: ExecutionContext): Promise<boolean> {
+        if (this.configService.disableAuth) {
+            return true;
+        }
         const ctx = GqlExecutionContext.create(context).getContext();
         return this.authenticate(ctx.req);
     }
 
     async validate(payload: JwtPayload, done: (err: Error | null, user: any) => void) {
-        const user = await this.authService.validateUser(payload);
+        const user = await this.authService.validateUser(payload.identifier);
         if (!user) {
             return done(new UnauthorizedException(), false);
         }

+ 14 - 11
server/src/api/auth/auth.resolver.ts

@@ -1,6 +1,6 @@
 import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
 
-import { Role } from '../../common/types/role';
+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';
@@ -29,26 +29,29 @@ export class AuthResolver {
     /**
      * Returns information about the current authenticated user.
      */
-    @RolesGuard([Role.Authenticated])
+    @RolesGuard([Permission.Authenticated])
     @Query()
-    me(@Context('req') request: any) {
-        const user = request.user as User;
+    async me(@Context('req') request: any) {
+        const user = await this.authService.validateUser(request.user.identifier);
         return user ? this.publiclyAccessibleUser(user) : null;
     }
 
     /**
      * Exposes a subset of the User properties which we want to expose to the public API.
      */
-    private publiclyAccessibleUser(
-        user: User,
-    ): Pick<User, 'id' | 'identifier' | 'roles'> & { channelTokens: string[] } {
+    private publiclyAccessibleUser(user: User): any {
         return {
             id: user.id,
             identifier: user.identifier,
-            roles: user.roles,
-            channelTokens: user.roles.includes(Role.SuperAdmin)
-                ? [this.channelService.getDefaultChannel().token]
-                : [],
+            roles: user.roles.reduce(
+                (roleTypes, role) => [...roleTypes, ...role.permissions],
+                [] as Permission[],
+            ),
+            channelTokens: this.getAvailableChannelTokens(user),
         };
     }
+
+    private getAvailableChannelTokens(user: User): string[] {
+        return user.roles.reduce((tokens, role) => role.channels.map(c => c.token), [] as string[]);
+    }
 }

+ 3 - 0
server/src/api/channel/channel.api.graphql

@@ -0,0 +1,3 @@
+type Mutation {
+  createChannel(code: String!): Channel!
+}

+ 14 - 0
server/src/api/channel/channel.resolver.ts

@@ -0,0 +1,14 @@
+import { Args, Mutation, Resolver } from '@nestjs/graphql';
+
+import { Channel } from '../../entity/channel/channel.entity';
+import { ChannelService } from '../../service/channel.service';
+
+@Resolver('Channel')
+export class ChannelResolver {
+    constructor(private channelService: ChannelService) {}
+
+    @Mutation()
+    createChannel(@Args() args): Promise<Channel> {
+        return this.channelService.create(args.code);
+    }
+}

+ 3 - 13
server/src/api/common/request-context.pipe.ts

@@ -4,26 +4,16 @@ import { I18nError } from '../../i18n/i18n-error';
 import { ChannelService } from '../../service/channel.service';
 
 import { RequestContext } from './request-context';
+import { RequestContextService } from './request-context.service';
 
 /**
  * Creates a new RequestContext based on the token passed in the query string of the request.
  */
 @Injectable()
 export class RequestContextPipe implements PipeTransform<any, RequestContext> {
-    constructor(private channelService: ChannelService) {}
+    constructor(private requestContextService: RequestContextService) {}
 
     transform(value: any, metadata: ArgumentMetadata) {
-        const v = value;
-        if (value.req && value.req.query) {
-            const token = value.req.query.token;
-
-            const ctx = new RequestContext(value.req.query.token);
-            const channel = this.channelService.getChannelFromToken(token);
-            if (channel) {
-                ctx.channelId = channel.id;
-            }
-            return ctx;
-        }
-        throw new I18nError(`error.unexpected-request-context`);
+        return this.requestContextService.fromRequest(value.req);
     }
 }

+ 26 - 0
server/src/api/common/request-context.service.ts

@@ -0,0 +1,26 @@
+import { Injectable } from '@nestjs/common';
+
+import { I18nError } from '../../i18n/i18n-error';
+import { ChannelService } from '../../service/channel.service';
+
+import { RequestContext } from './request-context';
+
+/**
+ * Creates new RequestContext instances.
+ */
+@Injectable()
+export class RequestContextService {
+    constructor(private channelService: ChannelService) {}
+
+    /**
+     * Creates a new RequestContext based on an Express request object.
+     */
+    fromRequest(req: any): RequestContext {
+        if (req && req.query) {
+            const token = req.query.token;
+            const channel = this.channelService.getChannelFromToken(token);
+            return new RequestContext(channel);
+        }
+        throw new I18nError(`error.unexpected-request-context`);
+    }
+}

+ 13 - 14
server/src/api/common/request-context.ts

@@ -2,6 +2,7 @@ import { LanguageCode } from 'shared/generated-types';
 import { ID } from 'shared/shared-types';
 
 import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
+import { Channel } from '../../entity/channel/channel.entity';
 
 /**
  * The RequestContext is intended to hold information relevant to the current request, which may be
@@ -9,33 +10,31 @@ import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
  * exposed, as well as the active language.
  */
 export class RequestContext {
-    get channelId(): ID {
-        return this._channelId;
+    get channel(): Channel {
+        return this._channel || ({} as any);
     }
 
-    set channelId(value: ID) {
-        this._channelId = value;
+    get channelId(): ID | undefined {
+        return this._channel && this._channel.id;
     }
 
-    get token(): string {
-        return this._token;
-    }
     get languageCode(): LanguageCode {
-        return this._languageCode;
+        if (this._languageCode) {
+            return this._languageCode;
+        } else if (this._channel) {
+            return this._channel.defaultLanguageCode;
+        } else {
+            return DEFAULT_LANGUAGE_CODE;
+        }
     }
 
     private _languageCode: LanguageCode;
-    private _channelId: ID;
 
-    constructor(private _token: string) {
-        this._languageCode = DEFAULT_LANGUAGE_CODE;
-    }
+    constructor(private _channel?: Channel) {}
 
     setLanguageCode(value: LanguageCode | null | undefined) {
         if (value) {
             this._languageCode = value;
-        } else {
-            this._languageCode = DEFAULT_LANGUAGE_CODE;
         }
     }
 }

+ 1 - 1
server/src/api/jwt.strategy.ts

@@ -20,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
     }
 
     async validate(payload: JwtPayload, done: (err: Error | null, user: any) => void) {
-        const user = await this.authService.validateUser(payload);
+        const user = await this.authService.validateUser(payload.identifier);
         if (!user) {
             return done(new UnauthorizedException(), false);
         }

+ 3 - 0
server/src/api/product/product.resolver.ts

@@ -17,6 +17,7 @@ import { Translated } from '../../common/types/locale-types';
 import { assertFound } from '../../common/utils';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { Product } from '../../entity/product/product.entity';
+import { Permission } from '../../entity/role/permission';
 import { I18nError } from '../../i18n/i18n-error';
 import { FacetValueService } from '../../service/facet-value.service';
 import { ProductVariantService } from '../../service/product-variant.service';
@@ -24,6 +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';
 
 @Resolver('Product')
 export class ProductResolver {
@@ -34,6 +36,7 @@ export class ProductResolver {
     ) {}
 
     @Query()
+    @RolesGuard([Permission.ReadCatalog])
     @ApplyIdCodec()
     async products(
         @Context(RequestContextPipe) ctx: RequestContext,

+ 34 - 17
server/src/api/roles-guard.ts

@@ -1,10 +1,12 @@
-import { CanActivate, ExecutionContext, UseGuards } from '@nestjs/common';
+import { CanActivate, ExecutionContext, Injectable, UseGuards } from '@nestjs/common';
 import { ExtractJwt, Strategy } from 'passport-jwt';
 
-import { Role } from '../common/types/role';
+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';
 
 /**
  * A guard which combines the JWT passport auth method with restrictions based on
@@ -12,48 +14,63 @@ import { AuthGuard } from './auth-guard';
  *
  * @example
  * ```
- *  @RolesGuard([Role.SuperAdmin])
+ *  @RolesGuard([Permission.SuperAdmin])
  *  @Query('administrators')
  *  getAdministrators() {
  *      // ...
  *  }
  * ```
  */
-export function RolesGuard(roles: Role[]) {
+export function RolesGuard(permissions: Permission[]) {
     // tslint:disable-next-line:ban-types
     const guards: Array<CanActivate | Function> = [AuthGuard];
 
-    if (roles.length && !authenticatedOnly(roles)) {
-        guards.push(forRoles(roles));
+    if (permissions.length && !authenticatedOnly(permissions)) {
+        guards.push(forPermissions(permissions));
     }
 
     return UseGuards(...guards);
 }
 
-function authenticatedOnly(roles: Role[]): boolean {
-    return roles.length === 1 && roles[0] === Role.Authenticated;
+function authenticatedOnly(permissions: Permission[]): boolean {
+    return permissions.length === 1 && permissions[0] === Permission.Authenticated;
 }
 
 /**
- * A guard which specifies which roles are authorized to access a given
- * route or property in a Controller / Resolver.
+ * A guard which specifies which permissions are authorized to access a given
+ * route or property in a Resolver.
  */
-function forRoles(roles: Role[]) {
-    return {
+function forPermissions(permissions: Permission[]) {
+    @Injectable()
+    class RoleGuard implements CanActivate {
+        constructor(
+            private requestContextService: RequestContextService,
+            private configService: ConfigService,
+        ) {}
+
         canActivate(context: ExecutionContext) {
-            const user: User = context.switchToHttp().getRequest().user;
+            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;
             }
-            return arraysIntersect(roles, user.roles);
-        },
-    } as CanActivate;
+            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);
+        }
+    }
+    return RoleGuard;
 }
 
 /**
  * Returns true if any element of arr1 appears in arr2.
  */
-function arraysIntersect(arr1, arr2): boolean {
+function arraysIntersect<T>(arr1: T[], arr2: T[]): boolean {
     return arr1.reduce((intersects, role) => {
         return intersects || arr2.includes(role);
     }, false);

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

@@ -2,3 +2,7 @@ import { LanguageCode } from 'shared/generated-types';
 
 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 CUSTOMER_ROLE_CODE = '__customer_role__';
+export const CUSTOMER_ROLE_DESCRIPTION = 'Customer';

+ 2 - 2
server/src/common/types/auth-types.ts

@@ -1,6 +1,6 @@
-import { Role } from './role';
+import { Permission } from '../../entity/role/permission';
 
 export interface JwtPayload {
     identifier: string;
-    roles: Role[];
+    roles: Permission[];
 }

+ 8 - 0
server/src/common/types/common-types.ts

@@ -1,7 +1,15 @@
 import { VendureEntity } from '../../entity/base/base.entity';
+import { Channel } from '../../entity/channel/channel.entity';
 
 import { LocaleString } from './locale-types';
 
+/**
+ * Entities which can be assigned to Channels should implement this interface.
+ */
+export interface ChannelAware {
+    channels: Channel[];
+}
+
 /**
  * Creates a type based on T, but with all properties non-optional
  * and readonly.

+ 0 - 10
server/src/common/types/role.ts

@@ -1,10 +0,0 @@
-/**
- * All possible authorization roles for registered users.
- */
-export enum Role {
-    // The Authenticated role means simply that the user is logged in
-    Authenticated = 'Authenticated',
-    Customer = 'Customer',
-    ChannelAdmin = 'ChannelAdmin',
-    SuperAdmin = 'SuperAdmin',
-}

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

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

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

@@ -10,6 +10,10 @@ import { getConfig, VendureConfig } from '../config/vendure-config';
 
 @Injectable()
 export class ConfigService implements VendureConfig {
+    get disableAuth(): boolean {
+        return this.activeConfig.disableAuth;
+    }
+
     get defaultLanguageCode(): LanguageCode {
         return this.activeConfig.defaultLanguageCode;
     }
@@ -46,5 +50,11 @@ export class ConfigService implements VendureConfig {
 
     constructor() {
         this.activeConfig = getConfig();
+        if (this.activeConfig.disableAuth) {
+            // tslint:disable-next-line
+            console.warn(
+                'WARNING: auth has been disabled. This should never be the case for a production system!',
+            );
+        }
     }
 }

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

@@ -11,6 +11,12 @@ import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { mergeConfig } from './merge-config';
 
 export interface VendureConfig {
+    /**
+     * Disable authentication & permissions checks.
+     * NEVER set the to true in production. It exists
+     * only to aid the ease of development.
+     */
+    disableAuth?: boolean;
     /**
      * The default languageCode of the app.
      */
@@ -53,6 +59,7 @@ export interface VendureConfig {
 }
 
 const defaultConfig: ReadOnlyRequired<VendureConfig> = {
+    disableAuth: false,
     defaultLanguageCode: LanguageCode.en,
     port: API_PORT,
     cors: false,

+ 3 - 0
server/src/entity/channel/channel.entity.ts

@@ -1,3 +1,4 @@
+import { LanguageCode } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity } from 'typeorm';
 
@@ -16,6 +17,8 @@ export class Channel extends VendureEntity {
     @Column({ unique: true })
     token: string;
 
+    @Column('varchar') defaultLanguageCode: LanguageCode;
+
     private generateToken(): string {
         const randomString = () =>
             Math.random()

+ 7 - 0
server/src/entity/channel/channel.graphql

@@ -0,0 +1,7 @@
+type Channel implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    code: String!
+    token: String!
+}

+ 2 - 0
server/src/entity/entities.ts

@@ -15,6 +15,7 @@ import { ProductVariantTranslation } from './product-variant/product-variant-tra
 import { ProductVariant } from './product-variant/product-variant.entity';
 import { ProductTranslation } from './product/product-translation.entity';
 import { Product } from './product/product.entity';
+import { Role } from './role/role.entity';
 import { User } from './user/user.entity';
 
 /**
@@ -38,5 +39,6 @@ export const coreEntitiesMap = {
     ProductVariant,
     ProductVariantPrice,
     ProductVariantTranslation,
+    Role,
     User,
 };

+ 8 - 3
server/src/entity/product/product.entity.ts

@@ -1,9 +1,10 @@
-import { DeepPartial } from 'shared/shared-types';
-import { HasCustomFields } from 'shared/shared-types';
+import { DeepPartial, HasCustomFields } from 'shared/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, OneToMany } from 'typeorm';
 
+import { ChannelAware } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
 import { CustomProductFields } from '../custom-entity-fields';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
@@ -11,7 +12,7 @@ import { ProductVariant } from '../product-variant/product-variant.entity';
 import { ProductTranslation } from './product-translation.entity';
 
 @Entity()
-export class Product extends VendureEntity implements Translatable, HasCustomFields {
+export class Product extends VendureEntity implements Translatable, HasCustomFields, ChannelAware {
     constructor(input?: DeepPartial<Product>) {
         super(input);
     }
@@ -35,4 +36,8 @@ export class Product extends VendureEntity implements Translatable, HasCustomFie
 
     @Column(type => CustomProductFields)
     customFields: CustomProductFields;
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
 }

+ 20 - 0
server/src/entity/role/permission.ts

@@ -0,0 +1,20 @@
+/**
+ * Permissions for administrators.
+ */
+export enum Permission {
+    // The Authenticated role means simply that the user is logged in
+    Authenticated = 'Authenticated',
+    // CRUD permissions on the various classes of entity
+    CreateCatalog = 'CreateCatalog',
+    ReadCatalog = 'ReadCatalog',
+    UpdateCatalog = 'UpdateCatalog',
+    DeleteCatalog = 'DeleteCatalog',
+    CreateCustomer = 'CreateCustomer',
+    ReadCustomer = 'ReadCustomer',
+    UpdateCustomer = 'UpdateCustomer',
+    DeleteCustomer = 'DeleteCustomer',
+    CreateOrder = 'CreateOrder',
+    ReadOrder = 'ReadOrder',
+    UpdateOrder = 'UpdateOrder',
+    DeleteOrder = 'DeleteOrder',
+}

+ 26 - 0
server/src/entity/role/role.entity.ts

@@ -0,0 +1,26 @@
+import { DeepPartial } from 'shared/shared-types';
+import { Column, Entity, JoinTable, ManyToMany, ManyToOne } from 'typeorm';
+
+import { ChannelAware } from '../../common/types/common-types';
+import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
+import { User } from '../user/user.entity';
+
+import { Permission } from './permission';
+
+@Entity()
+export class Role extends VendureEntity implements ChannelAware {
+    constructor(input?: DeepPartial<Role>) {
+        super(input);
+    }
+
+    @Column() code: string;
+
+    @Column() description: string;
+
+    @Column('simple-array') permissions: Permission[];
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
+}

+ 6 - 0
server/src/entity/role/role.graphql

@@ -0,0 +1,6 @@
+type Role {
+    code: String!
+    description: String!
+    permissions: [String!]!
+    channels: [Channel!]!
+}

+ 6 - 5
server/src/entity/user/user.entity.ts

@@ -1,10 +1,9 @@
-import { DeepPartial } from 'shared/shared-types';
-import { HasCustomFields } from 'shared/shared-types';
-import { Column, Entity } from 'typeorm';
+import { DeepPartial, HasCustomFields } from 'shared/shared-types';
+import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 
-import { Role } from '../../common/types/role';
 import { VendureEntity } from '../base/base.entity';
 import { CustomUserFields } from '../custom-entity-fields';
+import { Role } from '../role/role.entity';
 
 @Entity()
 export class User extends VendureEntity implements HasCustomFields {
@@ -17,7 +16,9 @@ export class User extends VendureEntity implements HasCustomFields {
 
     @Column() passwordHash: string;
 
-    @Column('simple-array') roles: Role[];
+    @ManyToMany(type => Role)
+    @JoinTable()
+    roles: Role[];
 
     @Column({ nullable: true })
     lastLogin: string;

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

@@ -2,18 +2,19 @@ import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 
-import { Role } from '../common/types/role';
 import { CreateAdministratorDto } from '../entity/administrator/administrator.dto';
 import { Administrator } from '../entity/administrator/administrator.entity';
 import { User } from '../entity/user/user.entity';
 
 import { PasswordService } from './password.service';
+import { RoleService } from './role.service';
 
 @Injectable()
 export class AdministratorService {
     constructor(
         @InjectConnection() private connection: Connection,
         private passwordService: PasswordService,
+        private roleService: RoleService,
     ) {}
 
     findAll(): Promise<Administrator[]> {
@@ -30,10 +31,13 @@ export class AdministratorService {
         const user = new User();
         user.passwordHash = await this.passwordService.hash(createAdministratorDto.password);
         user.identifier = createAdministratorDto.emailAddress;
-        user.roles = [Role.SuperAdmin];
-        const createdUser = await this.connection.getRepository(User).save(user);
+        // 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()];
+
+        const createdUser = await this.connection.manager.save(user);
         administrator.user = createdUser;
 
-        return this.connection.getRepository(Administrator).save(administrator);
+        return this.connection.manager.save(administrator);
     }
 }

+ 10 - 9
server/src/service/auth.service.ts

@@ -4,8 +4,8 @@ import * as jwt from 'jsonwebtoken';
 import { Connection } from 'typeorm';
 
 import { JwtPayload } from '../common/types/auth-types';
-import { Role } from '../common/types/role';
 import { ConfigService } from '../config/config.service';
+import { Permission } from '../entity/role/permission';
 import { User } from '../entity/user/user.entity';
 
 import { PasswordService } from './password.service';
@@ -20,9 +20,8 @@ export class AuthService {
 
     async createToken(identifier: string, password: string): Promise<{ user: User; token: string }> {
         const user = await this.connection.getRepository(User).findOne({
-            where: {
-                identifier,
-            },
+            where: { identifier },
+            relations: ['roles', 'roles.channels'],
         });
 
         if (!user) {
@@ -34,17 +33,19 @@ export class AuthService {
         if (!passwordMatches) {
             throw new UnauthorizedException();
         }
-        const payload: JwtPayload = { identifier, roles: user.roles };
+        const payload: JwtPayload = {
+            identifier,
+            roles: user.roles.reduce((roles, r) => [...roles, ...r.permissions], [] as Permission[]),
+        };
         const token = jwt.sign(payload, this.configService.jwtSecret, { expiresIn: 3600 });
 
         return { user, token };
     }
 
-    async validateUser(payload: JwtPayload): Promise<any> {
+    async validateUser(identifier: string): Promise<User | undefined> {
         return await this.connection.getRepository(User).findOne({
-            where: {
-                identifier: payload.identifier,
-            },
+            where: { identifier },
+            relations: ['roles', 'roles.channels'],
         });
     }
 }

+ 27 - 2
server/src/service/channel.service.ts

@@ -2,7 +2,9 @@ import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 
-import { DEFAULT_CHANNEL_CODE } from '../common/constants';
+import { RequestContext } from '../api/common/request-context';
+import { DEFAULT_CHANNEL_CODE, DEFAULT_LANGUAGE_CODE } from '../common/constants';
+import { ChannelAware } from '../common/types/common-types';
 import { Channel } from '../entity/channel/channel.entity';
 import { I18nError } from '../i18n/i18n-error';
 
@@ -21,6 +23,16 @@ export class ChannelService {
         this.allChannels = await this.findAll();
     }
 
+    /**
+     * Assigns a ChannelAware entity to the default Channel as well as any channel
+     * specified in the RequestContext.
+     */
+    assignToChannels<T extends ChannelAware>(entity: T, ctx: RequestContext): T {
+        const channelIds = [...new Set([ctx.channelId, this.getDefaultChannel().id])];
+        entity.channels = channelIds.map(id => ({ id })) as any;
+        return entity;
+    }
+
     /**
      * Given a channel token, returns the corresponding Channel if it exists.
      */
@@ -44,6 +56,16 @@ export class ChannelService {
         return this.connection.getRepository(Channel).find();
     }
 
+    async create(code: string): Promise<Channel> {
+        const channel = new Channel({
+            code,
+            defaultLanguageCode: DEFAULT_LANGUAGE_CODE,
+        });
+        const newChannel = await this.connection.getRepository(Channel).save(channel);
+        this.allChannels.push(channel);
+        return channel;
+    }
+
     /**
      * There must always be a default Channel. If none yet exists, this method creates one.
      */
@@ -55,7 +77,10 @@ export class ChannelService {
         });
 
         if (!defaultChannel) {
-            const newDefaultChannel = new Channel({ code: DEFAULT_CHANNEL_CODE });
+            const newDefaultChannel = new Channel({
+                code: DEFAULT_CHANNEL_CODE,
+                defaultLanguageCode: DEFAULT_LANGUAGE_CODE,
+            });
             await this.connection.manager.save(newDefaultChannel);
         }
     }

+ 4 - 3
server/src/service/customer.service.ts

@@ -4,7 +4,6 @@ import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
 import { ListQueryOptions } from '../common/types/common-types';
-import { Role } from '../common/types/role';
 import { CreateAddressDto } from '../entity/address/address.dto';
 import { Address } from '../entity/address/address.entity';
 import { CreateCustomerDto } from '../entity/customer/customer.dto';
@@ -14,12 +13,14 @@ import { I18nError } from '../i18n/i18n-error';
 
 import { buildListQuery } from './helpers/build-list-query';
 import { PasswordService } from './password.service';
+import { RoleService } from './role.service';
 
 @Injectable()
 export class CustomerService {
     constructor(
         @InjectConnection() private connection: Connection,
         private passwordService: PasswordService,
+        private roleService: RoleService,
     ) {}
 
     findAll(options: ListQueryOptions<Customer>): Promise<PaginatedList<Customer>> {
@@ -47,8 +48,8 @@ export class CustomerService {
             const user = new User();
             user.passwordHash = await this.passwordService.hash(password);
             user.identifier = createCustomerDto.emailAddress;
-            user.roles = [Role.Customer];
-            const createdUser = await this.connection.getRepository(User).save(user);
+            user.roles = [await this.roleService.getCustomerRole()];
+            const createdUser = await this.connection.manager.save(user);
             customer.user = createdUser;
         }
 

+ 2 - 1
server/src/service/facet-value.service.ts

@@ -16,6 +16,7 @@ import { FacetValueTranslation } from '../entity/facet-value/facet-value-transla
 import { FacetValue } from '../entity/facet-value/facet-value.entity';
 import { Facet } from '../entity/facet/facet.entity';
 
+import { ActiveConnection } from './helpers/connection.decorator';
 import { createTranslatable } from './helpers/create-translatable';
 import { translateDeep } from './helpers/translate-entity';
 import { TranslationUpdaterService } from './helpers/translation-updater.service';
@@ -24,7 +25,7 @@ import { updateTranslatable } from './helpers/update-translatable';
 @Injectable()
 export class FacetValueService {
     constructor(
-        @InjectConnection() private connection: Connection,
+        @ActiveConnection() private connection: Connection,
         private translationUpdaterService: TranslationUpdaterService,
     ) {}
 

+ 9 - 0
server/src/service/helpers/connection.decorator.ts

@@ -0,0 +1,9 @@
+import { InjectConnection } from '@nestjs/typeorm';
+import { getConnectionManager } from 'typeorm';
+
+import { getConfig } from '../../config/vendure-config';
+
+export function ActiveConnection() {
+    const cm = getConnectionManager();
+    return InjectConnection(getConfig().dbConnectionOptions.name);
+}

+ 6 - 6
server/src/service/product-variant.service.spec.ts

@@ -35,7 +35,7 @@ describe('ProductVariantService', () => {
     describe('create()', () => {
         it('saves a new ProductVariant with the correct properties', async () => {
             const productEntity = new Product();
-            await productVariantService.create(new RequestContext(''), productEntity, {
+            await productVariantService.create(new RequestContext(), productEntity, {
                 sku: '123456',
                 price: 123,
                 translations: [
@@ -57,7 +57,7 @@ describe('ProductVariantService', () => {
 
         it('saves each ProductVariantTranslation', async () => {
             const productEntity = new Product();
-            await productVariantService.create(new RequestContext(''), productEntity, {
+            await productVariantService.create(new RequestContext(), productEntity, {
                 sku: '123456',
                 price: 123,
                 translations: [
@@ -87,7 +87,7 @@ describe('ProductVariantService', () => {
                 .registerMockRepository(ProductOption)
                 .find.mockReturnValue(mockOptions);
 
-            await productVariantService.create(new RequestContext(''), productEntity, {
+            await productVariantService.create(new RequestContext(), productEntity, {
                 sku: '123456',
                 price: 123,
                 translations: [
@@ -132,7 +132,7 @@ describe('ProductVariantService', () => {
                 .registerMockRepository(Product)
                 .findOne.mockReturnValue(mockProduct);
             const mockCreate = jest.spyOn(productVariantService, 'create').mockReturnValue(Promise.resolve());
-            await productVariantService.generateVariantsForProduct(new RequestContext(''), 123);
+            await productVariantService.generateVariantsForProduct(new RequestContext(), 123);
 
             const saveCalls = mockCreate.mock.calls;
             expect(saveCalls.length).toBe(1);
@@ -162,7 +162,7 @@ describe('ProductVariantService', () => {
                 .registerMockRepository(Product)
                 .findOne.mockReturnValue(mockProduct);
             const mockCreate = jest.spyOn(productVariantService, 'create').mockReturnValue(Promise.resolve());
-            await productVariantService.generateVariantsForProduct(new RequestContext(''), 123);
+            await productVariantService.generateVariantsForProduct(new RequestContext(), 123);
 
             const saveCalls = mockCreate.mock.calls;
             expect(saveCalls.length).toBe(3);
@@ -200,7 +200,7 @@ describe('ProductVariantService', () => {
                 .findOne.mockReturnValue(mockProduct);
             const mockCreate = jest.spyOn(productVariantService, 'create').mockReturnValue(Promise.resolve());
 
-            await productVariantService.generateVariantsForProduct(new RequestContext(''), 123);
+            await productVariantService.generateVariantsForProduct(new RequestContext(), 123);
 
             const saveCalls = mockCreate.mock.calls;
             expect(saveCalls.length).toBe(9);

+ 10 - 4
server/src/service/product.service.spec.ts

@@ -8,6 +8,7 @@ import { ProductTranslation } from '../entity/product/product-translation.entity
 import { Product } from '../entity/product/product.entity';
 import { MockConnection } from '../testing/connection.mock';
 
+import { ChannelService } from './channel.service';
 import { MockTranslationUpdaterService } from './helpers/translation-updater.mock';
 import { TranslationUpdaterService } from './helpers/translation-updater.service';
 import { ProductService } from './product.service';
@@ -23,6 +24,7 @@ describe('ProductService', () => {
                 ProductService,
                 { provide: TranslationUpdaterService, useClass: MockTranslationUpdaterService },
                 { provide: Connection, useClass: MockConnection },
+                { provide: ChannelService, useClass: MockChannelService },
             ],
         }).compile();
 
@@ -38,7 +40,7 @@ describe('ProductService', () => {
         });
 
         it('saves a new Product with the correct properties', async () => {
-            await productService.create(new RequestContext(''), {
+            await productService.create(new RequestContext(), {
                 translations: [
                     {
                         languageCode: LanguageCode.en,
@@ -60,7 +62,7 @@ describe('ProductService', () => {
         });
 
         it('saves each ProductTranslation', async () => {
-            await productService.create(new RequestContext(''), {
+            await productService.create(new RequestContext(), {
                 translations: [
                     {
                         languageCode: LanguageCode.en,
@@ -93,7 +95,7 @@ describe('ProductService', () => {
             ];
             connection.registerMockRepository(ProductOptionGroup).find.mockReturnValue(mockOptionGroups);
 
-            await productService.create(new RequestContext(''), {
+            await productService.create(new RequestContext(), {
                 translations: [
                     {
                         languageCode: LanguageCode.en,
@@ -124,7 +126,7 @@ describe('ProductService', () => {
                 image: 'some-image',
                 translations: [],
             };
-            await productService.update(new RequestContext(''), dto);
+            await productService.update(new RequestContext(), dto);
             const savedProduct = connection.manager.save.mock.calls[0][0];
 
             expect(translationUpdater.diff).toHaveBeenCalledTimes(1);
@@ -133,3 +135,7 @@ describe('ProductService', () => {
         });
     });
 });
+
+class MockChannelService {
+    assignToChannels = jest.fn();
+}

+ 5 - 3
server/src/service/product.service.ts

@@ -1,12 +1,11 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import { CreateProductInput, LanguageCode, UpdateProductInput } from 'shared/generated-types';
+import { CreateProductInput, UpdateProductInput } from 'shared/generated-types';
 import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../api/common/request-context';
-import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
-import { ListQueryOptions, NullOptionals } from '../common/types/common-types';
+import { ListQueryOptions } from '../common/types/common-types';
 import { Translated } from '../common/types/locale-types';
 import { assertFound } from '../common/utils';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
@@ -14,6 +13,7 @@ import { ProductTranslation } from '../entity/product/product-translation.entity
 import { Product } from '../entity/product/product.entity';
 import { I18nError } from '../i18n/i18n-error';
 
+import { ChannelService } from './channel.service';
 import { buildListQuery } from './helpers/build-list-query';
 import { createTranslatable } from './helpers/create-translatable';
 import { translateDeep } from './helpers/translate-entity';
@@ -25,6 +25,7 @@ export class ProductService {
     constructor(
         @InjectConnection() private connection: Connection,
         private translationUpdaterService: TranslationUpdaterService,
+        private channelService: ChannelService,
     ) {}
 
     findAll(
@@ -70,6 +71,7 @@ export class ProductService {
 
     async create(ctx: RequestContext, createProductDto: CreateProductInput): Promise<Translated<Product>> {
         const save = createTranslatable(Product, ProductTranslation, async p => {
+            this.channelService.assignToChannels(p, ctx);
             const { optionGroupCodes } = createProductDto;
             if (optionGroupCodes && optionGroupCodes.length) {
                 const optionGroups = await this.connection.getRepository(ProductOptionGroup).find();

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

@@ -0,0 +1,86 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { Connection } from 'typeorm';
+
+import {
+    CUSTOMER_ROLE_CODE,
+    CUSTOMER_ROLE_DESCRIPTION,
+    SUPER_ADMIN_ROLE_CODE,
+    SUPER_ADMIN_ROLE_DESCRIPTION,
+} from '../common/constants';
+import { Permission } from '../entity/role/permission';
+import { Role } from '../entity/role/role.entity';
+import { I18nError } from '../i18n/i18n-error';
+
+import { ChannelService } from './channel.service';
+import { ActiveConnection } from './helpers/connection.decorator';
+
+export interface CreateRoleDto {
+    code: string;
+    description: string;
+    permissions: Permission[];
+}
+
+@Injectable()
+export class RoleService {
+    constructor(@ActiveConnection() private connection: Connection, private channelService: ChannelService) {}
+
+    async initRoles() {
+        await this.ensureSuperAdminRoleExists();
+        await this.ensureCustomerRoleExists();
+    }
+
+    getSuperAdminRole(): Promise<Role> {
+        return this.getRoleByCode(SUPER_ADMIN_ROLE_CODE).then(role => {
+            if (!role) {
+                throw new I18nError(`error.super-admin-role-not-found`);
+            }
+            return role;
+        });
+    }
+
+    getCustomerRole(): Promise<Role> {
+        return this.getRoleByCode(CUSTOMER_ROLE_CODE).then(role => {
+            if (!role) {
+                throw new I18nError(`error.customer-role-not-found`);
+            }
+            return role;
+        });
+    }
+
+    async create(input: CreateRoleDto): Promise<Role> {
+        const role = new Role(input);
+        role.channels = [this.channelService.getDefaultChannel()];
+        return this.connection.manager.save(role);
+    }
+
+    private getRoleByCode(code: string): Promise<Role | undefined> {
+        return this.connection.getRepository(Role).findOne({
+            where: { code },
+        });
+    }
+
+    private async ensureSuperAdminRoleExists() {
+        try {
+            await this.getSuperAdminRole();
+        } catch (err) {
+            await this.create({
+                code: SUPER_ADMIN_ROLE_CODE,
+                description: SUPER_ADMIN_ROLE_DESCRIPTION,
+                permissions: Object.values(Permission),
+            });
+        }
+    }
+
+    private async ensureCustomerRoleExists() {
+        try {
+            await this.getCustomerRole();
+        } catch (err) {
+            await this.create({
+                code: CUSTOMER_ROLE_CODE,
+                description: CUSTOMER_ROLE_DESCRIPTION,
+                permissions: [Permission.Authenticated],
+            });
+        }
+    }
+}

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

@@ -16,6 +16,7 @@ import { ProductOptionGroupService } from './product-option-group.service';
 import { ProductOptionService } from './product-option.service';
 import { ProductVariantService } from './product-variant.service';
 import { ProductService } from './product.service';
+import { RoleService } from './role.service';
 
 const exportedProviders = [
     AdministratorService,
@@ -28,6 +29,7 @@ const exportedProviders = [
     ProductOptionGroupService,
     ProductService,
     ProductVariantService,
+    RoleService,
 ];
 
 /**
@@ -43,9 +45,10 @@ const exportedProviders = [
     exports: exportedProviders,
 })
 export class ServiceModule implements OnModuleInit {
-    constructor(private channelService: ChannelService) {}
+    constructor(private channelService: ChannelService, private roleService: RoleService) {}
 
     async onModuleInit() {
         await this.channelService.initChannels();
+        await this.roleService.initRoles();
     }
 }

Some files were not shown because too many files changed in this diff