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

fix: automatically trigger clear_local when permissions changed #131

Merged
merged 4 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 29 additions & 0 deletions src/admin/admin.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AdminController } from './admin.controller';
import { CouchdbService } from '../couchdb/couchdb.service';
import { authGuardMockProviders } from '../auth/auth-guard-mock.providers';
import { AdminService } from './admin.service';

describe('AdminController', () => {
let controller: AdminController;
let mockAdminService: CouchdbService;

beforeEach(async () => {
mockAdminService = {
clearLocal: () => Promise.resolve(),
} as any;
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminController],
providers: [
...authGuardMockProviders,
{ provide: AdminService, useValue: mockAdminService },
],
}).compile();

controller = module.get(AdminController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
30 changes: 30 additions & 0 deletions src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Controller, Param, Post, UseGuards } from '@nestjs/common';
import { CombinedAuthGuard } from '../auth/guards/combined-auth/combined-auth.guard';
import { OnlyAuthenticated } from '../auth/only-authenticated.decorator';
import { AdminService } from './admin.service';

/**
* This service provides some general administrativ endpoints.
sleidig marked this conversation as resolved.
Show resolved Hide resolved
*/
@OnlyAuthenticated()
@UseGuards(CombinedAuthGuard)
@Controller('admin')
export class AdminController {
constructor(private adminService: AdminService) {}

/**
* Deletes all local documents of the remote database.
* These document hold meta-information about the replication process.
* Deleting them forces clients to re-run sync and check which documents are different.
* See {@link https://docs.couchdb.org/en/stable/replication/protocol.html#retrieve-replication-logs-from-source-and-target}
*
* @param db name of the database where the local documents should be deleted from
*
* This function should be called whenever the permissions change to re-trigger sync
*/
@Post('/clear_local/:db')
async clearLocal(@Param('db') db: string): Promise<any> {
await this.adminService.clearLocal(db);
return true;
}
}
4 changes: 3 additions & 1 deletion src/admin/admin.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin/admin.controller';
import { AdminController } from './admin.controller';
import { PermissionModule } from '../permissions/permission.module';
import { CouchdbModule } from '../couchdb/couchdb.module';
import { AuthModule } from '../auth/auth.module';
import { AdminService } from './admin.service';

@Module({
controllers: [AdminController],
imports: [PermissionModule, CouchdbModule, AuthModule],
providers: [AdminService],
})
export class AdminModule {}
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { CouchdbService } from '../couchdb/couchdb.service';
import { of } from 'rxjs';
import { CouchdbService } from '../../couchdb/couchdb.service';
import { authGuardMockProviders } from '../../auth/auth-guard-mock.providers';

describe('AdminController', () => {
let controller: AdminController;
describe('AdminService', () => {
let service: AdminService;
let mockCouchDBService: CouchdbService;

beforeEach(async () => {
Expand All @@ -14,18 +13,17 @@ describe('AdminController', () => {
delete: () => of({}),
} as any;
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminController],
providers: [
...authGuardMockProviders,
AdminService,
{ provide: CouchdbService, useValue: mockCouchDBService },
],
}).compile();

controller = module.get(AdminController);
service = module.get<AdminService>(AdminService);
});

it('should be defined', () => {
expect(controller).toBeDefined();
expect(service).toBeDefined();
});

it('should delete all docs in the _local db', async () => {
Expand All @@ -42,12 +40,11 @@ describe('AdminController', () => {
jest.spyOn(mockCouchDBService, 'delete').mockReturnValue(of(undefined));
const dbName = 'app';

const result = await controller.clearLocal(dbName);
await service.clearLocal(dbName);

expect(mockCouchDBService.get).toHaveBeenCalledWith(dbName, '_local_docs');
mockAllDocsResponse.rows.forEach((row) => {
expect(mockCouchDBService.delete).toHaveBeenCalledWith(dbName, row.id);
});
expect(result).toBe(true);
});
});
27 changes: 27 additions & 0 deletions src/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { firstValueFrom } from 'rxjs';
import { AllDocsResponse } from '../restricted-endpoints/replication/bulk-document/couchdb-dtos/all-docs.dto';
import { CouchdbService } from '../couchdb/couchdb.service';

@Injectable()
export class AdminService {
constructor(private couchdbService: CouchdbService) {}

async clearLocal(db: string) {
const localDocsResponse = await firstValueFrom(
this.couchdbService.get<AllDocsResponse>(db, '_local_docs'),
);

// Get IDs of the replication checkpoints
const ids = localDocsResponse.rows
.map((doc) => doc.id)
.filter(
(id) => !id.includes('purge-mrview') && !id.includes('shard-sync'),
);
const deletePromises = ids.map((id) =>
firstValueFrom(this.couchdbService.delete(db, id)),
);

await Promise.all(deletePromises);
}
}
46 changes: 0 additions & 46 deletions src/admin/admin/admin.controller.ts

This file was deleted.

46 changes: 46 additions & 0 deletions src/permissions/rules/rules.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ import { Permission } from './permission';
import { ConfigService } from '@nestjs/config';
import { CouchdbService } from '../../couchdb/couchdb.service';
import { ChangesResponse } from '../../restricted-endpoints/replication/bulk-document/couchdb-dtos/changes.dto';
import { AdminService } from '../../admin/admin.service';

describe('RulesService', () => {
let service: RulesService;
let adminRules: DocumentRule[];
let userRules: DocumentRule[];
let mockCouchDBService: CouchdbService;
let mockAdminService: AdminService;

let testPermission: Permission;
let changesResponse: ChangesResponse;

const normalUser = new UserInfo('normalUser', ['user_app']);
const adminUser = new UserInfo('superUser', ['user_app', 'admin_app']);
const DATABASE_NAME = 'app';
Expand All @@ -28,6 +32,7 @@ describe('RulesService', () => {
});
userRules = testPermission.data[normalUser.roles[0]];
adminRules = testPermission.data[adminUser.roles[1]];

changesResponse = {
last_seq: 'initial_seq',
results: [
Expand All @@ -43,6 +48,10 @@ describe('RulesService', () => {
.mockReturnValueOnce(of(changesResponse))
.mockReturnValueOnce(NEVER);

mockAdminService = {
clearLocal: jest.fn().mockResolvedValue(undefined),
} as any;

const module = await Test.createTestingModule({
providers: [
RulesService,
Expand All @@ -53,6 +62,7 @@ describe('RulesService', () => {
}),
},
{ provide: CouchdbService, useValue: mockCouchDBService },
{ provide: AdminService, useValue: mockAdminService },
],
}).compile();

Expand Down Expand Up @@ -178,4 +188,40 @@ describe('RulesService', () => {
expect(result).toEqual([publicRule]);
expect(result).not.toContain(testPermission.data.default);
});

it('should update rules and call clear_local when permission doc changed', () => {
jest.useFakeTimers();

const updatedPermission = new Permission({
user_app: [{ action: 'manage', subject: 'all' }],
});
const updatedPermissionChange = {
last_seq: '1',
results: [
{
doc: updatedPermission,
seq: '1',
changes: [],
id: updatedPermission._id,
},
],
pending: 0,
};

jest
.spyOn(mockCouchDBService, 'get')
.mockReturnValueOnce(of(changesResponse))
.mockReturnValueOnce(of(updatedPermissionChange))
.mockReturnValue(NEVER);

service.loadRulesContinuously('app');
jest.advanceTimersByTime(1500);

expect(service.getRulesForUser(normalUser)).toEqual([
{ action: 'manage', subject: 'all' },
]);
expect(mockAdminService.clearLocal).toHaveBeenCalled();

jest.useRealTimers();
});
});
14 changes: 12 additions & 2 deletions src/permissions/rules/rules.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CouchdbService } from '../../couchdb/couchdb.service';
import { ConfigService } from '@nestjs/config';
import { ChangesResponse } from '../../restricted-endpoints/replication/bulk-document/couchdb-dtos/changes.dto';
import { get } from 'lodash';
import { AdminService } from '../../admin/admin.service';

export type DocumentRule = RawRuleOf<DocumentAbility>;

Expand All @@ -24,6 +25,7 @@ export class RulesService {
constructor(
private couchdbService: CouchdbService,
private configService: ConfigService,
private adminService: AdminService,
) {
const permissionDbName = this.configService.get(
RulesService.ENV_PERMISSION_DB,
Expand Down Expand Up @@ -60,8 +62,16 @@ export class RulesService {
)
.subscribe((changes) => {
this.lastSeq = changes.last_seq;
if (changes.results.length > 0) {
this.permission = changes.results[0].doc.data;
if (changes.results?.length > 0) {
const newPermissions = changes.results[0].doc.data;
this.permission = newPermissions;

if (
this.permission !== undefined && // do not clear upon restart of the API
JSON.stringify(this.permission) !== JSON.stringify(newPermissions)
) {
this.adminService.clearLocal(db);
}
}
});
}
Expand Down
Loading