Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Refactor how permissions get serialized for sessions into using a new strategy #3222

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ export class DynamicFormInputComponent
({
id: this.listId++,
control: new UntypedFormControl(getConfigArgValue(value)),
} as InputListItem),
}) as InputListItem,
);
this.renderList$.next();
}
Expand Down
9 changes: 4 additions & 5 deletions packages/core/src/api/resolvers/base/base-auth.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import {
AuthenticationResult as ShopAuthenticationResult,
PasswordValidationError,
AuthenticationResult as ShopAuthenticationResult,
} from '@vendure/common/lib/generated-shop-types';
import {
AuthenticationResult as AdminAuthenticationResult,
CurrentUser,
CurrentUserChannel,
MutationAuthenticateArgs,
MutationLoginArgs,
Success,
Expand All @@ -22,7 +21,6 @@ import { NATIVE_AUTH_STRATEGY_NAME } from '../../../config/auth/native-authentic
import { ConfigService } from '../../../config/config.service';
import { LogLevel } from '../../../config/logger/vendure-logger';
import { User } from '../../../entity/user/user.entity';
import { getUserChannelsPermissions } from '../../../service/helpers/utils/get-user-channels-permissions';
import { AdministratorService } from '../../../service/services/administrator.service';
import { AuthService } from '../../../service/services/auth.service';
import { UserService } from '../../../service/services/user.service';
Expand Down Expand Up @@ -143,11 +141,12 @@ export class BaseAuthResolver {
/**
* Exposes a subset of the User properties which we want to expose to the public API.
*/
protected publiclyAccessibleUser(user: User): CurrentUser {
protected async publiclyAccessibleUser(user: User): Promise<CurrentUser> {
return {
id: user.id,
identifier: user.identifier,
channels: getUserChannelsPermissions(user) as CurrentUserChannel[],
channels:
await this.configService.authOptions.rolePermissionResolverStrategy.resolvePermissions(user),
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
import { ID } from '@vendure/common/lib/shared-types';

import { RequestContext } from '../../api';
import { EntityNotFoundError, Injector } from '../../common';
import { TransactionalConnection } from '../../connection';
import { Channel, Role, User } from '../../entity';
import { ChannelRole } from '../../entity/channel-role/channel-role.entity';
import { UserChannelPermissions } from '../../service/helpers/utils/get-user-channels-permissions';

import { RolePermissionResolverStrategy } from './role-permission-resolver-strategy';

export class ChannelRolePermissionResolverStrategy implements RolePermissionResolverStrategy {
private connection: TransactionalConnection;
private userService: import('../../service/services/user.service').UserService;

async init(injector: Injector) {
this.connection = injector.get(TransactionalConnection);
this.userService = injector.get((await import('../../service/services/user.service.js')).UserService);
}

/**
* @description TODO
*/
async persistUserAndTheirRoles(ctx: RequestContext, user: User, roleIds: ID[]): Promise<void> {
const roles =
// Empty array is important because that would return every row when used in the query
roleIds.length === 0
? []
: await this.connection.getRepository(ctx, Role).findBy(roleIds.map(id => ({ id })));
for (const roleId of roleIds) {
const foundRole = roles.find(role => role.id === roleId);
if (!foundRole) throw new EntityNotFoundError('Role', roleId);
}

// TODO we are relying here on the `roles` relation existing, it could be missing if you query
// user entries without supplying the relations argument, for example:
// this happens when you create a new user via `.save()` because the default reloading doesnt fetch relations
// Q: Should we simply refetch inside her to be more fault tolerant? Could be fixed on the outside too
const currentUser = user.roles ? user : await this.userService.getUserById(ctx, user.id);
if (!currentUser) throw new EntityNotFoundError('User', user.id);

const rolesAdded = roles.filter(role => !currentUser.roles.some(userRole => userRole.id === role.id));
const rolesRemoved = currentUser.roles.filter(role => roleIds.indexOf(role.id) === -1);

// Copy so as to not mutate the original user object when setting roles
const userCopy = new User({ ...currentUser, roles });
// Lets keep the roles on the user eventhough this strategy technically doesnt need them there
// This makes it possible to switch back to the default strategy without breaking anything
const newUser = await this.connection.getRepository(ctx, User).save(userCopy);

if (rolesAdded.length > 0) {
// TODO these would come from the new `channelIds` argument from the UI
// For now as proof of concept, lets just always assign the default channel
// Test the permissions by manually creating rows for your channels
const channels = await this.connection
.getRepository(ctx, Channel)
.findBy([{ code: DEFAULT_CHANNEL_CODE }]);

const newChannelRoleEntries = channels.flatMap(channel =>
rolesAdded.map(role => new ChannelRole({ user: newUser, channel, role })),
);

await this.connection
.getRepository(ctx, ChannelRole)
.save(newChannelRoleEntries, { reload: false });
}

// TODO could be reduced to one query
// potentially improve later once we're happy with the `persistUserAndTheirRoles` level of abstraction
for (const role of rolesRemoved) {
await this.connection
.getRepository(ctx, ChannelRole)
.delete({ role: { id: role.id }, user: { id: user.id } });
}
}

async resolvePermissions(user: User): Promise<UserChannelPermissions[]> {
const channelRoleEntries = await this.connection.rawConnection.getRepository(ChannelRole).find({
where: { user: { id: user.id } },
relations: ['user', 'channel', 'role'],
});

const channelRolePermissions = new Array<UserChannelPermissions>(channelRoleEntries.length);
for (let i = 0; i < channelRoleEntries.length; i++) {
channelRolePermissions[i] = {
id: channelRoleEntries[i].channel.id,
token: channelRoleEntries[i].channel.token,
code: channelRoleEntries[i].channel.code,
permissions: channelRoleEntries[i].role.permissions,
};
}
channelRoleEntries.sort((a, b) => (a.id < b.id ? -1 : 1));
DanielBiegler marked this conversation as resolved.
Show resolved Hide resolved

return channelRolePermissions;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ID } from '@vendure/common/lib/shared-types';

import { RequestContext } from '../../api';
import { EntityNotFoundError, Injector } from '../../common';
import { TransactionalConnection } from '../../connection';
import { Role, User } from '../../entity';
import {
getChannelPermissions,
UserChannelPermissions,
} from '../../service/helpers/utils/get-user-channels-permissions';

import { RolePermissionResolverStrategy } from './role-permission-resolver-strategy';

export class DefaultRolePermissionResolverStrategy implements RolePermissionResolverStrategy {
private connection: TransactionalConnection;

async init(injector: Injector) {
this.connection = injector.get(TransactionalConnection);
}

/**
* @description TODO
*/
async persistUserAndTheirRoles(ctx: RequestContext, user: User, roleIds: ID[]): Promise<void> {
const roles =
// Empty array is important because that would return every row when used in the query
roleIds.length === 0
? []
: await this.connection.getRepository(ctx, Role).findBy(roleIds.map(id => ({ id })));
for (const roleId of roleIds) {
const foundRole = roles.find(role => role.id === roleId);
if (!foundRole) throw new EntityNotFoundError('Role', roleId);
}
// Copy so as to not mutate the original user object when setting roles
const userCopy = new User({ ...user, roles });
await this.connection.getRepository(ctx, User).save(userCopy, { reload: false });
}

async resolvePermissions(user: User): Promise<UserChannelPermissions[]> {
return getChannelPermissions(user.roles);
}
}
24 changes: 24 additions & 0 deletions packages/core/src/config/auth/role-permission-resolver-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ID } from '@vendure/common/lib/shared-types';

import { RequestContext } from '../../api';
import { InjectableStrategy } from '../../common';
import { User } from '../../entity';
import { UserChannelPermissions } from '../../service/helpers/utils/get-user-channels-permissions';

/**
* @description TODO
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/**
 * @description
 * A RolePermissionResolverStrategy defines how role-based permissions for a user should be resolved.
 * This strategy is used to determine the permissions assigned to a user based on their roles per channel.
 *
 * By default {@link DefaultRolePermissionResolverStrategy} is used. However, for more complex environments using
 * multiple channels and roles {@link ChannelRolePermissionResolverStrategy} is recommended.
 * 
 * :::info
 *
 * This is configured via the `authOptions.rolePermissionResolverStrategy` properties of your VendureConfig.
 *
 * :::
 *
 * @docsCategory auth
 * @since 3.3.0
 */

*/
export interface RolePermissionResolverStrategy extends InjectableStrategy {
/**
* TODO
*/
persistUserAndTheirRoles(
ctx: RequestContext,
user: User,
/* channelIds: ID[], */ roleIds: ID[],
): Promise<void>;
/**
* @param user User for which you want to retrieve permissions
*/
resolvePermissions(user: User): Promise<UserChannelPermissions[]>;
}
3 changes: 2 additions & 1 deletion packages/core/src/config/config.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Module, OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';

import { ConfigurableOperationDef } from '../common/configurable-operation';
import { Injector } from '../common/injector';
Expand Down Expand Up @@ -83,6 +82,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
sessionCacheStrategy,
passwordHashingStrategy,
passwordValidationStrategy,
rolePermissionResolverStrategy,
} = this.configService.authOptions;
const { taxZoneStrategy, taxLineCalculationStrategy } = this.configService.taxOptions;
const { jobQueueStrategy, jobBufferStorageStrategy } = this.configService.jobQueueOptions;
Expand Down Expand Up @@ -117,6 +117,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
sessionCacheStrategy,
passwordHashingStrategy,
passwordValidationStrategy,
rolePermissionResolverStrategy,
assetNamingStrategy,
assetPreviewStrategy,
assetStorageStrategy,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/config/default-config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { LanguageCode } from '@vendure/common/lib/generated-types';
import {
DEFAULT_AUTH_TOKEN_HEADER_KEY,
DEFAULT_CHANNEL_TOKEN_KEY,
SUPER_ADMIN_USER_IDENTIFIER,
SUPER_ADMIN_USER_PASSWORD,
DEFAULT_CHANNEL_TOKEN_KEY,
} from '@vendure/common/lib/shared-constants';
import { randomBytes } from 'crypto';

Expand All @@ -17,6 +17,7 @@ import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-previe
import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storage-strategy';
import { BcryptPasswordHashingStrategy } from './auth/bcrypt-password-hashing-strategy';
import { DefaultPasswordValidationStrategy } from './auth/default-password-validation-strategy';
import { DefaultRolePermissionResolverStrategy } from './auth/default-role-permission-resolver-strategy';
import { NativeAuthenticationStrategy } from './auth/native-authentication-strategy';
import { defaultCollectionFilters } from './catalog/default-collection-filters';
import { DefaultProductVariantPriceCalculationStrategy } from './catalog/default-product-variant-price-calculation-strategy';
Expand Down Expand Up @@ -109,6 +110,7 @@ export const defaultConfig: RuntimeVendureConfig = {
customPermissions: [],
passwordHashingStrategy: new BcryptPasswordHashingStrategy(),
passwordValidationStrategy: new DefaultPasswordValidationStrategy({ minLength: 4 }),
rolePermissionResolverStrategy: new DefaultRolePermissionResolverStrategy(),
},
catalogOptions: {
collectionFilters: defaultCollectionFilters,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export * from './auth/default-password-validation-strategy';
export * from './auth/native-authentication-strategy';
export * from './auth/password-hashing-strategy';
export * from './auth/password-validation-strategy';
export * from './auth/role-permission-resolver-strategy';
export * from './auth/channel-role-permission-resolver-strategy';
export * from './auth/default-role-permission-resolver-strategy';
export * from './catalog/collection-filter';
export * from './catalog/default-collection-filters';
export * from './catalog/default-product-variant-price-selection-strategy';
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/config/vendure-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-str
import { AuthenticationStrategy } from './auth/authentication-strategy';
import { PasswordHashingStrategy } from './auth/password-hashing-strategy';
import { PasswordValidationStrategy } from './auth/password-validation-strategy';
import { RolePermissionResolverStrategy } from './auth/role-permission-resolver-strategy';
import { CollectionFilter } from './catalog/collection-filter';
import { ProductVariantPriceCalculationStrategy } from './catalog/product-variant-price-calculation-strategy';
import { ProductVariantPriceSelectionStrategy } from './catalog/product-variant-price-selection-strategy';
Expand Down Expand Up @@ -473,6 +474,13 @@ export interface AuthOptions {
* @default DefaultPasswordValidationStrategy
*/
passwordValidationStrategy?: PasswordValidationStrategy;
/**
* @todo TODO
* @description TODO By default, it uses the {@link DefaultRolePermissionResolverStrategy}, which TODO
* @since TODO
* @default DefaultRolePermissionResolverStrategy
*/
rolePermissionResolverStrategy?: RolePermissionResolverStrategy;
}

/**
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/entity/channel-role/channel-role.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { DeepPartial } from '@vendure/common/lib/shared-types';
import { Column, Entity, ManyToOne, Unique } from 'typeorm';

import { HasCustomFields } from '../../config';
import { VendureEntity } from '../base/base.entity';
import { Channel } from '../channel/channel.entity';
import { CustomChannelRoleFields } from '../custom-entity-fields';
import { Role } from '../role/role.entity';
import { User } from '../user/user.entity';

/**
* @description
* TODO
*
* @docsCategory entities
*/
@Entity()
@Unique('UNIQUE_CHANNEL_ROLE_PER_USER', ['user', 'channel', 'role'])
export class ChannelRole extends VendureEntity implements HasCustomFields {
constructor(input?: DeepPartial<ChannelRole>) {
super(input);
}

@Column(type => CustomChannelRoleFields)
customFields: CustomChannelRoleFields;

@ManyToOne(type => User, { onDelete: 'CASCADE' })
user: User;

@ManyToOne(type => Channel, { onDelete: 'CASCADE' })
channel: Channel;

@ManyToOne(type => Role, { onDelete: 'CASCADE' })
role: Role;
}
1 change: 1 addition & 0 deletions packages/core/src/entity/custom-entity-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ export class CustomShippingMethodFieldsTranslation {}
export class CustomStockLocationFields {}
export class CustomTaxCategoryFields {}
export class CustomTaxRateFields {}
export class CustomChannelRoleFields {}
export class CustomUserFields {}
export class CustomZoneFields {}
2 changes: 2 additions & 0 deletions packages/core/src/entity/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AuthenticationMethod } from './authentication-method/authentication-met
import { ExternalAuthenticationMethod } from './authentication-method/external-authentication-method.entity';
import { NativeAuthenticationMethod } from './authentication-method/native-authentication-method.entity';
import { Channel } from './channel/channel.entity';
import { ChannelRole } from './channel-role/channel-role.entity';
import { CollectionAsset } from './collection/collection-asset.entity';
import { CollectionTranslation } from './collection/collection-translation.entity';
import { Collection } from './collection/collection.entity';
Expand Down Expand Up @@ -83,6 +84,7 @@ export const coreEntitiesMap = {
AuthenticationMethod,
Cancellation,
Channel,
ChannelRole,
Collection,
CollectionAsset,
CollectionTranslation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { CachedSession, CachedSessionUser } from '../../../config/session-cache/
import { Channel } from '../../../entity/channel/channel.entity';
import { User } from '../../../entity/user/user.entity';
import { ChannelService } from '../../services/channel.service';
import { getUserChannelsPermissions } from '../utils/get-user-channels-permissions';

/**
* @description
Expand Down Expand Up @@ -58,7 +57,9 @@ export class RequestContextService {
}
let session: CachedSession | undefined;
if (user) {
const channelPermissions = user.roles ? getUserChannelsPermissions(user) : [];
const channelPermissions = user.roles
? await this.configService.authOptions.rolePermissionResolverStrategy.resolvePermissions(user)
: [];
session = {
user: {
id: user.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface UserChannelPermissions {

/**
* Returns an array of Channels and permissions on those Channels for the given User.
* @deprecated See `RolePermissionResolverStrategy`
*/
export function getUserChannelsPermissions(user: User): UserChannelPermissions[] {
return getChannelPermissions(user.roles);
Expand Down
Loading
Loading