Skip to main content

Authorisation

RecoApp implements fine-grained, shop-scoped authorisation using CASL (Isomorphic Authorisation) to enforce multi-tenant data isolation. This ensures that each shop can only access their own data and prevents cross-shop data leakage.

Overview

Authorisation in RecoApp is distinct from authentication:

  • Authentication verifies who you are (covered in Authentication)
  • Authorisation determines what you can do with the resources you have access to

Key Principles

  1. Shop-Scoped Access: Each shop can only access resources where shop_id matches their own
  2. Explicit Denials: Cross-shop access is explicitly denied (defence in depth)
  3. Resource-Level Control: Permissions defined per resource type (Shop, Pixel, ProductAlternative, etc.)
  4. Action-Based: Permissions defined by CRUD actions (Create, Read, Update, Delete, Manage)
  5. Request-Scoped Context: Shop context isolated per request (thread-safe)
info

Authorisation is always applied after authentication. You must pass authentication guards before authorisation guards are evaluated.


Architecture

Components

ComponentLocationPurpose
AbilityFactoryrecoapp-api/src/authorisation/ability.factory.tsDefines permissions for shops and resources
ShopScopedGuardrecoapp-api/src/authorisation/guards/shop-scoped.guard.tsEnforces permissions at controller level
ShopContextServicerecoapp-api/src/shopify/shop-context.service.tsRequest-scoped storage for authenticated shop
@CheckPoliciesrecoapp-api/src/authorisation/decorators/check-policies.decorator.tsDecorator to define required permissions
@CurrentShoprecoapp-api/src/shopify/decorators/current-shop.decorator.tsDecorator to inject authenticated shop

CASL Abilities

RecoApp uses CASL for defining and checking permissions. CASL provides a declarative way to define authorisation rules.

Actions

Location: recoapp-api/src/authorisation/ability.factory.ts (Lines 14-20)

enum Action {
Manage = 'manage', // Special: matches any action
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}

Resource Types

Location: recoapp-api/src/authorisation/ability.factory.ts (Lines 25-43)

type Subjects =
| 'Shop'
| 'Pixel'
| 'ProductAlternative'
| 'WebPixel'
| 'all';

Ability Factory

The AbilityFactory service creates CASL abilities based on the authenticated shop.

Location: recoapp-api/src/authorisation/ability.factory.ts (Lines 91-123)

createForShop(shop: ShopRecord): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
createMongoAbility,
);

// 🔒 Core Multi-Tenant Rule: Shop can only access their own data
can(Action.Manage, 'all', { shop_id: shop.shop_id });

// Shop can read and update their own shop record
can(Action.Read, Shop, { id: shop.id });
can(Action.Update, Shop, { id: shop.id });

// Shop CANNOT delete their own record (use uninstall webhook)
cannot(Action.Delete, Shop);

// 🔒 Explicit Denial: Cannot access other shops' data
cannot(Action.Manage, 'all', {
shop_id: { $ne: shop.shop_id }
});

return build({
detectSubjectType: (item) =>
item.constructor.name as ExtractSubjectType<Subjects>,
});
}

Permission Rules Explained

1. Core Rule: Shop-Scoped Access

can(Action.Manage, 'all', { shop_id: shop.shop_id });

Meaning: A shop can perform any action (Manage = wildcard) on any resource where the shop_id field matches their own shop ID.

