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
- Shop-Scoped Access: Each shop can only access resources where
shop_idmatches their own - Explicit Denials: Cross-shop access is explicitly denied (defence in depth)
- Resource-Level Control: Permissions defined per resource type (Shop, Pixel, ProductAlternative, etc.)
- Action-Based: Permissions defined by CRUD actions (Create, Read, Update, Delete, Manage)
- Request-Scoped Context: Shop context isolated per request (thread-safe)
Authorisation is always applied after authentication. You must pass authentication guards before authorisation guards are evaluated.
Architecture
Components
| Component | Location | Purpose |
|---|---|---|
| AbilityFactory | recoapp-api/src/authorisation/ability.factory.ts | Defines permissions for shops and resources |
| ShopScopedGuard | recoapp-api/src/authorisation/guards/shop-scoped.guard.ts | Enforces permissions at controller level |
| ShopContextService | recoapp-api/src/shopify/shop-context.service.ts | Request-scoped storage for authenticated shop |
| @CheckPolicies | recoapp-api/src/authorisation/decorators/check-policies.decorator.ts | Decorator to define required permissions |
| @CurrentShop | recoapp-api/src/shopify/decorators/current-shop.decorator.ts | Decorator 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 withshop_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.
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
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
},
);
@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();
}
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
ShopScopedGuardchecks CASL abilities@CheckPoliciesdefines 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
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:
- Check CASL ability rules in
ability.factory.ts - Verify
@CheckPoliciesdecorator on endpoint - Confirm resource has correct
shop_idfield - Check service method filters by
shop_id
500 Internal Server Error
Returned when authorisation logic encounters an unexpected error.
Common Causes:
ShopContextServicenot 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:
- Check
ability.factory.tsrules - Verify resource has
shop_idfield - Confirm
shop_idmatches authenticated shop - Check
@CheckPoliciesdecorator on endpoint
"Cannot read property 'shop_id' of undefined"
Cause: Resource doesn't have shop_id field or is null
Solution:
- Verify resource is properly fetched from database
- Ensure database schema includes
shop_idfield - Add null checks before ability checks
Cross-Shop Access Still Possible
Possible Causes:
- Service method doesn't filter by
shop_id @CheckPoliciesdecorator missingShopScopedGuardnot applied- CASL rules misconfigured
Debug Checklist:
- Controller has
@UseGuards(ShopifyAuthGuard, ShopScopedGuard) - Controller has
@CheckPolicies(...)decorator - Service method filters by
shop_id - Database schema has
shop_idforeign 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.
Related Documentation
- Authentication - Authentication mechanisms and guards
- Installation: recoapp-api - API setup and configuration