-
Notifications
You must be signed in to change notification settings - Fork 185
feat(api): add Authorization Module [main<-1209] #1210
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
base: main
Are you sure you want to change the base?
Changes from all commits
4f6420b
56a1b29
ebc0c75
fb9c01e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| /* | ||
| * Copyright © 2025 Hexastack. All rights reserved. | ||
| * | ||
| * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: | ||
| * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. | ||
| * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). | ||
| */ | ||
|
|
||
| import { Global, Module } from '@nestjs/common'; | ||
|
|
||
| import { UserModule } from '@/user/user.module'; | ||
|
|
||
| import { AuthorizationService } from './authorization.service'; | ||
|
|
||
| @Global() | ||
| @Module({ | ||
| imports: [UserModule], | ||
| providers: [AuthorizationService], | ||
| exports: [AuthorizationService], | ||
| }) | ||
| export class AuthorizationModule {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| /* | ||
| * Copyright © 2025 Hexastack. All rights reserved. | ||
| * | ||
| * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: | ||
| * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. | ||
| * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). | ||
| */ | ||
|
|
||
| import { Injectable } from '@nestjs/common'; | ||
|
|
||
| import { LoggerService } from '@/logger/logger.service'; | ||
| import { PermissionService } from '@/user/services/permission.service'; | ||
| import { UserService } from '@/user/services/user.service'; | ||
| import { MethodToAction } from '@/user/types/action.type'; | ||
| import { TModel } from '@/user/types/model.type'; | ||
|
|
||
| @Injectable() | ||
| export class AuthorizationService { | ||
| constructor( | ||
| private readonly logger: LoggerService, | ||
| private readonly userService: UserService, | ||
| private readonly permissionService: PermissionService, | ||
| ) {} | ||
|
|
||
| /** | ||
| * Checks if a request (REST/WebSocket) is authorized to get access | ||
| * | ||
| * @param method - The Request Method | ||
| * @param userRoles - An array of ID's user Roles or empty | ||
| * @param targetModel - The target model that we want access | ||
| * @returns | ||
| */ | ||
| async canAccess( | ||
| method: string, | ||
| userId: string, | ||
| targetModel?: TModel, | ||
| ): Promise<boolean> { | ||
| try { | ||
| if (!targetModel) { | ||
| return false; | ||
| } | ||
| const user = await this.userService.findOne(userId); | ||
| const permissions = await this.permissionService.getPermissions(); | ||
|
|
||
| if (permissions && user?.roles?.length) { | ||
| const permissionsFromRoles = Object.entries(permissions) | ||
| .filter(([key, _]) => user.roles.includes(key)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chainClarify the type of user.roles for consistent filtering. The filtering logic on line 44 uses 🏁 Script executed: #!/bin/bash
# Description: Check the User schema to understand the structure of roles field
# Search for User schema definition
ast-grep --pattern $'class User {
$$$
roles$$$
$$$
}'
# Also check for @Prop decorator on roles field
rg -B 2 -A 2 "roles.*:" api/src/user/schemas/user.schema.tsLength of output: 510 🏁 Script executed: #!/bin/bash
# Show lines around the filter usage in authorization.service.ts
rg -n "user.roles.includes" -C 3 api/src/authorization/authorization.service.ts
# Show the containing method signature
rg -n "filter" -C 5 api/src/authorization/authorization.service.ts
# Show imports of User/UserFull in this file
rg -n "import.*User" api/src/authorization/authorization.service.tsLength of output: 1038 #!/bin/bash
# Find UserService and its findOne method signature
rg -n "class UserService" -C 10 api/src/user/services/user.service.ts
rg -n "findOne" -C 5 api/src/user/services/user.service.ts🏁 Script executed: #!/bin/bash
# Inspect UserService class and findOne method to determine return type and roles field type
rg -n "class UserService" -C 10 api/src/user/services/user.service.ts
rg -n "findOne" -C 5 api/src/user/services/user.service.tsLength of output: 670 #!/bin/bash
# Locate BaseService file
base_service_file=$(fd base-service.ts -t f)
# Display the BaseService class definition
rg -n "export abstract class BaseService" -C 15 "$base_service_file"
# Show the findOne method signature and return type
rg -n -C 15 "findOne" "$base_service_file"#!/bin/bash
# Inspect BaseService to see what type findOne returns
rg -n "export abstract class BaseService" -C 10 api/src/utils/generics/base-service.ts
rg -n "findOne" -C 10 api/src/utils/generics/base-service.ts
# Inspect UserRepository to see if findOne is overridden or has custom logic
rg -n "export class UserRepository" -C 10 api/src/user/repositories/user.repository.ts
rg -n "findOne" -C 10 api/src/user/repositories/user.repository.tsNormalize
- const permissionsFromRoles = Object.entries(permissions)
- .filter(([key, _]) => user.roles.includes(key))
- .map(([_, value]) => value);
+ // extract string IDs from Role objects (or use directly if already string[])
+ const roleIds = user.roles.map(r => typeof r === 'string' ? r : r._id.toString());
+ const permissionsFromRoles = Object.entries(permissions)
+ .filter(([key]) => roleIds.includes(key))
+ .map(([_, value]) => value);Ensure that 🤖 Prompt for AI Agents |
||
| .map(([_, value]) => value); | ||
|
Comment on lines
+43
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chainVerify the structure of permissions returned by getPermissions(). The code assumes that 🏁 Script executed: #!/bin/bash
# Description: Check the implementation of getPermissions() method to understand its return type
# Search for the getPermissions method implementation
ast-grep --pattern $'getPermissions() {
$$$
}'
# Also search with async
ast-grep --pattern $'async getPermissions() {
$$$
}'
# Look for the method signature in PermissionService
rg -A 10 "getPermissions" api/src/user/services/permission.service.tsLength of output: 549 🏁 Script executed: #!/bin/bash
# Locate the PermissionsTree type definition
rg -n "type PermissionsTree" -n api
rg -n "interface PermissionsTree" -n api
# Inspect buildTree implementation to see its return shape
rg -A 25 "buildTree" -n api/src/user/services/permission.service.tsLength of output: 932 Document the We’ve confirmed that • File: import type { PermissionsTree } from '../../user/types/permission.type';
// Explicitly type the returned permissions structure
const permissions: PermissionsTree = await this.permissionService.getPermissions();— or — /**
* PermissionsTree mapping role IDs to model permissions:
* Record<roleId, Record<modelIdentity, string[]>>
*/
const permissions = await this.permissionService.getPermissions();This documents the expected shape and reinforces the assumption that roles are the top-level keys. 🤖 Prompt for AI Agents |
||
|
|
||
| if ( | ||
| permissionsFromRoles.some((permission) => | ||
| permission[targetModel]?.includes(MethodToAction[method]), | ||
| ) | ||
| ) { | ||
| return true; | ||
| } | ||
| } | ||
| } catch (err) { | ||
| this.logger.error('Request has no ability to get access', err); | ||
| return false; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -16,21 +16,19 @@ import { | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| UnauthorizedException, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from '@nestjs/common'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Reflector } from '@nestjs/core'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { EventEmitter2 } from '@nestjs/event-emitter'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Request } from 'express'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { AuthorizationService } from '@/authorization/authorization.service'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { TRole } from '../schemas/role.schema'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { User } from '../schemas/user.schema'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { PermissionService } from '../services/permission.service'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { MethodToAction } from '../types/action.type'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { TModel } from '../types/model.type'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Injectable() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export class Ability implements CanActivate { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| constructor( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private reflector: Reflector, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private readonly permissionService: PermissionService, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private readonly eventEmitter: EventEmitter2, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private readonly authorizationService: AuthorizationService, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async canActivate(context: ExecutionContext): Promise<boolean> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -69,26 +67,17 @@ export class Ability implements CanActivate { | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const modelFromPathname = _parsedUrl?.pathname | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ?.split('/')[1] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .toLowerCase() as TModel | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const permissions = await this.permissionService.getPermissions(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (permissions) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const permissionsFromRoles = Object.entries(permissions) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(([key, _]) => user.roles.includes(key)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(([_, value]) => value); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const targetModel = _parsedUrl?.pathname?.split('/')[1].toLowerCase() as | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suggest we tackle this in a different PR, but it's worth mentioning that we can improve the curent implementation by using the built-in |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | TModel | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| modelFromPathname && | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| permissionsFromRoles.some((permission) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| permission[modelFromPathname]?.includes(MethodToAction[method]), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return await this.authorizationService.canAccess( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| method, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| user.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| targetModel, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new NotFoundException('Failed to load permissions'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+74
to
82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suggest we use throw
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ensure user roles are properly populated.
The
findOnemethod might not populate the user's roles. Based on the code snippet fromuser.controller.ts, you should usefindOneAndPopulateto ensure roles are loaded.📝 Committable suggestion
🤖 Prompt for AI Agents
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't need to use findOneAndPopulate because we only use the role IDs for permission checks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@medchedli You're absolutely right! Looking at the code, the roles are stored as an array of role IDs in the user document, and the permission check uses
user.roles.includes(key)which works directly with the IDs. No need for population since we're only doing ID-based comparisons. Thanks for the clarification!learns something new
✏️ Learnings added