Examples:

  • Shop A (shop_id: gid://shopify/Shop/12345) can read Pixel records with shop_id: gid://shopify/Shop/12345
  • Shop A cannot read Pixel records with shop_id: gid://shopify/Shop/99999

2. Shop Record Permissions

can(Action.Read, Shop, { id: shop.id });
can(Action.Update, Shop, { id: shop.id });
cannot(Action.Delete, Shop);

Meaning:

  • Shops can read their own Shop record (by record ID)
  • Shops can update their own Shop record
  • Shops cannot delete their own Shop record (deletion handled by uninstall webhook)

3. Explicit Denial

cannot(Action.Manage, 'all', { shop_id: { $ne: shop.shop_id } });

Meaning: Explicitly deny all actions on resources where shop_id does not match the authenticated shop's ID.

Why: Defence in depth. Even if the core rule is misconfigured, this explicit denial prevents cross-shop access.

tip

CASL evaluates rules in order. Later rules can override earlier rules. The explicit denial is added last to ensure it takes precedence.


Shop-Scoped Guard

The ShopScopedGuard enforces authorisation at the controller level using CASL abilities.

Location: recoapp-api/src/authorisation/guards/shop-scoped.guard.ts

How It Works

Implementation

Location: recoapp-api/src/authorisation/guards/shop-scoped.guard.ts (Lines 43-74)

async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. Get policy handlers from @CheckPolicies decorator
const policyHandlers = this.reflector.get<PolicyHandler[]>(
CHECK_POLICIES_KEY,
context.getHandler(),
);

// 2. If no policies defined, allow access
if (!policyHandlers || policyHandlers.length === 0) {
return true;
}

// 3. Get authenticated shop from context
const shop = this.shopContext.getShop();
if (!shop) {
throw new ForbiddenException('Shop context not found');
}

// 4. Create ability for this shop
const ability = this.abilityFactory.createForShop(shop);

// 5. Check all policy handlers
try {
const results = policyHandlers.map((handler) => handler(ability));
const allPassed = results.every((result) => result === true);

if (!allPassed) {
throw new ForbiddenException('Insufficient permissions');
}

return true;
} catch (error) {
throw new ForbiddenException('Authorisation check failed');
}
}

Usage Patterns

Basic Usage

@Get('/pixels')
@UseGuards(ShopifyAuthGuard, ShopScopedGuard)
@CheckPolicies((ability) => ability.can('read', 'Pixel'))
async getPixels(@CurrentShop() shop: ShopRecord) {
// Only returns pixels for authenticated shop
return this.pixelsService.findAll(shop.shop_id);
}

Multiple Policies

@Patch('/:id')
@UseGuards(ShopifyAuthGuard, ShopScopedGuard)
@CheckPolicies(
(ability) => ability.can('update', 'ProductAlternative'),
(ability) => ability.cannot('delete', 'ProductAlternative')
)
async update(
@Param('id') id: string,
@Body() updateDto: UpdateDto,
@CurrentShop() shop: ShopRecord
) {
// Can update but not delete
return this.service.update(id, updateDto, shop.shop_id);
}

Instance-Based Checks

@Delete('/:id')
@UseGuards(ShopifyAuthGuard, ShopScopedGuard)
async delete(
@Param('id') id: string,
@CurrentShop() shop: ShopRecord
) {
// Fetch the resource first
const resource = await this.service.findOne(id);

// Check if shop can delete this specific instance
const ability = this.abilityFactory.createForShop(shop);
if (ability.cannot('delete', resource)) {
throw new ForbiddenException('Cannot delete this resource');
}

return this.service.delete(id);
}

Resource-Specific Abilities

// Check ability for a specific resource subject
@Get('/shop/:shopId')
@UseGuards(ShopifyAuthGuard, ShopScopedGuard)
@CheckPolicies((ability) => ability.can('read', 'Shop'))
async getShopById(
@Param('shopId') shopId: string,
@CurrentShop() shop: ShopRecord
) {
// Verify the shopId matches authenticated shop
if (shopId !== shop.shop_id) {
throw new ForbiddenException('Cannot access other shops data');
}

return this.shopsService.findOne(shopId);
}

Request-Scoped Context

Shop context is stored in a request-scoped service to prevent data leakage between concurrent requests.

Location: recoapp-api/src/shopify/shop-context.service.ts

Why Request-Scoped?

Problem: Global or singleton services can leak data between requests in async environments.

Solution: Request-scoped services create a new instance per HTTP request.

@Injectable({ scope: Scope.REQUEST })
export class ShopContextService {
private shop: ShopRecord | null = null;

setShop(shop: ShopRecord): void {
this.shop = shop;
}

getShop(): ShopRecord | null {
return this.shop;
}
}

Flow

warning

Thread Safety: Request-scoped services are created per request in NestJS. However, ensure you're not storing shop context in global variables or singleton services, as this can cause data leakage in high-concurrency scenarios.


Decorators

@CheckPolicies

Defines required permissions for an endpoint.

Location: recoapp-api/src/authorisation/decorators/check-policies.decorator.ts

Usage:

