Day#13 Scoped Access Done Right (12 modules, 80+ tests)

Today I finalized tenant-aware access across all core modules using a shared ScopedRepository<T> abstraction. This was the largest refactoring effort so far: 12 modules, 80+ unit tests, and one generic strategy to guarantee data isolation via subscriber_id.

mariusz
mariusz
20 posts
2 followers

Motivation

The goal was to ensure that each request only sees data belonging to its tenant (subscriber_id). Instead of duplicating this logic across repositories, I built a dedicated class: ScopedRepository<T>.


The ScopedRepository<T> abstraction

This generic class wraps a regular TypeORM Repository<T> and uses the current user’s scope to transparently inject subscriber_id into queries.

Key methods:

  • find(options) → injects subscriber_id into where
  • findOne(options) → injects subscriber_id into where
  • save(entity) → verifies entity’s subscriberId before saving
  • remove(entity) → same

Example:

const roles = await this.scopedRepo.find({
  relations: ['users'],
});

This expands to:

repo.find({
  relations: ['users'],
  where: { subscriber_id: '...' },
});

All without manually accessing subscriberId in each service method.


The CurrentUserService

This request-scoped service provides per-request context:

@Injectable({ scope: Scope.REQUEST })
export class CurrentUserService {
  constructor(@Inject(REQUEST) private readonly request: Request) {}

  get subscriberId(): string | null {
    const user = this.request.user as any;
    if (!user) return null;
    if (user.role === 'admin') return null;
    return user.subscriber_id ?? null;
  }

  get isScoped(): boolean {
    return this.subscriberId !== null;
  }
}

This is used inside ScopedRepository:

private scoped<U extends FindManyOptions<T> | FindOneOptions<T>>(options: U): U {
  if (!this.currentUser.isScoped) return options;
  return {
    ...options,
    where: {
      ...(options.where ?? {}),
      subscriber_id: this.currentUser.subscriberId,
    },
  };
}

Unit Tests

Each of the 12 modules includes unit tests verifying the use of scoped access.

What is tested?

  • findAll always includes where.subscriber_id
  • findOne uses { id, subscriber_id } in where
  • save operations assign correct subscriberId
  • remove only affects scoped entities
  • Whether queries include the correct `subscriber_id`
  • Whether saved and removed entities match the scoped tenant
  • That unscoped operations (e.g., admin) bypass the filter when needed

Modules covered

  • Group
  • GroupMembership
  • GroupRelation
  • User
  • UserContract
  • ContractType
  • Address
  • Customer
  • Role
  • Payment
  • Subscription

Every repository interaction is covered.

Example: RoleService

describe('findOne', () => {
  it('should query with subscriber_id', async () => {
    repo.findOne.mockResolvedValue(mockRole);
    const result = await service.findOne('123');
    expect(repo.findOne).toHaveBeenCalledWith({
      where: { id: '123', subscriber_id: 'test-subscriber-id' },
      relations: ['users'],
    });
    expect(result).toEqual(mockRole);
  });
});

Example: SubscriptionService

describe('findAll', () => {
  it('should apply tenant scope with relations', async () => {
    await service.findAll();
    expect(repo.find).toHaveBeenCalledWith({
      where: { subscriber_id: 'test-subscriber-id' },
      relations: ['user', 'plan', 'payments'],
    });
  });
});

Example: ScopedRepository save()

describe('save', () => {
  it('should throw if subscriberId is missing on entity', async () => {
    await expect(
      scopedRepo.save({ name: 'X' } as any),
    ).rejects.toThrow('Missing subscriberId on entity');
  });

  it('should proceed if entity has subscriberId', async () => {
    const entity = { name: 'X', subscriberId: 'sub-123' };
    repo.save.mockResolvedValue(entity);
    const result = await scopedRepo.save(entity);
    expect(result).toEqual(entity);
  });
});

Workload

  • 12 modules adjusted
  • 80+ test cases updated or added
  • ScopedRepository and CurrentUserService created
  • One long day (much too long)

release: v0.6.0


Opublikowano

w

,

przez

Komentarze

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *