Conversation
BREAKING CHANGE: remove auto incremental ids from user, group and permissions and add a virtual uid property that returns string value of documents object id
Execution.spec.ts, processProgram.spec.ts and code.spec.ts didn't exist when issue-361 (ID -> UID) branched, so they were written against the old userId: number shape. Update them to match the merged-in string-based uid now that main has been merged in.
POST /SASLogon/login and GET /SASjsApi/session still returned the old `id` field, while the rest of the ID->UID migration (#363) standardized on `uid`. Not functionally broken - Mongoose provides a built-in `id` virtual by default (_id.toHexString()) that happened to resolve to the same value as the new `uid` virtual - but it's an inconsistent public API surface, and relying on that coincidence wasn't the intent of the migration. Neither of these files was touched by any of issue-361's own commits, so this predates the merge rather than being caused by it. - web.ts: login response and session storage now source from user.uid explicitly - session.ts: SessionResponse dropped its Omit<UserResponse, 'uid'> + id override in favor of just extending UserResponse - verifyTokenInDB.ts: token-refresh path, same fix - login.tsx / appContext.tsx: updated to read the corrected field Verified with a real end-to-end request (genuine app boot, real MongoDB, real CSRF handshake) - not just type-checking - to confirm the actual HTTP response bodies carry uid, not id.
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Request Changes
This PR replaces numeric auto-increment IDs with MongoDB _id-based UIDs (24-char hex strings) across the entire server codebase. The core approach is sound, but there are several critical issues that need to be addressed before merging.
Critical
-
permission.tsline 341 —select: 'groupId name description'not updated to'uid name description': InupdatePermission, the group populate select still saysgroupIdwhile every other select in the PR was changed touid. The response will have a missing/nulluidfor the group object on PATCH permission. Should be.populate({ path: 'group', select: 'uid name description' }). -
desktop.tsline 6 — regex/^\/SASjsApi\/user\/[0-9]*$/only matches numeric IDs: UIDs are now 24-char hex strings. In desktop mode, GET/PATCH to/SASjsApi/user/{uid}will be blocked bydesktopRestrict, breaking desktop user profile access entirely. This line wasn't modified in the PR but is now broken by the ID→UID change. Update to/^\/SASjsApi\/user\/[0-9a-fA-F]{24}$/. -
seedDB.ts—ALL_USERS_GROUPname changed from'AllUsers'to'all-users'without migration: Existing deployments will get a duplicate group — new users join'all-users'while existing users remain in'AllUsers'. Permissions referencing the old group name are orphaned. The comment on line 27 still says'AllUsers'. Either add a migration to rename the existing group, or keep the original name. -
Merge regression reverting PR #388: The
issue-361branch was based on a commit before the #388 fix (return 200 with log on SAS session failure). The merge into main reverted the fix —processProgram.tsnow throws onSessionState.failedinstead of resolving,Execution.tswraps it inSessionExecutionErrorproducing a 400, and tests were reverted to expect the old throwing behavior.
Warnings
- swagger.yaml
SessionResponsestill usesidnotuid— Code returnsuidbut committed swagger showsid. Same for/SASLogon/loginuser object. - Inconsistent param naming in
UserController—getUseruses@Path() uidbutupdateUser/deleteUseruse@Path() userIdwith stale@example userId 1234. - Missing
uidValidationonPOST /:groupUid/:userUidandDELETE /:groupUid/:userUidroutes ingroup.ts. group.ts@example userId "12ByteString"should be@example userUidfor theaddUserToGroupmethod.
Looks Good
- Core approach of using MongoDB
_idasuidvirtual is sound - Clean removal of
Countermodel andgetSequenceNextValue - Good
uidValidationwith Joi (24-char hex) security addition - Frontend changes are consistent and complete
- Test files properly updated throughout
Reviewed by Hermes Agent (GitHub App)
| .populate({ | ||
| path: 'group', | ||
| select: 'groupId name description -_id' | ||
| select: 'groupId name description' |
There was a problem hiding this comment.
Critical: The group populate select here still says 'groupId name description', but every other select in this PR was changed to 'uid name description'. On a PATCH /SASjsApi/permission response, the group object will have a missing/null uid field. Should be:
.populate({ path: 'group', select: 'uid name description' })Reviewed by Hermes Agent (GitHub App)
|
|
||
| export const ALL_USERS_GROUP = { | ||
| name: 'AllUsers', | ||
| name: 'all-users', |
There was a problem hiding this comment.
Critical: The ALL_USERS_GROUP name was changed from 'AllUsers' to 'all-users' without a migration. Existing deployments will get a duplicate group — new users join 'all-users' while existing users remain in 'AllUsers'. Permissions referencing the old group name are orphaned.
Also, the comment on line 27 still says 'AllUsers'.
Either add a migration to rename the existing group, or keep the original name.
Reviewed by Hermes Agent (GitHub App)
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Request Changes
This is a well-conceived refactor that replaces numeric auto-increment IDs with virtual uid fields backed by MongoDB _id (ObjectId). The virtual-uid-on-_id approach is sound and requires zero data migration for the ID swap itself. However, there are 4 critical bugs that will break runtime behavior or silently break existing deployments.
Critical
-
C1:
updatePermissionstill selects the removedgroupIdfield (api/src/controllers/permission.ts:341): The populateselect: 'groupId name description'references a field that no longer exists on theGroupmodel (replaced by theuidvirtual). Every other populate in this PR was updated to'uid name description'— this one was missed. PATCH on group-based permissions returns a broken group object. No test covers PATCH on a group-typed permission, which is why CI is green. (Inline comment posted on this line.) -
C2: Desktop mode regex rejects hex UIDs (
api/src/middlewares/desktop.ts:6):regexUser = /^\/SASjsApi\/user\/[0-9]*$/only matches numeric IDs. UIDs are 24-character hex strings (e.g.,507f1f77bcf86cd799439011) containinga-f, which won't match. In Desktop mode,GET /SASjsApi/user/<uid>and PATCH will return 403 — a hard regression for every Desktop-mode install. No spec exercises Desktop mode. Fix:const regexUser = /^\/SASjsApi\/user\/[0-9a-fA-F]{24}$/ -
C3: Leftover
.idreferences in tests (user.spec.ts:240,244,251,255,auth.spec.ts:239): These LDAP-related test cases still usedbUser!.id/currentUser.idinstead of.uid. TheUsermodel no longer has anidfield. Theauth.spec.ts:239case is a false-positive test —verifyTokenInDB(currentUser.id, ...)returnsundefined, matchingtoBeUndefined(), but no longer validates that the token was actually removed for that user. Alsouser.spec.ts:267uses hardcoded/SASjsApi/user/1234which now failsuidValidationwith 400, not the expected 401. -
C4:
PUBLIC_GROUP_NAMErenamed'Public'→'public'andALL_USERS_GROUP'AllUsers'→'all-users'— breaks existing deployments:isPublicRoute.tsqueriesGroup.findOne({ name: PUBLIC_GROUP_NAME }). For any existing database seeded under the old name'Public', this returnsnulland every public-route permission grant silently stops working on upgrade.seedDBwon't find the old'AllUsers'group and will create a second orphan group. The PR body claims "no data migration is required" — true for the ID→UID swap, but the group-name rename is an undocumented breaking change. Either revert the renames or add a migration step. (Inline comments posted on both lines.)
Warnings
- W1:
verifyAdminIfNeededrelies onreq.params.uid— tightly coupled to the literal param name. Considerreq.params.uid ?? req.params.userId. - W2:
getPreProgramVariablesfallbackuserId: 'demoId'is not a valid ObjectId. Use'000000000000000000000000'. - W3:
publicUser.userId = 'public_user_id'and desktop'1234'/'12345'are sentinel strings, not valid UIDs — add clarifying comments. - W4:
permission.ts:176JSDoc says@param permissionId The user's identifier— copy-paste from user controller, doesn't match@Path() uid. (Inline comment posted.)
Suggestions
- S1: Extract a shared
populateGrouphelper — the group-populate string is duplicated in 4 places. C1 happened because one copy drifted. - S2: Add a test for PATCH permission on a group principal, and at least one Desktop-mode test.
- S3:
addUserToGroup/removeUserFromGrouproutes don't runuidValidationongroupUid/userUidparams. - S4:
crypto.tsis fine security-wise —randomBytesis the correct CSPRNG. - S5:
SessionResponsedroppedneedsToUpdatePasswordfromrequiredin Swagger — confirm frontend handles its absence.
Looks Good
- Virtual
uidover_idis the right design — no stored duplicate, no migration for the ID swap. Counter.tsandgetSequenceNextValue.tsremoval is clean — zero lingering references.uidValidation(Joi.string().length(24).hex().required()) is correctly applied to route params.- Auth middleware chain is intact — no auth-bypass introduced.
- Frontend migration is complete and consistent across all components.
GroupActionenum replacing magic strings is a clean improvement.
Reviewed by Hermes Agent (GitHub App)
| .populate({ | ||
| path: 'group', | ||
| select: 'groupId name description -_id' | ||
| select: 'groupId name description' |
There was a problem hiding this comment.
Critical (C1): select: 'groupId name description' — groupId no longer exists on the Group model (replaced by the uid virtual in this PR). Every other populate was updated to 'uid name description' (e.g., Permission.ts:70, the user populate at line 338). This one was missed.
Fix:
select: 'uid name description'No test covers PATCH on a group-typed permission, which is why this slipped through CI.
|
|
||
| export const desktopUser: RequestUser = { | ||
| userId: 12345, | ||
| userId: '12345', |
There was a problem hiding this comment.
Critical (C2) — related to this change: the desktopUser.userId is now '12345' (string), but the regexUser at line 6 (/^\/SASjsApi\/user\/[0-9]*$/) only matches numeric IDs. UIDs are 24-character hex strings (e.g., 507f1f77bcf86cd799439011) containing a-f, which won't match [0-9]*.
In Desktop mode, GET /SASjsApi/user/<uid> and PATCH will return 403. Fix the regex at line 6:
const regexUser = /^\/SASjsApi\/user\/[0-9a-fA-F]{24}$/Or relax to /^\/SASjsApi\/user\/[\w-]+$/ to also tolerate '12345'.
| import { AuthProviderType } from '../utils' | ||
|
|
||
| export const PUBLIC_GROUP_NAME = 'Public' | ||
| export const PUBLIC_GROUP_NAME = 'public' |
There was a problem hiding this comment.
Critical (C4): Renaming PUBLIC_GROUP_NAME from 'Public' to 'public' breaks existing deployments. isPublicRoute.ts queries Group.findOne({ name: PUBLIC_GROUP_NAME }) — for any database seeded under the old name 'Public', this returns null and every public-route permission grant silently stops working on upgrade.
Either revert to 'Public' or add a migration step in seedDB.
|
|
||
| export const ALL_USERS_GROUP = { | ||
| name: 'AllUsers', | ||
| name: 'all-users', |
There was a problem hiding this comment.
Critical (C4): Renaming ALL_USERS_GROUP.name from 'AllUsers' to 'all-users' breaks existing deployments — seedDB won't find the old 'AllUsers' group and will create a second orphan group.
Also: groupnameSchema is Joi.string().lowercase().alphanum() — 'all-users' contains a hyphen (not alphanum). seedDB bypasses Joi so this won't error, but API-created groups can't use this name format.
|
|
||
| /** | ||
| * @summary Delete a permission. Admin only. | ||
| * @param permissionId The user's identifier |
There was a problem hiding this comment.
Warning (W4): JSDoc says @param permissionId The user's identifier — copy-paste from user controller. The param is now uid (via @Path() uid), not permissionId. Same issue on updatePermission.
| .populate({ | ||
| path: 'group', | ||
| select: 'groupId name description -_id' | ||
| select: 'groupId name description' |
There was a problem hiding this comment.
groupId no longer exists on the Group model after this migration — the model now exposes a uid virtual (stringified _id). Every other populate in this file was updated to uid, but this one in updatePermission was missed, so the returned group object will be missing the uid field the frontend expects.
| select: 'groupId name description' | |
| .populate({ path: 'group', select: 'uid name description' }) |
| isAdmin: false, | ||
| isActive: true | ||
| }) | ||
| @Patch('{userId}') |
There was a problem hiding this comment.
@Get('{uid}') was renamed in this PR, but @Patch('{userId}') and @Delete('{userId}') below were not. tsoa will generate two separate swagger paths (/SASjsApi/user/{uid} and /SASjsApi/user/{userId}), which is inconsistent and confusing for API consumers. The param name is cosmetic (both map to _id), but for a clean contract they should all be {uid}.
| @Patch('{userId}') | |
| @Patch('{uid}') |
| @@ -198,18 +194,16 @@ export class UserController { | |||
| */ | |||
| @Delete('{userId}') | |||
There was a problem hiding this comment.
@Delete('{userId}') should also be renamed to {uid} for consistency with @Get('{uid}') (same as the @Patch above). Additionally the JSDoc @example userId 1234 above this decorator still uses a numeric example even though userId is now a hex UID string — update it to e.g. "507f1f77bcf86cd799439011".
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Request Changes
Migrating numeric auto-increment IDs to stringified MongoDB ObjectId UIDs is a sound approach — collision-resistant, unpredictable, no auth-bypass risk — and the bulk of the migration (models, controllers, middleware, tests, frontend) is consistently applied. Counter.ts and getSequenceNextValue.ts are cleanly removed with no dangling references. A few real bugs and inconsistencies remain.
Critical
-
permission.ts:341— broken group populate inupdatePermission..populate({ path: 'group', select: 'groupId name description' })still referencesgroupId, which no longer exists on the Group model (now auidvirtual). Every other populate in the file was updated touid; this one was missed, so theupdatePermissionresponse returns a group object missing theuidfield the frontend expects. Fix:select: 'uid name description'. (Inline comment posted.) -
desktop.ts:6— allow-list regex no longer matches UIDs.const regexUser = /^\/SASjsApi\/user\/[0-9]*$/only matches numeric path segments, but UIDs are now 24-char hex strings. In desktop mode (MODE=desktop),GET /SASjsApi/user/{uid}andPATCH /SASjsApi/user/{uid}will fail the allow-list and be rejected with 403, breaking desktop mode for user routes. The PR touched this file (userId: 12345→userId: '12345') but didn't update the regex. Fix:/^\/SASjsApi\/user\/[0-9a-f]{24}$/i.
Warnings
-
Swagger path param inconsistency (
user.ts).@Get('{uid}')was renamed but@Patch('{userId}')and@Delete('{userId}')were not. tsoa generates two separate swagger entries (/SASjsApi/user/{uid}and/SASjsApi/user/{userId}). The param name is cosmetic (both map to_id), but unify to{uid}for a clean contract. (Inline comment posted.) -
Stale JSDoc examples.
@Deletestill has@example userId 1234(numeric) and@Patchhas@example userId "1234"even though the param is now a 24-char hex UID string. Update to a representative value. (Inline comment posted.) -
No data-migration handling. There's no migration script for existing documents. Old rows retain orphaned
id/groupId/permissionIdfields, old unique indexes may persist, and theCountercollection is orphaned. External clients persisting numeric IDs will break. If migrations are handled externally, that's fine — but it should be documented in the PR description / release notes. -
Missing
uidValidationon group add/remove-user routes.routes/api/group.tsappliesuidValidationto/:uidGET/DELETE but not to the/:groupUid/:userUidPOST/DELETE add/remove-user routes. Mongoose rejects invalid ObjectIds safely, so this isn't a security hole, but it's inconsistent.
Suggestions
publicUser.userId = 'public_user_id'is not a valid ObjectId (was0before, so pre-existing) — fine, but worth a comment so future readers don't assume it's an ObjectId.
Looks Good
- UID = stringified ObjectId: correct, collision-resistant, unpredictable — no auth-bypass concern.
- All
findOne({ id: ... })→findOne({ _id: ... })applied consistently across controllers, middleware, utils. verifyAdminIfNeededcorrectly updated to readreq.params.uid.uidValidationusesJoi.string().length(24).hex()— correct for ObjectId format.- Tests comprehensively updated with hex-string fixtures across all spec files.
- Frontend correctly migrated:
id→uid,groupId→uid,permissionId→uidacross components/hooks/types;appContext.tsxmade setters optional with null-check inprofile.tsx. Counter.ts/getSequenceNextValue.tsremoved with zero dangling references.
Reviewed by Hermes Agent (GitHub App)
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Request Changes
This PR replaces numeric auto-increment IDs with MongoDB ObjectId-based uid virtuals across the API and web frontend. The migration is thorough, but there are several bugs that will cause runtime failures.
Critical
-
permission.ts:341— group populate still selectsgroupIdwhich was removed from the Group model. InupdatePermission():select: 'groupId name description'. Every other group populate was updated touid; this one was missed. Should be'uid name description'. -
permission.ts:300— populate select excludes_idbutuidis a virtual derived from_id.select: 'uid username displayName isAdmin -_id'— with-_id, the virtualuid(which returnsthis._id.toString()) will beundefined. Remove-_id; thetoJSONtransform already strips_idfrom JSON output.
Warnings
-
user.ts:163,195— tsoa decorators@Patch('{userId}')and@Delete('{userId}')were not updated to{uid}. Express routes use/:uid, but the decorators still say{userId}, producing inconsistent swagger docs (GET uses{uid}, PATCH/DELETE use{userId}). -
Breaking change:
PUBLIC_GROUP_NAMEchanged from'Public'to'public'andALL_USERS_GROUP.namefrom'AllUsers'to'all-users'. Existing deployments will have orphaned groups.
Suggestions
- CI only runs lint/build — no test step. The bugs above would only surface at runtime with a real MongoDB.
Reviewed by Hermes Agent (GitHub App)
| .populate({ | ||
| path: 'group', | ||
| select: 'groupId name description -_id' | ||
| select: 'groupId name description' |
There was a problem hiding this comment.
Bug: select: 'groupId name description' — groupId was removed from the Group model in this PR. Every other group populate was updated to uid, but this one in updatePermission() was missed. Change to 'uid name description'.
| users: groupInDB.populate({ | ||
| path: 'users', | ||
| select: 'id username displayName isAdmin -_id', | ||
| select: 'uid username displayName isAdmin -_id', |
There was a problem hiding this comment.
Bug: select: 'uid username displayName isAdmin -_id' — the -_id excludes _id from the query, but uid is a virtual that returns this._id.toString(). With _id excluded, uid will be undefined. Remove -_id — the toJSON transform on the User model already strips _id from JSON output.
| isAdmin: false, | ||
| isActive: true | ||
| }) | ||
| @Patch('{userId}') |
There was a problem hiding this comment.
Inconsistency: The tsoa decorator is still @Patch('{userId}') but the express route uses /:uid. This generates swagger docs showing /SASjsApi/user/{userId} for PATCH while GET uses {uid}. Update to @Patch('{uid}') and @Path() uid: string.
| @@ -198,18 +194,16 @@ export class UserController { | |||
| */ | |||
| @Delete('{userId}') | |||
There was a problem hiding this comment.
Inconsistency: Same as above — @Delete('{userId}') should be @Delete('{uid}') with @Path() uid: string.
| import { AuthProviderType } from '../utils' | ||
|
|
||
| export const PUBLIC_GROUP_NAME = 'Public' | ||
| export const PUBLIC_GROUP_NAME = 'public' |
There was a problem hiding this comment.
Breaking change: PUBLIC_GROUP_NAME changed from 'Public' to 'public'. Existing deployments with a group named Public will have it orphaned. Consider a migration script.
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Request Changes
This PR replaces numeric auto-increment IDs with MongoDB ObjectId-based uid virtuals across the API and web frontend. The migration is thorough, but there are several bugs that will cause runtime failures.
Critical
-
permission.ts:341— group populate still selectsgroupIdwhich was removed from the Group model. InupdatePermission():select: 'groupId name description'. Every other group populate was updated touid; this one was missed. Should be'uid name description'. -
permission.ts:300— populate select excludes_idbutuidis a virtual derived from_id.select: 'uid username displayName isAdmin -_id'— with-_id, the virtualuid(which returnsthis._id.toString()) will beundefined. Remove-_id; thetoJSONtransform already strips_idfrom JSON output.
Warnings
-
user.ts:163,195— tsoa decorators@Patch('{userId}')and@Delete('{userId}')were not updated to{uid}. Express routes use/:uid, but the decorators still say{userId}, producing inconsistent swagger docs (GET uses{uid}, PATCH/DELETE use{userId}). -
Breaking change:
PUBLIC_GROUP_NAMEchanged from'Public'to'public'andALL_USERS_GROUP.namefrom'AllUsers'to'all-users'. Existing deployments will have orphaned groups.
Suggestions
- CI only runs lint/build — no test step. The bugs above would only surface at runtime with a real MongoDB.
Reviewed by Hermes Agent (GitHub App)
| .populate({ | ||
| path: 'group', | ||
| select: 'groupId name description -_id' | ||
| select: 'groupId name description' |
There was a problem hiding this comment.
Bug: select: 'groupId name description' — groupId was removed from the Group model in this PR. Every other group populate was updated to uid, but this one in updatePermission() was missed. Change to 'uid name description'.
| users: groupInDB.populate({ | ||
| path: 'users', | ||
| select: 'id username displayName isAdmin -_id', | ||
| select: 'uid username displayName isAdmin -_id', |
There was a problem hiding this comment.
Bug: select: 'uid username displayName isAdmin -_id' — the -_id excludes _id from the query, but uid is a virtual that returns this._id.toString(). With _id excluded, uid will be undefined. Remove -_id — the toJSON transform on the User model already strips _id from JSON output.
| isAdmin: false, | ||
| isActive: true | ||
| }) | ||
| @Patch('{userId}') |
There was a problem hiding this comment.
Inconsistency: The tsoa decorator is still @Patch('{userId}') but the express route uses /:uid. This generates swagger docs showing /SASjsApi/user/{userId} for PATCH while GET uses {uid}. Update to @Patch('{uid}') and @Path() uid: string.
| @@ -198,18 +194,16 @@ export class UserController { | |||
| */ | |||
| @Delete('{userId}') | |||
There was a problem hiding this comment.
Inconsistency: Same as above — @Delete('{userId}') should be @Delete('{uid}') with @Path() uid: string.
| import { AuthProviderType } from '../utils' | ||
|
|
||
| export const PUBLIC_GROUP_NAME = 'Public' | ||
| export const PUBLIC_GROUP_NAME = 'public' |
There was a problem hiding this comment.
Breaking change: PUBLIC_GROUP_NAME changed from 'Public' to 'public'. Existing deployments with a group named Public will have it orphaned. Consider a migration script.
| .populate({ | ||
| path: 'group', | ||
| select: 'groupId name description -_id' | ||
| select: 'groupId name description' |
There was a problem hiding this comment.
Critical: Incomplete migration — leftover groupId in populate select
select: 'groupId name description'The Group model no longer has a groupId field (it was removed in this PR and replaced with the uid virtual). This select string references a non-existent field, so the populated group object in the updatePermission response will be missing its uid. Compare with the Permission.get static method in api/src/model/Permission.ts:70 which correctly uses 'uid name description'.
Fix:
select: 'uid name description'Note: the existing test permission.spec.ts:382 only asserts res.body.setting and does not check res.body.group.uid, so this bug is not caught by the test suite.
| schema: | ||
| properties: | ||
| user: {properties: {needsToUpdatePassword: {type: boolean}, isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [needsToUpdatePassword, isAdmin, displayName, username, id], type: object} | ||
| user: {properties: {needsToUpdatePassword: {type: boolean}, isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {}}, required: [needsToUpdatePassword, isAdmin, displayName, username, id], type: object} |
There was a problem hiding this comment.
Warning: Login response schema still uses id with empty type
user: {properties: {..., id: {}}, required: [..., id], ...}The actual login response in api/src/controllers/web.ts:137 returns uid: user.uid, not id. The swagger schema here still uses id with an empty schema object ({} — no type specified), which is invalid OpenAPI. It should be uid: {type: string} and the required array should list uid instead of id.
| @@ -149,36 +149,36 @@ export class PermissionController { | |||
| /** | |||
| * @summary Update permission setting. Admin only | |||
| * @param permissionId The permission's identifier | |||
There was a problem hiding this comment.
Suggestion: JSDoc @param permissionId should be @param uid
* @param permissionId The permission's identifierThe actual parameter is uid (see @Path() uid: string on line 168), but the JSDoc still references permissionId. Same issue on line 176 for deletePermission. These should be @param uid The permission's identifier.
|
|
||
| export const desktopUser: RequestUser = { | ||
| userId: 12345, | ||
| userId: '12345', |
There was a problem hiding this comment.
Critical: Desktop mode regexUser won't match new hex UIDs
The desktopUser now has userId: '12345' (a string), but the bigger issue is in desktop.ts:6:
const regexUser = /^\/SASjsApi\/user\/[0-9]*$/ // /SASjsApi/user/1This regex only matches numeric path segments. After this PR, user identifiers are 24-character hex strings (e.g. /SASjsApi/user/507f1f77bcf86cd799439011). The character class [0-9]* will not match hex strings containing a-f, so desktopRestrict will return 403 "Not Allowed while in Desktop Mode" for GET and PATCH requests to /SASjsApi/user/:uid — breaking the desktop autoExec feature.
Fix:
const regexUser = /^\/SASjsApi\/user\/[0-9a-f]{24}$/i| import { AuthProviderType } from '../utils' | ||
|
|
||
| export const PUBLIC_GROUP_NAME = 'Public' | ||
| export const PUBLIC_GROUP_NAME = 'public' |
There was a problem hiding this comment.
Critical: Group name rename breaks existing deployments — no migration
export const PUBLIC_GROUP_NAME = 'public'PUBLIC_GROUP_NAME was changed from 'Public' to 'public', and ALL_USERS_GROUP.name was changed from 'AllUsers' to 'all-users' (see api/src/utils/seedDB.ts:85). The Group model enforces name: { unique: true }, and seedDB only creates groups if findOne({ name }) returns null.
For existing deployments, the DB already contains groups named 'Public' and 'AllUsers'. After this change:
isPublicRoute(api/src/utils/isPublicRoute.ts:9) doesGroup.findOne({ name: 'public' })-> returns null -> public route auth bypass silently stops working.createUser(api/src/controllers/user.ts:236) doesgetGroupByName(ALL_USERS_GROUP.name)looking for'all-users'-> returns null -> new users are not added to the all-users group.seedDBwill create duplicate groups with the new names (the old ones remain).
There is no migration script in this PR. Either provide a migration to rename existing groups, or keep backward-compatible lookups (e.g. check both names).
| type: number | ||
| example: 1234 | ||
| type: string | ||
| '/SASjsApi/user/{userId}': |
There was a problem hiding this comment.
Warning: Swagger path key inconsistent — {userId} vs {uid}
'/SASjsApi/user/{userId}':The GET endpoint is under '/SASjsApi/user/{uid}' (line 1406) but PATCH and DELETE are under a separate path key '/SASjsApi/user/{userId}' (line 1430). The actual Express routes in api/src/routes/api/user.ts all use '/:uid' with uidValidation. The parameter name should be uid (not userId) and both path keys should be merged into a single '/SASjsApi/user/{uid}' entry.
Also, the example: '1234' and example: 1234 on lines 1457 and 1483 should be valid 24-char hex strings.
| type: string | ||
| isAdmin: | ||
| type: boolean | ||
| id: |
There was a problem hiding this comment.
Warning: SessionResponse schema uses id but code returns uid
id:
type: stringThe SessionResponse schema defines the field as id, but the actual session() function in api/src/controllers/session.ts:47 returns uid: req.user!.userId. The schema should use uid (consistent with UserResponse which uses uid), and the required list on line 589 should reference uid instead of id.
| * @param groupUid The group's identifier | ||
| * @example groupUid "12ByteString" | ||
| * @param userUid The user's identifier | ||
| * @example userId "12ByteString" |
There was a problem hiding this comment.
Suggestion: JSDoc @example userId should be @example userUid
* @example userId "12ByteString"The parameter is named userUid (see @param userUid on line 102), but the @example tag references userId. This is a copy-paste leftover from the ID->UID migration. Should be @example userUid "12ByteString".
| .populate({ | ||
| path: 'group', | ||
| select: 'groupId name description -_id' | ||
| select: 'groupId name description' |
There was a problem hiding this comment.
Bug: groupId is the old field name that no longer exists on the Group schema. This should be uid to match the rest of the migration (e.g. line 338 for user and lines 70/76 in model/Permission.ts). As written, the populate will not select the virtual uid field, so updatedPermission.group.uid will be undefined for group-based permissions.
Fix:
- select: 'groupId name description'
+ select: 'uid name description'There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Request Changes
Critical
-
api/src/controllers/permission.ts:341—select: 'groupId name description'still references the oldgroupIdfield, which no longer exists on theGroupschema. This should beuid name description(matching line 338 foruserand the populate inmodel/Permission.ts). As written,updatedPermission.group.uidwill beundefinedfor group-based permissions after an update, breaking the response shape. The existingupdatetest only uses auserprincipal and only assertsres.body.setting, so this bug is not covered by tests. -
api/src/middlewares/desktop.ts:6—const regexUser = /^\/SASjsApi\/user\/[0-9]*$/was not updated for the UID migration. UIDs are now 24-char hex strings (Joi.string().length(24).hex()), soGET /SASjsApi/user/<uid>andPATCH /SASjsApi/user/<uid>will failreqAllowedInDesktopModeand return403 Not Allowed while in Desktop Mode.in desktop mode. Update to e.g./^\/SASjsApi\/user\/[0-9a-fA-F]{24}$/.
Suggestions
api/src/controllers/user.ts:163— the@Patch('{userId}')/@Delete('{userId}')route templates and@Path() userIdparam names still sayuserIdwhile the@Get('{uid}')path usesuid. Consider renaming for consistency (cosmetic; functionally correct since the value is the UID string).api/src/controllers/user.ts:193— JSDoc@example userId 1234and@example userId "1234"are stale; the example value should be a 24-char hex string to match the new contract.
Looks Good
- The virtual
uidproperty (this._id.toString()) on User/Group/Permission is a clean, zero-migration approach — no stored field, no data migration needed. toJSONtransform consistentlydelete ret._id/delete ret.id, so the oldidwon't leak in responses.- Authz checks (
verifyAdminIfNeeded,authenticateToken,authorize) correctly useuser.userId(now a string) for comparisons, matching the new string UID in the JWT/session. - Web frontend migration to
uidis complete (permission table, add/update/delete, login, session restore). - Test specs updated to use
dbUser.uid/dbGroup.uidand 24-hex validation.
Reviewed by Hermes Agent (GitHub App)
| .populate({ | ||
| path: 'group', | ||
| select: 'groupId name description -_id' | ||
| select: 'groupId name description' |
There was a problem hiding this comment.
🔴 Bug: stale groupId in select. Group.groupId was removed in this PR (replaced by the uid virtual), so select('groupId name description') will not populate the group's uid. The updatePermission response returns a group object with a missing/undefined uid, inconsistent with createPermission (which correctly uses 'uid ...') and with the PermissionDetailsResponse contract that requires uid.
Fix:
.populate({
path: 'group',
select: 'uid name description'
})|
|
||
| const session = (req: express.Request) => ({ | ||
| id: req.user!.userId, | ||
| uid: req.user!.userId, |
There was a problem hiding this comment.
uid here, but api/public/swagger.yaml SessionResponse still documents an id field (string) and lists id in required (not uid). API clients generated from the swagger will look for id and miss the session user identifier.
Fix: in swagger.yaml SessionResponse, rename id → uid (and update required).
| ) | ||
|
|
||
| next() | ||
| userSchema.virtual('uid').get(function () { |
There was a problem hiding this comment.
✅ Security: good choice. Using _id.toString() (a 12-byte Mongo ObjectId) as the UID means it is unguessable (5 bytes of randomness + timestamp + counter) and inherently unique — no separate unique index needed. This avoids the IDOR risk a sequential numeric id carried.
uid is a Mongoose virtual, it will not be present on documents returned by .lean() or raw find() projections that don't include virtuals. All current call sites use Mongoose documents (with toJSON: { virtuals: true }), so this works today, but any future .lean() query would silently return objects without uid. Worth a brief code comment here noting that.
|
|
||
| export const blockFileRegex = /\.(exe|sh|htaccess)$/i | ||
|
|
||
| export const uidValidation = (data: any) => |
There was a problem hiding this comment.
✅ Good: this prevents NoSQL injection. Requiring length(24).hex() means object-form payloads like { \$gt: "" } cannot slip through into Mongo _id queries. The same constraint on principalId (line 121) is correct.
💡 Suggestion: the /:groupUid/:userUid route in routes/api/group.ts destructures groupUid/userUid from req.params and passes them straight to the controller without running uidValidation. If those params are ever used in a Mongo query (they are: Group.findOne({ _id: groupUid })), they should be validated with the same 24-hex check first to keep the injection guard consistent across all UID-bearing routes.
There was a problem hiding this comment.
Hermes Agent Code Review
Request changes — the refactor is sound and well-scoped, but a couple of leftover stale references to the removed id/groupId fields will produce undefined values at runtime and in tests.
Critical
-
api/src/controllers/permission.ts:341—updatePermissionstill calls.populate({ path: 'group', select: 'groupId name description' }).Group.groupIdno longer exists (replaced by theuidvirtual), so the populatedgroup.uidwill be undefined in the response, breaking thePermissionDetailsResponsecontract. Should beselect: 'uid name description'(matchingcreatePermission). (inline comment posted) -
Stale
.idreads in test files —api/src/routes/api/spec/auth.spec.ts:239(currentUser.id) andapi/src/routes/api/spec/user.spec.ts:240,244,255,259(dbUser!.id). Theidfield was removed from theUsermodel (now a virtualuid), so these evaluate toundefined. Consequences:auth.spec.ts:verifyTokenInDB(undefined, …)does not actually verify the current user's tokens were removed.user.spec.ts:generateAndSaveToken(undefined)mints a token foruserId: undefined, andPATCH /SASjsApi/user/undefinedhits the newuidValidation(24-hex) and returns 400, so these tests pass for the wrong reason rather than exercising the 405 path they intend.
Fix: replace every remaining
dbUser.id/currentUser.idwith.uid.
Warnings
- Swagger
SessionResponsemismatch —api/public/swagger.yamlSessionResponsestill documentsid(string) inpropertiesandrequired, but the runtime (session.ts:47) now returnsuid. Generated clients will look foridand miss the identifier. Rename touidin the swagger. (inline comment posted) /:groupUid/:userUidroute lacks validation —routes/api/group.tsvalidates:uidwithuidValidationon GET/DELETE group, but the add/remove-user routes (POST /:groupUid/:userUid,DELETE /:groupUid/:userUid) destructure the params and pass them directly intoGroup.findOne({ _id: groupUid })/User.findOne({ _id: userUid })without the 24-hex check. For consistency and to keep the NoSQL-injection guard complete, validate both params there too. (inline comment posted)PUBLIC_GROUP_NAMEchanged fromPublictopublicandALL_USERS_GROUPfromAllUserstoall-users. This is a behaviour change for existing deployments — groups seeded under the old names will no longer match the lookup increateUser(which callsgetGroupByName(ALL_USERS_GROUP.name)), so new users may not be added to the all-users group. Confirm this is intended and that a migration updates existing group names; otherwise existing data will be orphaned.
Suggestions
- No unit tests for UID generation/format. The UIDs are Mongo ObjectIds (inherently unique), but a small test asserting
randomBytesHexString(12)returns a 24-char hex string and that two calls differ would lock in the contract. CurrentlyrandomBytesHexStringis only exercised incidentally viaauth.spec.ts. uidis a Mongoose virtual (this._id.toString()). It will not appear on results from.lean()or raw projections lackingvirtuals: true. All current call sites use full documents, so this is fine today — a one-line comment on each model noting this would help future contributors. (inline comment posted on User.ts)- Unused import nit:
api/src/model/User.tsimportsObjectIdfrom mongoose but it is not referenced anywhere in the file.
Looks Good
- Security posture improved. Replacing a sequential numeric
idwith an unguessable 12-byte Mongo ObjectId as the public identifier removes a real IDOR/enumeration vector. TheuidValidation(Joi.string().length(24).hex().required()) and the same rule onprincipalIdcorrectly prevent NoSQL injection via object-form payloads. - Auth/permission checks are consistent.
authorize.ts,authenticateToken.ts,verifyTokenInDB.ts,verifyAdminIfNeeded.tsall query by_idand compareuserId(string) with strict===/!==— noundefined === undefined → truebypass, no type-coercion gap. Countermodel andgetSequenceNextValuefully removed — a repo-wide search confirms no remaining imports or callers.- Frontend switched consistently —
appContext.tsx,profile.tsx,permissionTable.tsx,useFilterPermissions.tsx,helper.ts,types.ts, and the permission hooks all readuid/permission.uidwith no staleid/groupId/permissionIdreads remaining on the web side. profile.tsxguard (if (appContext.userId)) is a nice fix — avoids fetching/SASjsApi/user/with an empty uid on first render.
Reviewed by Hermes Agent (GitHub App)
BREAKING CHANGE: remove auto incremental ids from user, group and permissions and add a virtual uid property that returns string value of documents object id
Issue
Closes #361.
While addressing #359, the auto-incrementing sequence counter used for
User,Group, andPermissionIDs was found to be broken on Cosmos DB(it set every ID to
1). More generally, sequencing via a shared counterdocument is not a good fit: it's a bottleneck on every
insert and a source of race conditions under concurrent writes.
Intent
Replace auto-incremental numeric IDs with a string
uidacrossUser,Group, andPermission, and make the API consistently exposeuidinstead of
idwherever these entities appear in a response.Implementation
uidproperty to theUser,Group, andPermissionMongoose models, returning
this._id.toString(). No new field is storedand no data migration is required — every document already has
_id,old and new alike.
Countermodel andgetSequenceNextValueutility are gone, along with the bottleneck/racecondition they caused.
identifier — including login (
POST /SASLogon/login), session(
GET /SASjsApi/session), and theuser/group/permissionendpoints— to consistently return
uid.restoration on page load) to read
uidinstead ofid.This is a breaking change for any existing client relying on numeric
IDs from this API — endpoints now return an opaque string identifier
instead.
Checks
npm run lint:fix).npm test).