@CheckPolicies((ability) => ability.can('read', 'Pixel'))

Multiple Policies:

@CheckPolicies(
(ability) => ability.can('read', 'Pixel'),
(ability) => ability.cannot('delete', 'Pixel')
)

Type Definition:

type PolicyHandler = (ability: AppAbility) => boolean;

export const CheckPolicies = (...handlers: PolicyHandler[]) =>
SetMetadata(CHECK_POLICIES_KEY, handlers);

@CurrentShop

Injects the authenticated shop into controller methods.

Location: recoapp-api/src/shopify/decorators/current-shop.decorator.ts

Usage:

async getShop(@CurrentShop() shop: ShopRecord) {
return shop; // Already authenticated and authorised
}

Implementation:

export const CurrentShop = createParamDecorator(
(data: unknown, ctx: ExecutionContext): ShopRecord => {
const request = ctx.switchToHttp().getRequest();
return request.shop; // Attached by ShopifyAuthGuard
},
);
tip

@CurrentShop() can only be used after ShopifyAuthGuard has run, as the guard attaches the shop to the request object.


Multi-Tenant Data Isolation

Database-Level Filtering

All queries must filter by shop_id to ensure multi-tenant isolation.

Example Service Method:

async findAll(shopId: string): Promise<PixelRecord[]> {
const xata = this.xata.getClient();

return await xata.db.pixels
.filter({ shop_id: shopId }) // ✅ Always filter by shop_id
.getAll();
}
danger

Never query without shop_id filtering!

// ❌ BAD: Returns data for all shops
return await xata.db.pixels.getAll();

// ✅ GOOD: Returns data only for authenticated shop
return await xata.db.pixels
.filter({ shop_id: shop.shop_id })
.getAll();

Foreign Key Constraints

Database schema enforces referential integrity with foreign key constraints.

Example Schema (Xata):

{
"name": "pixels",
"columns": [
{
"name": "shop_id",
"type": "link",
"link": {
"table": "shops"
}
}
]
}

This ensures:

  • Pixel records must reference a valid shop
  • Cannot create pixels for non-existent shops
  • Cascading deletes when shop is removed

Defence in Depth

RecoApp implements multiple layers of security to prevent unauthorised access:

Layer 1: Network Security

  • HTTPS enforcement
  • CORS whitelist (dynamic from database)

Layer 2: Authentication

  • Session token validation (JWT)
  • Pixel API key validation
  • Webhook HMAC validation
  • See Authentication for details

Layer 3: Authorisation Guards

  • ShopScopedGuard checks CASL abilities
  • @CheckPolicies defines required permissions
  • Request-scoped context prevents leakage

Layer 4: Service-Level Filtering

  • All queries filter by shop_id
  • No cross-shop data retrieval
  • Explicit validation in service methods

Layer 5: Database Constraints

  • Foreign key constraints
  • Unique constraints on shop-scoped fields
  • Database-level validation
tip

Defence in Depth Principle: Even if one layer fails, the other layers provide protection. For example:

  • If CASL rules are misconfigured, service-level filtering still prevents cross-shop access
  • If service-level filtering is missing, database constraints prevent invalid data
  • If authentication is bypassed (e.g., super admin), authorisation still checks shop_id matching

Error Handling

401 Unauthorized

Returned when authentication fails (before authorisation is checked).

Causes:

  • Missing/invalid session token
  • Invalid API key
  • Invalid HMAC signature
  • Shop not found
  • Shop uninstalled

Solution: Check Authentication documentation.

403 Forbidden

Returned when authentication succeeds but authorisation fails.

Causes:

  • Shop attempting to access another shop's data
  • Missing required permissions
  • Resource action not allowed

Example Response:

{
"statusCode": 403,
"message": "Insufficient permissions",
"error": "Forbidden"
}

Debugging:

  1. Check CASL ability rules in ability.factory.ts
  2. Verify @CheckPolicies decorator on endpoint
  3. Confirm resource has correct shop_id field
  4. Check service method filters by shop_id

500 Internal Server Error

Returned when authorisation logic encounters an unexpected error.

Common Causes:

  • ShopContextService not injected correctly
  • Shop context not set by authentication guard
  • Malformed CASL ability rules

Solution: Check logs for detailed error messages.


