Skip to content

Commit cd5eff1

Browse files
authored
fix: enhance user context validation in auth module (#1726)
Fixes #1723 - Improved error handling in the auth module to ensure user context is present and valid. - Added checks for user roles and identifiers, throwing appropriate exceptions for missing or invalid data. - Introduced a new integration test suite for AuthZGuard, validating role-based access control for various actions in the application. - Tests cover scenarios for viewer and admin roles, ensuring correct permissions are enforced. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Hardened authorization: properly rejects requests with missing users or invalid roles and ensures a valid subject is derived for permission checks, improving reliability and security of access control responses. * **Tests** * Added comprehensive integration tests for authorization, covering admin/viewer role behaviors, API key permissions, and various resource actions to verify expected allow/deny outcomes across scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 7bdeca8 commit cd5eff1

File tree

4 files changed

+224
-7
lines changed

4 files changed

+224
-7
lines changed

api/src/unraid-api/auth/auth.module.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AuthService } from '@app/unraid-api/auth/auth.service.js';
88
import { CasbinModule } from '@app/unraid-api/auth/casbin/casbin.module.js';
99
import { CasbinService } from '@app/unraid-api/auth/casbin/casbin.service.js';
1010
import { BASE_POLICY, CASBIN_MODEL } from '@app/unraid-api/auth/casbin/index.js';
11+
import { resolveSubjectFromUser } from '@app/unraid-api/auth/casbin/resolve-subject.util.js';
1112
import { CookieService, SESSION_COOKIE_CONFIG } from '@app/unraid-api/auth/cookie.service.js';
1213
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
1314
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
@@ -41,13 +42,7 @@ import { getRequest } from '@app/utils.js';
4142

