Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4dd7b85
chore: role base implementation
batmnkh2344 Sep 25, 2025
b2a1d4f
Merge branch 'main' of github.com:erxes/erxes-next into permission
batmnkh2344 Sep 29, 2025
2e96770
Merge branch 'main' of github.com:erxes/erxes-next into permission
batmnkh2344 Sep 30, 2025
4a216b7
chore: auto-create default role if none exists
batmnkh2344 Sep 30, 2025
22ae5dc
chore: wrap resolvers
batmnkh2344 Sep 30, 2025
5bb7617
Merge branch 'main' of github.com:erxes/erxes-next into permission
batmnkh2344 Sep 30, 2025
ac4e7bc
chore: mark resolvers as group
batmnkh2344 Oct 1, 2025
c335d42
Merge branch 'main' of github.com:erxes/erxes-next into permission
batmnkh2344 Oct 1, 2025
e735d67
Merge branch 'main' of github.com:erxes/erxes-next into permission
batmnkh2344 Oct 1, 2025
660d31c
Merge branch 'main' of github.com:erxes/erxes-next into permission
batmnkh2344 Oct 1, 2025
0f2aa6c
chore: prevent multiple owner
batmnkh2344 Oct 1, 2025
6626154
chore: notification for team invite
batmnkh2344 Oct 1, 2025
c230769
added role select field on team members record field
Kato-111 Oct 2, 2025
bee6e59
Merge branch 'permission' of github.com:erxes/erxes-next into permission
Kato-111 Oct 2, 2025
323eec5
follow up
Kato-111 Oct 2, 2025
e54574c
Merge branch 'main' into permission
Enkhtuvshin0513 Oct 3, 2025
9e9bf26
Merge branch 'permission' of github.com:erxes/erxes-next into permission
Kato-111 Oct 3, 2025
d1d8585
relative to absolute import
Kato-111 Oct 3, 2025
6340390
Merge branch 'main' of github.com:erxes/erxes-next into permission
batmnkh2344 Oct 10, 2025
fbb5a87
Merge branch 'permission' of github.com:erxes/erxes-next into permission
batmnkh2344 Oct 10, 2025
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
11 changes: 4 additions & 7 deletions backend/core-api/src/apollo/apolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { buildSubgraphSchema } from '@apollo/subgraph';
import * as dotenv from 'dotenv';
import { IMainContext } from 'erxes-api-shared/core-types';
import {
generateApolloContext,
apolloCommonTypes,
wrapApolloMutations,
generateApolloContext,
wrapApolloResolvers,
} from 'erxes-api-shared/utils';
import { gql } from 'graphql-tag';
import { generateModels } from '../connectionResolvers';
import resolvers from './resolvers';
import { IMainContext } from 'erxes-api-shared/core-types';

import * as typeDefDetails from './schema/schema';
// load environment variables
Expand Down Expand Up @@ -42,10 +42,7 @@ export const initApolloServer = async (app, httpServer) => {
schema: buildSubgraphSchema([
{
typeDefs: await typeDefs(),
resolvers: {
...resolvers,
Mutation: wrapApolloMutations(resolvers?.Mutation || {}, ['login']),
},
resolvers: wrapApolloResolvers(resolvers),
},
]),
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
Expand Down
2 changes: 2 additions & 0 deletions backend/core-api/src/apollo/resolvers/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { relationsMutations } from '@/relations/graphql/mutations';
import { segmentMutations } from '@/segments/graphql/resolvers/mutations';
import { tagMutations } from '@/tags/graphql/mutations';
import { notificationMutations } from '~/modules/notifications/graphql/resolver/mutations';
import { roleMutations } from '~/modules/permissions/graphql/resolvers/mutations/role';

export const mutations = {
...contactMutations,
Expand All @@ -40,4 +41,5 @@ export const mutations = {
...automationMutations,
...notificationMutations,
...internalNoteMutations,
...roleMutations,
};
2 changes: 2 additions & 0 deletions backend/core-api/src/apollo/resolvers/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { segmentQueries } from '@/segments/graphql/resolvers';
import { tagQueries } from '@/tags/graphql/queries';

import { notificationQueries } from '@/notifications/graphql/resolver/queries';
import { roleQueries } from '@/permissions/graphql/resolvers/queries/role';

export const queries = {
...contactQueries,
Expand All @@ -43,4 +44,5 @@ export const queries = {
...logQueries,
...notificationQueries,
...internalNoteQueries,
...roleQueries,
};
4 changes: 3 additions & 1 deletion backend/core-api/src/apollo/resolvers/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import contactResolvers from '@/contacts/graphql/resolvers/customResolvers';
import documentResolvers from '@/documents/graphql/customResolvers';
import internalNoteResolvers from '@/internalNote/graphql/customResolvers';
import logResolvers from '@/logs/graphql/resolvers/customResolvers';
import notificationResolvers from '@/notifications/graphql/customResolvers';
import brandResolvers from '@/organization/brand/graphql/customResolver/brand';
import structureResolvers from '@/organization/structure/graphql/resolvers/customResolvers';
import userResolvers from '@/organization/team-member/graphql/customResolver';
import permissionResolvers from '@/permissions/graphql/resolvers/customResolver';
import productResolvers from '@/products/graphql/resolvers/customResolvers';
import segmentResolvers from '@/segments/graphql/resolvers/customResolvers';
import tagResolvers from '@/tags/graphql/customResolvers';
import notificationResolvers from '@/notifications/graphql/customResolvers';

export const customResolvers = {
...contactResolvers,
Expand All @@ -24,4 +25,5 @@ export const customResolvers = {
...notificationResolvers,
...documentResolvers,
...internalNoteResolvers,
...permissionResolvers,
};
19 changes: 14 additions & 5 deletions backend/core-api/src/apollo/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,21 @@ import {
} from '@/logs/graphql/schema';

import {
mutations as NotificationsMutations,
queries as NotificationsQueries,
types as NotificationsTypes,
} from '@/notifications/graphql/schema';
import{
mutations as InternalNoteMutations,
queries as InternalNoteQueries,
types as InternalNoteTypes,
} from '@/internalNote/graphql/schemas';
import {
mutations as NotificationsMutations,
queries as NotificationsQueries,
types as NotificationsTypes,
} from '@/notifications/graphql/schema';

import {
mutations as RoleMutations,
queries as RoleQueries,
types as RoleTypes,
} from '@/permissions/graphql/schemas/role';

export const types = `
enum CacheControlScope {
Expand Down Expand Up @@ -192,6 +198,7 @@ export const types = `
${LogsTypes}
${NotificationsTypes}
${InternalNoteTypes}
${RoleTypes}
`;

export const queries = `
Expand Down Expand Up @@ -221,6 +228,7 @@ export const queries = `
${LogsQueries}
${NotificationsQueries}
${InternalNoteQueries}
${RoleQueries}
`;

export const mutations = `
Expand Down Expand Up @@ -249,6 +257,7 @@ export const mutations = `
${AutomationsMutations}
${NotificationsMutations}
${InternalNoteMutations}
${RoleMutations}
`;

export default { types, queries, mutations };
11 changes: 11 additions & 0 deletions backend/core-api/src/connectionResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
IProductDocument,
IProductsConfigDocument,
IRelationDocument,
IRoleDocument,
ITagDocument,
IUomDocument,
IUserDocument,
Expand Down Expand Up @@ -145,6 +146,10 @@ import {
INotificationDocument,
notificationSchema,
} from 'erxes-api-shared/core-modules';
import {
IRoleModel,
loadRoleClass,
} from '~/modules/permissions/db/models/Roles';
import {
IAutomationModel,
loadClass as loadAutomationClass,
Expand All @@ -162,6 +167,7 @@ export interface IModels {
UserMovements: IUserMovemmentModel;
Configs: IConfigModel;
Permissions: IPermissionModel;
Roles: IRoleModel;
UsersGroups: IUserGroupModel;
Tags: ITagModel;
InternalNotes: IInternalNoteModel;
Expand Down Expand Up @@ -244,6 +250,11 @@ export const loadClasses = (
loadPermissionClass(models),
);

models.Roles = db.model<IRoleDocument, IRoleModel>(
'roles',
loadRoleClass(models),
);

models.UsersGroups = db.model<IUserGroupDocument, IUserGroupModel>(
'user_groups',
loadUserGroupClass(models),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { WorkOS } from '@workos-inc/node';
import {
authCookieOptions,
getEnv,
logHandler,
markResolvers,
redis,
updateSaasOrganization,
} from 'erxes-api-shared/utils';
import { IContext } from '~/connectionResolvers';
import { WorkOS } from '@workos-inc/node';
import * as jwt from 'jsonwebtoken';
import { IContext } from '~/connectionResolvers';
import {
getCallbackRedirectUrl,
isValidEmail,
Expand Down Expand Up @@ -239,3 +240,7 @@ export const authMutations = {
return 'success';
},
};

markResolvers(authMutations, {
skipPermission: true,
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { markResolvers } from 'erxes-api-shared/utils/apollo/wrapperResolvers';
import { IContext } from '~/connectionResolvers';

export const authQueries = {
Expand All @@ -16,3 +17,7 @@ export const authQueries = {
return result;
},
};

markResolvers(authQueries, {
skipPermission: true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {

import { USER_MOVEMENT_STATUSES } from 'erxes-api-shared/core-modules';
import { title } from 'process';
import { PERMISSION_ROLES } from '~/modules/permissions/db/constants';

const SALT_WORK_FACTOR = 10;

Expand Down Expand Up @@ -234,7 +235,7 @@ export const loadUserClass = (models: IModels, subdomain: string) => {
this.checkPassword(password);
}

return models.Users.create({
const user = await models.Users.create({
isOwner,
username,
email,
Expand All @@ -246,6 +247,13 @@ export const loadUserClass = (models: IModels, subdomain: string) => {
password: notUsePassword ? '' : await this.generatePassword(password),
code: await this.generateUserCode(),
});

models.Roles.create({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider awaiting the asynchronous call to models.Roles.create when assigning a role, so that any errors in role creation are caught.

Suggested change
models.Roles.create({
await models.Roles.create({

userId: user._id,
role: PERMISSION_ROLES.MEMBER,
});
Comment on lines +251 to +254
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Missing await keyword - role creation should be awaited to ensure completion before returning user

Suggested change
models.Roles.create({
userId: user._id,
role: PERMISSION_ROLES.MEMBER,
});
await models.Roles.create({
userId: user._id,
role: PERMISSION_ROLES.MEMBER,
});

Comment on lines +251 to +254
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Assign OWNER role when isOwner is true.

createUser still flags owners in the Users document, but the associated role record is hard-coded to member, so the very first tenant admin (and any other owner creation path) loses elevated privileges immediately after sign-up. Please derive the role from isOwner before persisting.

-      models.Roles.create({
-        userId: user._id,
-        role: PERMISSION_ROLES.MEMBER,
-      });
+      const role = isOwner
+        ? PERMISSION_ROLES.OWNER
+        : PERMISSION_ROLES.MEMBER;
+
+      models.Roles.create({
+        userId: user._id,
+        role,
+      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
models.Roles.create({
userId: user._id,
role: PERMISSION_ROLES.MEMBER,
});
const role = isOwner
? PERMISSION_ROLES.OWNER
: PERMISSION_ROLES.MEMBER;
models.Roles.create({
userId: user._id,
role,
});
🤖 Prompt for AI Agents
In backend/core-api/src/modules/organization/team-member/db/models/Users.ts
around lines 251 to 254, the role is hard-coded to PERMISSION_ROLES.MEMBER when
creating the Roles record; instead derive the role from the user.isOwner flag
and persist that value. Compute const role = user.isOwner ?
PERMISSION_ROLES.OWNER : PERMISSION_ROLES.MEMBER (or the equivalent constant
used in this file) and pass role into models.Roles.create({ userId: user._id,
role }). Ensure you use the existing PERMISSION_ROLES value and replace the
hard-coded MEMBER with this derived role.


return user;
}

/**
Expand Down Expand Up @@ -329,7 +337,7 @@ export const loadUserClass = (models: IModels, subdomain: string) => {

this.checkPassword(password);

await models.Users.create({
const user = await models.Users.create({
email,
groupIds: [groupId],
isActive: true,
Expand All @@ -341,6 +349,11 @@ export const loadUserClass = (models: IModels, subdomain: string) => {
brandIds,
});

models.Roles.create({
userId: user._id,
role: PERMISSION_ROLES.MEMBER,
});
Comment on lines +352 to +355
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Missing await keyword - role creation should be awaited to ensure completion

Suggested change
models.Roles.create({
userId: user._id,
role: PERMISSION_ROLES.MEMBER,
});
await models.Roles.create({
userId: user._id,
role: PERMISSION_ROLES.MEMBER,
});


return token;
}

Expand Down Expand Up @@ -795,7 +808,7 @@ export const loadUserClass = (models: IModels, subdomain: string) => {
}
}

if (user.isOwner && !user.lastSeenAt) {
if (!user.lastSeenAt) {
const pluginNames = await getPlugins();

for (const pluginName of pluginNames) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getUserActionsMap, USER_ROLES } from 'erxes-api-shared/core-modules';
import { IUserDocument } from 'erxes-api-shared/core-types';
import { IContext } from '~/connectionResolvers';
import { getUserActionsMap, USER_ROLES } from 'erxes-api-shared/core-modules';

export default {
__resolveReference: async ({ _id }, { models }: IContext) => {
Expand All @@ -21,6 +21,12 @@ export default {
return 'Verified';
},

async role(user: IUserDocument, _args: undefined, { models }: IContext) {
const { role } = await models.Roles.getRole(user._id);

return role;
},

// async currentOrganization(_user, _args, { subdomain, models }: IContext) {
// const organization = await getOrganizationDetail({ subdomain, models });

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { IContext } from '~/connectionResolvers';
import {
IUser,
IDetail,
ILink,
IEmailSignature,
ILink,
IUser,
Resolver,
} from 'erxes-api-shared/core-types';
import { IContext } from '~/connectionResolvers';
import { PERMISSION_ROLES } from '~/modules/permissions/db/constants';

export interface IUsersEdit extends IUser {
channelIds?: string[];
_id: string;
}

export const userMutations = {
export const userMutations: Record<string, Resolver> = {
async usersCreateOwner(
_parent: undefined,
{
Expand Down Expand Up @@ -48,7 +50,12 @@ export const userMutations = {
},
};

await models.Users.createUser(doc);
const user = await models.Users.createUser(doc);

models.Roles.create({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, consider awaiting the call to models.Roles.create in the usersCreateOwner mutation for consistent error handling.

Suggested change
models.Roles.create({
await models.Roles.create({

userId: user._id,
role: PERMISSION_ROLES.OWNER,
Comment on lines +55 to +57
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Role creation is not awaited after user creation.

Not awaiting models.Roles.create may lead to permission issues if later code assumes the role exists. Please await this call.

});
Comment on lines +55 to +58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Missing await keyword - role creation should be awaited to ensure completion

Suggested change
models.Roles.create({
userId: user._id,
role: PERMISSION_ROLES.OWNER,
});
await models.Roles.create({
userId: user._id,
role: PERMISSION_ROLES.OWNER,
});

Comment on lines +53 to +58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Prevent owner role downgrade and await persistence

models.Users.createUser now writes a role document (defaulting to MEMBER) asynchronously. Immediately calling models.Roles.create here (without await) introduces two problems: (1) you race the pending create from createUser, so up to two role docs can be written for the same user, and findOne({ userId }) will typically return the first insert (MEMBER), effectively stripping the new owner of owner-level permissions; (2) any failure in the role write is silently ignored because the promise isn’t awaited, leaving the user without a role. Please update existing role state instead of blindly inserting, and await the write so the mutation fails fast on error.

-    models.Roles.create({
-      userId: user._id,
-      role: PERMISSION_ROLES.OWNER,
-    });
+    await models.Roles.findOneAndUpdate(
+      { userId: user._id },
+      { $set: { role: PERMISSION_ROLES.OWNER }, $setOnInsert: { userId: user._id } },
+      { upsert: true },
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const user = await models.Users.createUser(doc);
models.Roles.create({
userId: user._id,
role: PERMISSION_ROLES.OWNER,
});
const user = await models.Users.createUser(doc);
await models.Roles.findOneAndUpdate(
{ userId: user._id },
{
$set: { role: PERMISSION_ROLES.OWNER },
$setOnInsert: { userId: user._id }
},
{ upsert: true },
);
🤖 Prompt for AI Agents
In backend/core-api/src/modules/organization/team-member/graphql/mutations.ts
around lines 52 to 57, the code races and silently drops the OWNER role because
createUser also writes a MEMBER role asynchronously and the un-awaited
models.Roles.create can produce duplicate docs or be ignored on failure; replace
the blind create with an awaited update/upsert that sets the role to
PERMISSION_ROLES.OWNER (e.g., findOneAndUpdate({ userId: user._id }, { $set: {
role: OWNER } }, { upsert: true, returnDocument: 'after' })) and await the
promise so the mutation fails fast on error, and surface or throw the error
rather than ignoring it.


if (subscribeEmail && process.env.NODE_ENV === 'production') {
await fetch('https://erxes.io/subscribe', {
Expand Down Expand Up @@ -138,14 +145,14 @@ export const userMutations = {
details,
links,
employeeId,
positionIds
positionIds,
}: {
username: string;
email: string;
details: IDetail;
links: ILink;
employeeId: string;
positionIds: string[]
positionIds: string[];
},
{ user, models }: IContext,
) {
Expand All @@ -158,7 +165,7 @@ export const userMutations = {
},
links,
employeeId,
positionIds
positionIds,
};

const updatedUser = await models.Users.editProfile(user._id, doc);
Expand Down Expand Up @@ -351,3 +358,5 @@ export const userMutations = {
return;
},
};

userMutations.usersCreateOwner.skipPermission = true;
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const types = `
customFieldsData: JSON

isOwner: Boolean
role: String
permissionActions: JSON
configs: JSON
configsConstants: [JSON]
Expand Down
6 changes: 6 additions & 0 deletions backend/core-api/src/modules/permissions/db/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const PERMISSION_ROLES = {
OWNER: 'owner',
ADMIN: 'admin',
MEMBER: 'member',
ALL: ['owner', 'admin', 'member'],
};
20 changes: 20 additions & 0 deletions backend/core-api/src/modules/permissions/db/definitions/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Schema } from 'mongoose';
import { PERMISSION_ROLES } from '~/modules/permissions/db/constants';

export const roleSchema = new Schema(
{
userId: { type: String, label: 'User', index: true, required: true },
role: {
type: String,
enum: PERMISSION_ROLES.ALL,
label: 'Role',
index: true,
required: true,
},
},
{
timestamps: true,
},
);

roleSchema.index({ userId: 1, role: 1 }, { unique: true });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Compound unique index correctly enforces one document per (userId, role) pair.

The compound unique index allows a user to have multiple role documents (e.g., one for OWNER, another for ADMIN). Verify this multi-role-per-user design aligns with your authorization model. If the intent is strictly one role per user, change the index to { userId: 1 }, { unique: true } and remove role from the index.

Run this script to confirm whether the codebase expects single or multiple roles per user:


🏁 Script executed:

#!/bin/bash
# Check if role assignment logic expects one role or multiple roles per user

echo "=== Searching for role assignment patterns ==="
rg -nP --type=ts 'models\.Roles\.(create|update|getRole)' -A 3 -B 1

echo ""
echo "=== Checking for role queries/filters ==="
rg -nP --type=ts 'userId.*role|role.*userId' -C 2 -g '!**/node_modules/**'

Length of output: 4857


🏁 Script executed:

#!/bin/bash
set -e

echo "=== getRole implementation ==="
rg -nC5 "static async getRole" backend/core-api/src/modules/permissions/db/models/Roles.ts

echo ""
echo "=== createRole implementation ==="
rg -nC5 "static async createRole" backend/core-api/src/modules/permissions/db/models/Roles.ts

echo ""
echo "=== updateRole implementation ==="
rg -nC5 "static async updateRole" backend/core-api/src/modules/permissions/db/models/Roles.ts

Length of output: 984


Enforce single-role-per-user by updating unique index
In backend/core-api/src/modules/permissions/db/definitions/roles.ts:20 change

roleSchema.index({ userId: 1, role: 1 }, { unique: true });

to

roleSchema.index({ userId: 1 }, { unique: true });

to align with getRole/createRole/updateRole—all of which operate on a single document per user.

🤖 Prompt for AI Agents
In backend/core-api/src/modules/permissions/db/definitions/roles.ts around line
20, the compound unique index on { userId: 1, role: 1 } must be changed to a
single-field unique index on userId to enforce one role document per user;
update the index call to use { userId: 1 } with { unique: true } (remove role
from the key), save the file, and ensure the application rebuilds the index (or
drop/recreate the old index in the DB/migration) so the new unique constraint
takes effect.

Loading
Loading