Testing Authorisation

Unit Testing Abilities

import { Test } from '@nestjs/testing';
import { AbilityFactory } from './ability.factory';

describe('AbilityFactory', () => {
let factory: AbilityFactory;

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [AbilityFactory],
}).compile();

factory = module.get<AbilityFactory>(AbilityFactory);
});

it('should allow shop to read own pixels', () => {
const shop = {
shop_id: 'gid://shopify/Shop/12345',
id: 'rec_123'
};
const ability = factory.createForShop(shop);

const pixel = { shop_id: 'gid://shopify/Shop/12345' };
expect(ability.can('read', pixel)).toBe(true);
});

it('should deny shop from reading other shops pixels', () => {
const shop = {
shop_id: 'gid://shopify/Shop/12345',
id: 'rec_123'
};
const ability = factory.createForShop(shop);

const pixel = { shop_id: 'gid://shopify/Shop/99999' };
expect(ability.can('read', pixel)).toBe(false);
});

it('should deny shop from deleting own shop record', () => {
const shop = {
shop_id: 'gid://shopify/Shop/12345',
id: 'rec_123'
};
const ability = factory.createForShop(shop);

expect(ability.can('delete', shop)).toBe(false);
});
});

Integration Testing Guards

import { Test } from '@nestjs/testing';
import { ShopScopedGuard } from './shop-scoped.guard';
import { ShopContextService } from '../shopify/shop-context.service';
import { AbilityFactory } from './ability.factory';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';

describe('ShopScopedGuard', () => {
let guard: ShopScopedGuard;
let shopContext: ShopContextService;

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
ShopScopedGuard,
ShopContextService,
AbilityFactory,
{ provide: Reflector, useValue: mockReflector },
],
}).compile();

guard = module.get<ShopScopedGuard>(ShopScopedGuard);
shopContext = module.get<ShopContextService>(ShopContextService);
});

it('should allow access if all policies pass', async () => {
const shop = { shop_id: 'gid://shopify/Shop/12345', id: 'rec_123' };
shopContext.setShop(shop);

const context = mockExecutionContext([
(ability) => ability.can('read', 'Pixel'),
]);

const result = await guard.canActivate(context);
expect(result).toBe(true);
});

it('should deny access if any policy fails', async () => {
const shop = { shop_id: 'gid://shopify/Shop/12345', id: 'rec_123' };
shopContext.setShop(shop);

const context = mockExecutionContext([
(ability) => ability.can('delete', shop), // Should fail
]);

await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException
);
});
});

E2E Testing

describe('Pixels (e2e)', () => {
let app: INestApplication;
let shopAToken: string;
let shopBToken: string;

beforeAll(async () => {
// Setup test app
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();

app = moduleFixture.createNestApplication();
await app.init();

// Create test shops and get tokens
shopAToken = await createTestShopAndGetToken('shop-a.myshopify.com');
shopBToken = await createTestShopAndGetToken('shop-b.myshopify.com');
});

it('should allow shop to read own pixels', () => {
return request(app.getHttpServer())
.get('/pixels')
.set('Authorization', `Bearer ${shopAToken}`)
.expect(200)
.expect((res) => {
// All pixels should belong to Shop A
res.body.forEach((pixel) => {
expect(pixel.shop_id).toBe('gid://shopify/Shop/12345');
});
});
});

it('should deny shop from accessing other shops pixels', () => {
// Shop B tries to access Shop A's pixel
const shopAPixelId = 'rec_shopA_pixel';

return request(app.getHttpServer())
.get(`/pixels/${shopAPixelId}`)
.set('Authorization', `Bearer ${shopBToken}`)
.expect(403);
});
});

Best Practises

1. Always Apply Both Guards

// ✅ GOOD: Authentication + Authorisation
@UseGuards(ShopifyAuthGuard, ShopScopedGuard)
@CheckPolicies((ability) => ability.can('read', 'Pixel'))

// ❌ BAD: Only authentication
@UseGuards(ShopifyAuthGuard)

2. Always Filter by shop_id

// ✅ GOOD
async findAll(shopId: string) {
return await xata.db.pixels
.filter({ shop_id: shopId })
.getAll();
}

// ❌ BAD
async findAll() {
return await xata.db.pixels.getAll(); // Returns all shops' data!
}