4243
try {
4344
const request = getRequest(ctx);
44-
const roles = request?.user?.roles || [];
45-
46-
if (!Array.isArray(roles)) {
47-
throw new UnauthorizedException('User roles must be an array');
48-
}
49-
50-
return roles.join(',');
45+
return resolveSubjectFromUser(request?.user);
5146
} catch (error) {
5247
logger.error('Failed to extract user context', error);
5348
throw new UnauthorizedException('Failed to authenticate user');
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { ExecutionContext, Type } from '@nestjs/common';
2+
import { Reflector } from '@nestjs/core';
3+
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host.js';
4+
5+
import type { Enforcer } from 'casbin';
6+
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
7+
import { AuthZGuard, BatchApproval } from 'nest-authz';
8+
import { beforeAll, describe, expect, it } from 'vitest';
9+
10+
import { CasbinService } from '@app/unraid-api/auth/casbin/casbin.service.js';
11+
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
12+
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
13+
import { resolveSubjectFromUser } from '@app/unraid-api/auth/casbin/resolve-subject.util.js';
14+
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
15+
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
16+
import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js';
17+
import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
18+
import { getRequest } from '@app/utils.js';
19+
20+
type Handler = (...args: any[]) => unknown;
21+
22+
type TestUser = {
23+
id?: string;
24+
roles?: Role[];
25+
};
26+
27+
type TestRequest = {
28+
user?: TestUser;
29+
};
30+
31+
function createExecutionContext(
32+
handler: Handler,
33+
classRef: Type<unknown> | null,
34+
roles: Role[],
35+
userId = 'api-key-viewer'
36+
): ExecutionContext {
37+
const request: TestRequest = {
38+
user: {
39+
id: userId,
40+
roles: [...roles],
41+
},
42+
};
43+
44+
const graphqlContextHost = new ExecutionContextHost(
45+
[undefined, undefined, { req: request }, undefined],
46+
classRef,
47+
handler
48+
);
49+
50+
graphqlContextHost.setType('graphql');
51+
52+
return graphqlContextHost as unknown as ExecutionContext;
53+
}
54+
55+
describe('AuthZGuard + Casbin policies', () => {
56+
let guard: AuthZGuard;
57+
let enforcer: Enforcer;
58+
59+
beforeAll(async () => {
60+
const casbinService = new CasbinService();
61+
enforcer = await casbinService.initializeEnforcer(CASBIN_MODEL, BASE_POLICY);
62+
63+
await enforcer.addGroupingPolicy('api-key-viewer', Role.VIEWER);
64+
await enforcer.addGroupingPolicy('api-key-admin', Role.ADMIN);
65+
66+
guard = new AuthZGuard(new Reflector(), enforcer, {
67+
enablePossession: false,
68+
batchApproval: BatchApproval.ALL,
69+
userFromContext: (ctx: ExecutionContext) => {
70+
const request = getRequest(ctx) as TestRequest | undefined;
71+
72+
return resolveSubjectFromUser(request?.user);
73+
},
74+
});
75+
});
76+
77+
it('denies viewer role from stopping docker containers', async () => {
78+
const context = createExecutionContext(
79+
DockerMutationsResolver.prototype.stop,
80+
DockerMutationsResolver,
81+
[Role.VIEWER],
82+
'api-key-viewer'
83+
);
84+
85+
await expect(guard.canActivate(context)).resolves.toBe(false);
86+
});
87+
88+
it('allows admin role to stop docker containers', async () => {
89+
const context = createExecutionContext(
90+
DockerMutationsResolver.prototype.stop,
91+
DockerMutationsResolver,
92+
[Role.ADMIN],
93+
'api-key-admin'
94+
);
95+
96+
await expect(guard.canActivate(context)).resolves.toBe(true);
97+
});
98+
99+
it('denies viewer role from stopping virtual machines', async () => {
100+
const context = createExecutionContext(
101+
VmMutationsResolver.prototype.stop,
102+
VmMutationsResolver,
103+
[Role.VIEWER],
104+
'api-key-viewer'
105+
);
106+
107+
await expect(guard.canActivate(context)).resolves.toBe(false);
108+
});
109+
110+
it('allows viewer role to read docker data', async () => {
111+
const context = createExecutionContext(
112+
DockerResolver.prototype.containers,
113+
DockerResolver,
114+
[Role.VIEWER],
115+
'api-key-viewer'
116+
);
117+
118+
await expect(guard.canActivate(context)).resolves.toBe(true);
119+
});
120+
121+
it('allows API key with explicit permission to access ME resource', async () => {
122+
await enforcer.addPolicy('api-key-custom', Resource.ME, AuthAction.READ_ANY);
123+
124+
const context = createExecutionContext(
125+
MeResolver.prototype.me,
126+
MeResolver,
127+
[],
128+
'api-key-custom'
129+
);
130+
131+
await expect(guard.canActivate(context)).resolves.toBe(true);
132+
});
133+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { UnauthorizedException } from '@nestjs/common';
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { resolveSubjectFromUser } from '@app/unraid-api/auth/casbin/resolve-subject.util.js';
6+
7+
describe('resolveSubjectFromUser', () => {
8+
it('returns trimmed user id when available', () => {
9+
const subject = resolveSubjectFromUser({ id: ' user-123 ', roles: ['viewer'] });
10+
11+
expect(subject).toBe('user-123');
12+
});
13+
14+
it('falls back to a single non-empty role', () => {
15+
const subject = resolveSubjectFromUser({ roles: [' viewer '] });
16+
17+
expect(subject).toBe('viewer');
18+
});
19+
20+
it('throws when role list is empty', () => {
21+
expect(() => resolveSubjectFromUser({ roles: [] })).toThrow(UnauthorizedException);
22+
});
23+
24+
it('throws when multiple roles are present', () => {
25+
expect(() => resolveSubjectFromUser({ roles: ['viewer', 'admin'] })).toThrow(
26+
UnauthorizedException
27+
);
28+
});
29+
30+
it('throws when roles is not an array', () => {
31+
expect(() => resolveSubjectFromUser({ roles: 'viewer' as unknown })).toThrow(
32+
UnauthorizedException
33+
);
34+
});
35+
36+
it('throws when role subject is blank', () => {
37+
expect(() => resolveSubjectFromUser({ roles: [' '] })).toThrow(UnauthorizedException);
38+
});
39+
40+
it('throws when user is missing', () => {
41+
expect(() => resolveSubjectFromUser(undefined)).toThrow(UnauthorizedException);
42+
});
43+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { UnauthorizedException } from '@nestjs/common';
2+
3+
type CasbinUser = {
4+
id?: unknown;
5+
roles?: unknown;
6+
};
7+
8+
/**
9+
* Determine the Casbin subject for a request user.
10+
*
11+
* Prefers a non-empty `user.id`, otherwise falls back to a single non-empty role.
12+
* Throws when the subject cannot be resolved.
13+
*/
14+
export function resolveSubjectFromUser(user: CasbinUser | undefined): string {
15+
if (!user) {
16+
throw new UnauthorizedException('Request user context missing');
17+
}
18+
19+
const roles = user.roles ?? [];
20+
21+
if (!Array.isArray(roles)) {
22+
throw new UnauthorizedException('User roles must be an array');
23+
}
24+
25+
const userId = typeof user.id === 'string' ? user.id.trim() : '';
26+
27+
if (userId.length > 0) {
28+
return userId;
29+
}
30+
31+
if (roles.length === 1) {
32+
const [role] = roles;
33+
34+
if (typeof role === 'string') {
35+
const trimmedRole = role.trim();
36+
37+
if (trimmedRole.length > 0) {
38+
return trimmedRole;
39+
}
40+
}
41+
42+
throw new UnauthorizedException('Role subject must be a non-empty string');
43+
}
44+
45+
throw new UnauthorizedException('Unable to determine subject from user context');
46+
}

0 commit comments

Comments
 (0)