Skip to content

Commit 56f437b

Browse files
sleidigCopilotAbhinegi2
authored
feat: filter _design docs from all replication (#215)
--------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Abhinegi2 <negiabhishek253@gmail.com>
1 parent d5349d7 commit 56f437b

8 files changed

Lines changed: 231 additions & 22 deletions

File tree

.env.template

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n<PUBLIC_KEY>\n-----END PUBLIC KEY---
1010
# secret to create JWT tokens. They are used in the JWT auth which works similar to CouchDB's `POST /_session` endpoint. This should be changed to prevent others to create fake JWT tokens.
1111
JWT_SECRET=someJwtSecret
1212

13+
# (optional) comma-separated list of document ID prefixes to exclude from replication.
14+
# Defaults to "_design/" if unset. Override to filter additional doc types.
15+
# REPLICATION_IGNORED_PREFIXES=_design/
16+
1317
# (optional) the [Sentry DSN](https://docs.sentry.io/product/sentry-basics/dsn-explainer/). If defined, error messages are sent to the sentry.io application monitoring & logging service.
1418
SENTRY_DSN=
1519
SENTRY_ENABLED=true

src/restricted-endpoints/replication/bulk-document/bulk-document.service.spec.ts

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1+
import { ConfigService } from '@nestjs/config';
12
import { Test, TestingModule } from '@nestjs/testing';
3+
import { of } from 'rxjs';
4+
import { CouchdbService } from '../../../couchdb/couchdb.service';
5+
import { PermissionService } from '../../../permissions/permission/permission.service';
6+
import { RulesService } from '../../../permissions/rules/rules.service';
7+
import { UserInfo } from '../../session/user-auth.dto';
8+
import { DocumentFilterService } from '../document-filter/document-filter.service';
29
import { BulkDocumentService } from './bulk-document.service';
3-
import { BulkGetResponse } from './couchdb-dtos/bulk-get.dto';
410
import { AllDocsResponse } from './couchdb-dtos/all-docs.dto';
511
import {
612
BulkDocsRequest,
713
DatabaseDocument,
814
} from './couchdb-dtos/bulk-docs.dto';
9-
import { UserInfo } from '../../session/user-auth.dto';
10-
import { PermissionService } from '../../../permissions/permission/permission.service';
11-
import { RulesService } from '../../../permissions/rules/rules.service';
12-
import { of } from 'rxjs';
13-
import { CouchdbService } from '../../../couchdb/couchdb.service';
15+
import { BulkGetResponse } from './couchdb-dtos/bulk-get.dto';
1416

1517
describe('BulkDocumentService', () => {
1618
let service: BulkDocumentService;
@@ -38,6 +40,8 @@ describe('BulkDocumentService', () => {
3840
providers: [
3941
BulkDocumentService,
4042
PermissionService,
43+
DocumentFilterService,
44+
{ provide: ConfigService, useValue: { get: () => undefined } },
4145
{ provide: RulesService, useValue: mockRulesService },
4246
{ provide: CouchdbService, useValue: mockCouchDBService },
4347
],
@@ -207,6 +211,71 @@ describe('BulkDocumentService', () => {
207211
});
208212
});
209213

214+
it('should filter out _design/ docs in BulkGet', () => {
215+
const designDoc: DatabaseDocument = {
216+
_id: '_design/some-view',
217+
_rev: 'rev1',
218+
};
219+
const bulkGetResponse = createBulkGetResponse(schoolDoc, designDoc);
220+
jest
221+
.spyOn(mockRulesService, 'getRulesForUser')
222+
.mockReturnValue([{ action: 'manage', subject: 'all' }]);
223+
224+
const result = service.filterBulkGetResponse(bulkGetResponse, normalUser);
225+
226+
expect(result.results.map((r) => r.id)).toEqual([schoolDoc._id]);
227+
});
228+
229+
it('should filter out _design/ docs in AllDocs', () => {
230+
const designDoc: DatabaseDocument = {
231+
_id: '_design/conflicts',
232+
_rev: 'rev1',
233+
};
234+
const allDocsResponse = createAllDocsResponse(schoolDoc, designDoc);
235+
jest
236+
.spyOn(mockRulesService, 'getRulesForUser')
237+
.mockReturnValue([{ action: 'manage', subject: 'all' }]);
238+
239+
const result = service.filterAllDocsResponse(allDocsResponse, normalUser);
240+
241+
expect(result.rows.map((r) => r.id)).toEqual([schoolDoc._id]);
242+
});
243+
244+
it('should filter out _design/ docs in BulkDocs writes', async () => {
245+
const designDoc: DatabaseDocument = {
246+
_id: '_design/search_index',
247+
_rev: 'rev1',
248+
};
249+
const request: BulkDocsRequest = {
250+
new_edits: false,
251+
docs: [childDoc, designDoc],
252+
};
253+
jest
254+
.spyOn(mockCouchDBService, 'post')
255+
.mockReturnValue(of(createAllDocsResponse(childDoc)));
256+
257+
const result = await service.filterBulkDocsRequest(request, normalUser, '');
258+
259+
expect(result.docs.map((d) => d._id)).toEqual([childDoc._id]);
260+
});
261+
262+
it('should filter out _design/ docs in Find responses', () => {
263+
const designDoc: DatabaseDocument = {
264+
_id: '_design/some-index',
265+
_rev: 'rev1',
266+
};
267+
jest
268+
.spyOn(mockRulesService, 'getRulesForUser')
269+
.mockReturnValue([{ action: 'manage', subject: 'all' }]);
270+
271+
const result = service.filterFindResponse(
272+
{ docs: [getSchoolDoc(), designDoc], bookmark: '' },
273+
normalUser,
274+
);
275+
276+
expect(result.docs.map((d) => d._id)).toEqual([schoolDoc._id]);
277+
});
278+
210279
function getSchoolDoc(): DatabaseDocument {
211280
return {
212281
_id: 'School:1',

src/restricted-endpoints/replication/bulk-document/bulk-document.service.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { firstValueFrom } from 'rxjs';
2020
import { Ability } from '@casl/ability';
2121
import { CouchdbService } from '../../../couchdb/couchdb.service';
22+
import { DocumentFilterService } from '../document-filter/document-filter.service';
2223

2324
/**
2425
* Handle bulk document requests with the remote CouchDB server
@@ -29,19 +30,22 @@ export class BulkDocumentService {
2930
constructor(
3031
private permissionService: PermissionService,
3132
private couchdbService: CouchdbService,
33+
private documentFilter: DocumentFilterService,
3234
) {}
3335

3436
filterBulkGetResponse(
3537
response: BulkGetResponse,
3638
user: UserInfo,
3739
): BulkGetResponse {
3840
const ability = this.permissionService.getAbilityFor(user);
39-
const withPermissions: BulkGetResult[] = response.results.map((result) => ({
40-
id: result.id,
41-
docs: result.docs.filter((doc) =>
42-
this.isPermittedBulkGetDoc(doc, ability),
43-
),
44-
}));
41+
const withPermissions: BulkGetResult[] = response.results
42+
.filter((result) => this.documentFilter.isReplicable(result.id))
43+
.map((result) => ({
44+
id: result.id,
45+
docs: result.docs.filter((doc) =>
46+
this.isPermittedBulkGetDoc(doc, ability),
47+
),
48+
}));
4549
// Only return results where at least one document is left
4650
return {
4751
results: withPermissions.filter((result) => result.docs.length > 0),
@@ -66,8 +70,10 @@ export class BulkDocumentService {
6670
return {
6771
total_rows: response.total_rows,
6872
offset: response.offset,
69-
rows: response.rows.filter((row) =>
70-
row.doc ? row.doc._deleted || ability.can('read', row.doc) : true,
73+
rows: response.rows.filter(
74+
(row) =>
75+
this.documentFilter.isReplicable(row.id) &&
76+
(row.doc ? row.doc._deleted || ability.can('read', row.doc) : true),
7177
),
7278
};
7379
}
@@ -93,12 +99,15 @@ export class BulkDocumentService {
9399
);
94100
return {
95101
new_edits: request.new_edits,
96-
docs: request.docs.filter((doc) =>
97-
this.hasPermissionsForDoc(
98-
doc,
99-
response.rows.find((responseDoc) => responseDoc.id === doc._id)?.doc,
100-
ability,
101-
),
102+
docs: request.docs.filter(
103+
(doc) =>
104+
this.documentFilter.isReplicable(doc._id) &&
105+
this.hasPermissionsForDoc(
106+
doc,
107+
response.rows.find((responseDoc) => responseDoc.id === doc._id)
108+
?.doc,
109+
ability,
110+
),
102111
),
103112
};
104113
}
@@ -108,7 +117,11 @@ export class BulkDocumentService {
108117
return {
109118
bookmark: request.bookmark,
110119
warning: request.warning,
111-
docs: request.docs.filter((doc) => ability.can('read', doc)),
120+
docs: request.docs.filter(
121+
(doc) =>
122+
this.documentFilter.isReplicable(doc._id) &&
123+
ability.can('read', doc),
124+
),
112125
};
113126
}
114127

src/restricted-endpoints/replication/changes/changes.controller.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
ChangesResponse,
1212
} from '../bulk-document/couchdb-dtos/changes.dto';
1313
import { ChangesController } from './changes.controller';
14+
import { DocumentFilterService } from '../document-filter/document-filter.service';
15+
import { ConfigService } from '@nestjs/config';
1416

1517
describe('ChangesController', () => {
1618
let controller: ChangesController;
@@ -50,6 +52,8 @@ describe('ChangesController', () => {
5052
{ provide: CouchdbService, useValue: mockCouchdbService },
5153
{ provide: RulesService, useValue: mockRulesService },
5254
PermissionService,
55+
DocumentFilterService,
56+
{ provide: ConfigService, useValue: { get: () => undefined } },
5357
],
5458
}).compile();
5559

@@ -328,6 +332,20 @@ describe('ChangesController', () => {
328332
expect(res.results).toEqual([docToChange(deletedWithoutProps)]);
329333
});
330334

335+
it('should silently skip _design/ documents without adding them to results or lostPermissions', async () => {
336+
const designDoc: DatabaseDocument = { _id: '_design/some-view' };
337+
getRulesSpy.mockReturnValue([{ subject: 'all', action: 'manage' }]);
338+
getSpy.mockReturnValue(createChanges([schoolDoc, designDoc, childDoc]));
339+
340+
const res = await controller.changes('some-db', user);
341+
342+
expect(res.results.map((r) => r.id)).toEqual([
343+
schoolDoc._id,
344+
childDoc._id,
345+
]);
346+
expect(res.lostPermissions).toEqual([]);
347+
});
348+
331349
function createChanges(
332350
docs: DatabaseDocument[],
333351
pending = 0,

src/restricted-endpoints/replication/changes/changes.controller.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import {
1414
ChangesParams,
1515
ChangesResponse,
1616
} from '../bulk-document/couchdb-dtos/changes.dto';
17+
import { DocumentFilterService } from '../document-filter/document-filter.service';
1718

1819
@UseGuards(CombinedAuthGuard)
1920
@Controller()
2021
export class ChangesController {
2122
constructor(
2223
private couchdbService: CouchdbService,
2324
private permissionService: PermissionService,
25+
private documentFilter: DocumentFilterService,
2426
) {}
2527

2628
/**
@@ -98,6 +100,12 @@ export class ChangesController {
98100

99101
for (let i = 0; i < changes.results.length; i++) {
100102
const change = changes.results[i];
103+
104+
if (!this.documentFilter.isReplicable(change.id)) {
105+
lastProcessedSeq = change.seq;
106+
continue;
107+
}
108+
101109
const { doc } = change;
102110

103111
const isPermitted = !doc
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { ConfigService } from '@nestjs/config';
2+
import { DocumentFilterService } from './document-filter.service';
3+
4+
describe('DocumentFilterService', () => {
5+
function createService(envValue?: string): DocumentFilterService {
6+
const configService = {
7+
get: jest.fn().mockReturnValue(envValue),
8+
} as any as ConfigService;
9+
return new DocumentFilterService(configService);
10+
}
11+
12+
describe('default configuration', () => {
13+
let service: DocumentFilterService;
14+
15+
beforeEach(() => {
16+
service = createService(undefined);
17+
});
18+
19+
it('should filter _design/ documents', () => {
20+
expect(service.isReplicable('_design/some-view')).toBe(false);
21+
expect(service.isReplicable('_design/conflicts')).toBe(false);
22+
});
23+
24+
it('should allow regular entity documents', () => {
25+
expect(service.isReplicable('Child:abc123')).toBe(true);
26+
expect(service.isReplicable('School:1')).toBe(true);
27+
});
28+
29+
it('should allow documents whose ID contains but does not start with _design/', () => {
30+
expect(service.isReplicable('Note:_design/test')).toBe(true);
31+
});
32+
});
33+
34+
describe('custom configuration', () => {
35+
it('should use prefixes from environment variable', () => {
36+
const service = createService('_design/, test-');
37+
38+
expect(service.isReplicable('_design/foo')).toBe(false);
39+
expect(service.isReplicable('test-doc')).toBe(false);
40+
expect(service.isReplicable('Child:1')).toBe(true);
41+
});
42+
43+
it('should handle a single custom prefix', () => {
44+
const service = createService('custom_prefix:');
45+
46+
expect(service.isReplicable('custom_prefix:doc')).toBe(false);
47+
expect(service.isReplicable('_design/foo')).toBe(true);
48+
});
49+
50+
it('should ignore empty entries in comma-separated list', () => {
51+
const service = createService('_design/,,, _local/');
52+
53+
expect(service.isReplicable('_design/x')).toBe(false);
54+
expect(service.isReplicable('_local/y')).toBe(false);
55+
expect(service.isReplicable('Child:1')).toBe(true);
56+
});
57+
});
58+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
4+
/**
5+
* Filters documents that should not be replicated to/from clients.
6+
*
7+
* By default, documents whose IDs start with `_design/` are excluded.
8+
* This can be overridden via the `REPLICATION_IGNORED_PREFIXES` environment
9+
* variable (comma-separated list of prefixes). If set to an empty string,
10+
* filtering is disabled.
11+
*/
12+
@Injectable()
13+
export class DocumentFilterService {
14+
private static readonly DEFAULT_IGNORED_PREFIXES = ['_design/'];
15+
16+
private readonly ignoredPrefixes: string[];
17+
18+
constructor(configService: ConfigService) {
19+
const envValue = configService.get<string | undefined>(
20+
'REPLICATION_IGNORED_PREFIXES',
21+
);
22+
this.ignoredPrefixes =
23+
envValue === undefined
24+
? DocumentFilterService.DEFAULT_IGNORED_PREFIXES
25+
: envValue
26+
.split(',')
27+
.map((p) => p.trim())
28+
.filter((p) => p.length > 0);
29+
}
30+
31+
/**
32+
* Whether the given document ID is eligible for replication.
33+
* Returns `false` for IDs that match any ignored prefix.
34+
*/
35+
isReplicable(docId: string): boolean {
36+
return !this.ignoredPrefixes.some((prefix) => docId.startsWith(prefix));
37+
}
38+
}

src/restricted-endpoints/replication/replication.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { PermissionModule } from '../../permissions/permission.module';
66
import { AuthModule } from '../../auth/auth.module';
77
import { BulkDocEndpointsController } from './bulk-document/bulk-doc-endpoints.controller';
88
import { ChangesController } from './changes/changes.controller';
9+
import { DocumentFilterService } from './document-filter/document-filter.service';
910

1011
@Module({
1112
imports: [HttpModule, PermissionModule, AuthModule],
@@ -14,6 +15,6 @@ import { ChangesController } from './changes/changes.controller';
1415
InfoEndpointsController,
1516
BulkDocEndpointsController,
1617
],
17-
providers: [BulkDocumentService],
18+
providers: [BulkDocumentService, DocumentFilterService],
1819
})
1920
export class ReplicationModule {}

0 commit comments

Comments
 (0)