3. Verify Instance Ownership

// ✅ GOOD: Verify resource belongs to shop before operations
async update(id: string, dto: UpdateDto, shopId: string) {
const resource = await this.findOne(id);

if (resource.shop_id !== shopId) {
throw new ForbiddenException('Cannot update other shops resources');
}

return await this.repository.update(id, dto);
}

4. Use Request-Scoped Services for Context

// ✅ GOOD: Request-scoped
@Injectable({ scope: Scope.REQUEST })
export class ShopContextService { ... }

// ❌ BAD: Singleton (can leak between requests)
@Injectable()
export class ShopContextService { ... }

5. Log Authorisation Failures

if (ability.cannot('delete', resource)) {
this.logger.warn(
`Shop ${shop.shop_id} attempted to delete resource ${resource.id} owned by ${resource.shop_id}`
);
throw new ForbiddenException('Cannot delete this resource');
}

6. Test Multi-Tenant Isolation

Always write tests that verify:

  • Shop A cannot access Shop B's data
  • Shop A cannot modify Shop B's data
  • Shop A cannot delete Shop B's data
  • Queries properly filter by shop_id

Troubleshooting

"Shop context not found"

Cause: ShopifyAuthGuard not applied before ShopScopedGuard

Solution: Ensure guards are applied in correct order:

@UseGuards(ShopifyAuthGuard, ShopScopedGuard)

"Insufficient permissions"

Cause: CASL ability rules deny the requested action

Debug:

  1. Check ability.factory.ts rules
  2. Verify resource has shop_id field
  3. Confirm shop_id matches authenticated shop
  4. Check @CheckPolicies decorator on endpoint

"Cannot read property 'shop_id' of undefined"

Cause: Resource doesn't have shop_id field or is null

Solution:

  1. Verify resource is properly fetched from database
  2. Ensure database schema includes shop_id field
  3. Add null checks before ability checks

Cross-Shop Access Still Possible

Possible Causes:

  1. Service method doesn't filter by shop_id
  2. @CheckPolicies decorator missing
  3. ShopScopedGuard not applied
  4. CASL rules misconfigured

Debug Checklist:

  • Controller has @UseGuards(ShopifyAuthGuard, ShopScopedGuard)
  • Controller has @CheckPolicies(...) decorator
  • Service method filters by shop_id
  • Database schema has shop_id foreign key
  • Ability rules include shop_id matching

Security Considerations

1. Implicit vs Explicit Rules

CASL uses an "implicit deny" model by default:

  • If no rule allows an action, it's denied
  • Explicit cannot() rules provide defence in depth

Recommendation: Always add explicit denials for critical paths:

// Implicit: If not explicitly allowed, denied
can(Action.Manage, 'all', { shop_id: shop.shop_id });

// Explicit: Explicitly deny cross-shop access (defence in depth)
cannot(Action.Manage, 'all', { shop_id: { $ne: shop.shop_id } });

2. Subject Type Detection

CASL needs to detect the type of subject being checked:

build({
detectSubjectType: (item) =>
item.constructor.name as ExtractSubjectType<Subjects>,
});

Limitation: This relies on constructor names, which can be minified/mangled in production.

Recommendation: For production, consider explicit subject types:

// Define subject type explicitly
type PixelSubject = { __typename: 'Pixel', shop_id: string };

// Use in ability checks
ability.can('read', { __typename: 'Pixel', shop_id: 'gid://...' });

3. Dynamic Conditions

CASL supports dynamic conditions based on resource properties:

// Allow only if pixel.status is 'active'
can('update', 'Pixel', {
shop_id: shop.shop_id,
status: 'active'
});

Use sparingly - complex conditions can be difficult to test and debug.

4. Ability Caching

Current: Abilities are created per request (no caching)

Consideration: For high-traffic applications, consider caching abilities per shop:

@Injectable()
export class AbilityCache {
private cache = new Map<string, AppAbility>();

get(shopId: string): AppAbility | undefined {
return this.cache.get(shopId);
}

set(shopId: string, ability: AppAbility): void {
this.cache.set(shopId, ability);
}
}

Warning: Cache invalidation must be implemented carefully to avoid stale permissions.


Further Reading