Skip to content

Commit ad124f8

Browse files
committed
improvements from review comments
1 parent af178fc commit ad124f8

File tree

8 files changed

+177
-48
lines changed

8 files changed

+177
-48
lines changed

src/couchdb/document-changes.service.spec.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,9 @@ describe('DocumentChangesService', () => {
6262
});
6363

6464
it('should reuse the same feed for the same database', () => {
65-
const subject1 = service.getChanges('app');
66-
const subject2 = service.getChanges('app');
65+
service.getChanges('app');
66+
service.getChanges('app');
6767

68-
expect(subject1).toBe(subject2);
6968
// Only one _changes request should be started
7069
expect(mockCouchdbService.get).toHaveBeenCalledTimes(1);
7170
});
@@ -76,10 +75,9 @@ describe('DocumentChangesService', () => {
7675
.spyOn(mockCouchdbService, 'get')
7776
.mockReturnValue(changesSubject as any);
7877

79-
const subject1 = service.getChanges('app');
80-
const subject2 = service.getChanges('other-db');
78+
service.getChanges('app');
79+
service.getChanges('other-db');
8180

82-
expect(subject1).not.toBe(subject2);
8381
expect(mockCouchdbService.get).toHaveBeenCalledTimes(2);
8482
});
8583

src/couchdb/document-changes.service.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import { Injectable, Logger } from '@nestjs/common';
2-
import { catchError, concatMap, defer, of, repeat, retry, Subject } from 'rxjs';
2+
import {
3+
catchError,
4+
concatMap,
5+
defer,
6+
Observable,
7+
of,
8+
repeat,
9+
retry,
10+
Subject,
11+
} from 'rxjs';
312
import {
413
ChangeResult,
514
ChangesResponse,
@@ -24,13 +33,13 @@ export class DocumentChangesService {
2433
* The underlying longpoll connection is started lazily on first
2534
* subscription and shared across all subscribers for that database.
2635
*/
27-
getChanges(db: string): Subject<ChangeResult> {
36+
getChanges(db: string): Observable<ChangeResult> {
2837
if (!this.feeds.has(db)) {
2938
const subject = new Subject<ChangeResult>();
3039
this.feeds.set(db, subject);
3140
this.startFeed(db, subject);
3241
}
33-
return this.feeds.get(db);
42+
return this.feeds.get(db).asObservable();
3443
}
3544

3645
private startFeed(db: string, subject: Subject<ChangeResult>): void {

src/permissions/permission-check/permission-check.controller.spec.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
import { BadGatewayException } from '@nestjs/common';
1+
import {
2+
BadGatewayException,
3+
BadRequestException,
4+
HttpException,
5+
} from '@nestjs/common';
26
import { Test, TestingModule } from '@nestjs/testing';
37
import { AxiosError, AxiosHeaders, AxiosResponse } from 'axios';
8+
import { of } from 'rxjs';
49
import { authGuardMockProviders } from '../../auth/auth-guard-mock.providers';
5-
import { CombinedAuthGuard } from '../../auth/guards/combined-auth/combined-auth.guard';
10+
import { BasicAuthGuard } from '../../auth/guards/basic-auth/basic-auth.guard';
11+
import { CouchdbService } from '../../couchdb/couchdb.service';
612
import { UserInfo } from '../../restricted-endpoints/session/user-auth.dto';
713
import { PermissionService } from '../permission/permission.service';
814
import { UserIdentityService } from '../user-identity/user-identity.service';
@@ -12,6 +18,7 @@ describe('PermissionCheckController', () => {
1218
let controller: PermissionCheckController;
1319
let mockUserIdentityService: UserIdentityService;
1420
let mockPermissionService: PermissionService;
21+
let mockCouchdbService: CouchdbService;
1522

1623
beforeEach(async () => {
1724
mockUserIdentityService = {
@@ -20,14 +27,18 @@ describe('PermissionCheckController', () => {
2027
mockPermissionService = {
2128
isAllowedTo: jest.fn(),
2229
} as any;
30+
mockCouchdbService = {
31+
get: jest.fn().mockReturnValue(of({ _id: 'Child:1' })),
32+
} as any;
2333

2434
const module: TestingModule = await Test.createTestingModule({
2535
controllers: [PermissionCheckController],
2636
providers: [
2737
...authGuardMockProviders,
28-
{ provide: CombinedAuthGuard, useValue: {} },
38+
{ provide: BasicAuthGuard, useValue: {} },
2939
{ provide: UserIdentityService, useValue: mockUserIdentityService },
3040
{ provide: PermissionService, useValue: mockPermissionService },
41+
{ provide: CouchdbService, useValue: mockCouchdbService },
3142
],
3243
}).compile();
3344

@@ -47,10 +58,11 @@ describe('PermissionCheckController', () => {
4758

4859
const result = await controller.checkPermissions({
4960
userIds: ['u1', 'u2'],
50-
entityDoc: { _id: 'Child:1' },
61+
entityId: 'Child:1',
5162
action: 'read',
5263
});
5364

65+
expect(mockCouchdbService.get).toHaveBeenCalledWith('app', 'Child:1');
5466
expect(result).toEqual({
5567
u1: { permitted: true },
5668
u2: { permitted: false },
@@ -64,7 +76,7 @@ describe('PermissionCheckController', () => {
6476

6577
const result = await controller.checkPermissions({
6678
userIds: ['u1'],
67-
entityDoc: { _id: 'Child:1' },
79+
entityId: 'Child:1',
6880
action: 'read',
6981
});
7082

@@ -93,7 +105,21 @@ describe('PermissionCheckController', () => {
93105

94106
const result = await controller.checkPermissions({
95107
userIds: ['u1'],
96-
entityDoc: { _id: 'Child:1' },
108+
entityId: 'Child:1',
109+
action: 'read',
110+
});
111+
112+
expect(result).toEqual({ u1: { permitted: false, error: 'NOT_FOUND' } });
113+
});
114+
115+
it('should return NOT_FOUND when user lookup throws HttpException 404', async () => {
116+
jest
117+
.spyOn(mockUserIdentityService, 'resolveUser')
118+
.mockRejectedValue(new HttpException('User not found', 404));
119+
120+
const result = await controller.checkPermissions({
121+
userIds: ['u1'],
122+
entityId: 'Child:1',
97123
action: 'read',
98124
});
99125

@@ -110,7 +136,7 @@ describe('PermissionCheckController', () => {
110136
await expect(
111137
controller.checkPermissions({
112138
userIds: ['u1'],
113-
entityDoc: { _id: 'Child:1' },
139+
entityId: 'Child:1',
114140
action: 'read',
115141
}),
116142
).rejects.toThrow(BadGatewayException);
@@ -139,9 +165,45 @@ describe('PermissionCheckController', () => {
139165
await expect(
140166
controller.checkPermissions({
141167
userIds: ['u1'],
142-
entityDoc: { _id: 'Child:1' },
168+
entityId: 'Child:1',
143169
action: 'read',
144170
}),
145171
).rejects.toThrow(BadGatewayException);
146172
});
173+
174+
it('should throw BadRequestException for malformed userIds', async () => {
175+
await expect(
176+
controller.checkPermissions({
177+
userIds: 'u1' as any,
178+
entityId: 'Child:1',
179+
action: 'read',
180+
}),
181+
).rejects.toThrow('userIds is required');
182+
});
183+
184+
it('should throw BadRequestException for invalid action', async () => {
185+
await expect(
186+
controller.checkPermissions({
187+
userIds: ['u1'],
188+
entityId: 'Child:1',
189+
action: 'approve' as any,
190+
}),
191+
).rejects.toThrow('action is invalid');
192+
});
193+
194+
it('should throw BadRequestException when entity document is not found', async () => {
195+
jest.spyOn(mockCouchdbService, 'get').mockReturnValue({
196+
subscribe: (observer) => {
197+
observer.error(new HttpException('not found', 404));
198+
},
199+
} as any);
200+
201+
await expect(
202+
controller.checkPermissions({
203+
userIds: ['u1'],
204+
entityId: 'Child:doesnotexist',
205+
action: 'read',
206+
}),
207+
).rejects.toThrow(BadRequestException);
208+
});
147209
});

src/permissions/permission-check/permission-check.controller.ts

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
BadRequestException,
44
Body,
55
Controller,
6+
HttpException,
67
Logger,
78
Post,
89
UseGuards,
@@ -17,9 +18,10 @@ import {
1718
ApiUnauthorizedResponse,
1819
} from '@nestjs/swagger';
1920
import { AxiosError } from 'axios';
20-
import { CombinedAuthGuard } from '../../auth/guards/combined-auth/combined-auth.guard';
21-
import { OnlyAuthenticated } from '../../auth/only-authenticated.decorator';
22-
import { PermissionService } from '../permission/permission.service';
21+
import { firstValueFrom } from 'rxjs';
22+
import { BasicAuthGuard } from '../../auth/guards/basic-auth/basic-auth.guard';
23+
import { CouchdbService } from '../../couchdb/couchdb.service';
24+
import { Action, PermissionService } from '../permission/permission.service';
2325
import { UserIdentityService } from '../user-identity/user-identity.service';
2426
import { PermissionCheckRequestDto } from './permission-check.request.dto';
2527

@@ -28,15 +30,22 @@ import { PermissionCheckRequestDto } from './permission-check.request.dto';
2830
*/
2931
@ApiTags('permissions')
3032
@ApiBasicAuth('BasicAuth')
31-
@OnlyAuthenticated()
32-
@UseGuards(CombinedAuthGuard)
33+
@UseGuards(BasicAuthGuard)
3334
@Controller('permissions')
3435
export class PermissionCheckController {
3536
private readonly logger = new Logger(PermissionCheckController.name);
37+
private static readonly VALID_ACTIONS = new Set<Action>([
38+
'read',
39+
'create',
40+
'update',
41+
'delete',
42+
'manage',
43+
]);
3644

3745
constructor(
3846
private readonly userIdentityService: UserIdentityService,
3947
private readonly permissionService: PermissionService,
48+
private readonly couchdbService: CouchdbService,
4049
) {}
4150

4251
/**
@@ -71,30 +80,49 @@ export class PermissionCheckController {
7180
})
7281
@ApiBadRequestResponse({
7382
description:
74-
'Request payload is invalid (e.g. missing userIds or entityDoc._id).',
83+
'Request payload is invalid (e.g. missing userIds or entityId).',
7584
})
7685
@ApiUnauthorizedResponse({ description: 'Authentication required.' })
7786
async checkPermissions(@Body() body: PermissionCheckRequestDto) {
7887
this.logger.debug(
79-
`Incoming permission check: userIds=${JSON.stringify(body?.userIds)}, ` +
80-
`entityDoc._id=${body?.entityDoc?._id}, action=${body?.action}, ` +
88+
`Incoming permission check: userCount=${Array.isArray(body?.userIds) ? body.userIds.length : 0}, ` +
89+
`entityId=${body?.entityId}, action=${body?.action ?? 'read'}, ` +
8190
`body keys=${body ? Object.keys(body) : 'null'}`,
8291
);
83-
if (!body?.userIds?.length) {
92+
93+
if (
94+
!Array.isArray(body?.userIds) ||
95+
body.userIds.length === 0 ||
96+
body.userIds.some((id) => typeof id !== 'string' || id.trim() === '')
97+
) {
8498
throw new BadRequestException('userIds is required');
8599
}
86-
if (!body?.entityDoc?._id) {
87-
throw new BadRequestException('entityDoc._id is required');
100+
101+
if (
102+
!body?.entityId ||
103+
typeof body.entityId !== 'string' ||
104+
body.entityId.trim() === ''
105+
) {
106+
throw new BadRequestException('entityId is required');
107+
}
108+
109+
if (
110+
body.action &&
111+
!PermissionCheckController.VALID_ACTIONS.has(body.action)
112+
) {
113+
throw new BadRequestException('action is invalid');
88114
}
89115

90-
const action = body.action || 'read';
116+
const action: Action = body.action ?? 'read';
117+
const entityDoc = await this.loadCanonicalEntityDoc(body.entityId);
118+
91119
const results = await Promise.all(
92120
body.userIds.map(async (userId) => {
93121
try {
94122
const user = await this.userIdentityService.resolveUser(userId);
95123
const permitted = await this.permissionService.isAllowedTo(
96124
action,
97-
body.entityDoc,
125+
entityDoc,
98126
user,
99127
'app',
100128
);
@@ -111,8 +139,15 @@ export class PermissionCheckController {
111139
);
112140
}
113141

114-
// User not found in Keycloak
115-
if (error instanceof AxiosError && error.response?.status === 404) {
142+
// User not found in Keycloak (404) or bad user ID format (400)
143+
if (
144+
(error instanceof AxiosError &&
145+
error.response?.status >= 400 &&
146+
error.response?.status < 500) ||
147+
(error instanceof HttpException &&
148+
error.getStatus() >= 400 &&
149+
error.getStatus() < 500)
150+
) {
116151
return [userId, { permitted: false, error: 'NOT_FOUND' }] as const;
117152
}
118153

@@ -127,4 +162,20 @@ export class PermissionCheckController {
127162

128163
return Object.fromEntries(results);
129164
}
165+
166+
private async loadCanonicalEntityDoc(entityId: string) {
167+
try {
168+
return await firstValueFrom(this.couchdbService.get('app', entityId));
169+
} catch (error) {
170+
if (error instanceof HttpException && error.getStatus() === 404) {
171+
throw new BadRequestException(`entityDoc not found: ${entityId}`);
172+
}
173+
174+
this.logger.error(
175+
`Failed to load canonical entity document ${entityId}`,
176+
error?.stack || error,
177+
);
178+
throw new BadGatewayException('Failed to load target entity document');
179+
}
180+
}
130181
}

src/permissions/permission-check/permission-check.request.dto.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { ApiProperty } from '@nestjs/swagger';
2-
import { DatabaseDocument } from '../../restricted-endpoints/replication/bulk-document/couchdb-dtos/bulk-docs.dto';
32
import { Action } from '../permission/permission.service';
43

54
/**
@@ -13,15 +12,12 @@ export class PermissionCheckRequestDto {
1312
userIds: string[];
1413

1514
@ApiProperty({
16-
description: 'Target entity document used for permission evaluation.',
17-
type: 'object',
18-
additionalProperties: true,
19-
required: ['_id'],
20-
properties: {
21-
_id: { type: 'string' },
22-
},
15+
description:
16+
'The _id of the target entity document to check permissions against.',
17+
type: 'string',
18+
example: 'Child:1',
2319
})
24-
entityDoc: DatabaseDocument;
20+
entityId: string;
2521

2622
@ApiProperty({
2723
description: 'Action to evaluate.',

src/permissions/permission.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const KEYCLOAK_HTTP_SERVICE = 'KEYCLOAK_HTTP_SERVICE';
2626
// CouchDB basic-auth headers and an error interceptor, which would
2727
// cause Keycloak admin API requests to fail with 401.
2828
provide: KEYCLOAK_HTTP_SERVICE,
29-
useFactory: () => new HttpService(axios.create()),
29+
useFactory: () => new HttpService(axios.create({ timeout: 5000 })),
3030
},
3131
{
3232
provide: UserAdminService,

0 commit comments

Comments
 (0)