Compare commits

..
9 Commits
Author SHA1 Message Date
YuryShkoda e1007e7e75 fix(api): return uid consistently from login and session endpoints
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.
2026-07-15 10:56:28 +03:00
YuryShkoda 45bff7b0d1 fix: update test fixtures for userId: string after ID->UID merge
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.
2026-07-15 09:48:42 +03:00
YuryShkoda 58c66c2ac4 Merge remote-tracking branch 'origin/main' into issue-361 2026-07-14 16:55:46 +03:00
sabhas c43afabe28 chore: remove unused code 2023-08-08 15:07:00 +05:00
sabhas 1531e9cd9c chore: addressed comments 2023-08-08 15:01:32 +05:00
sabhas 8cdf605006 chore: fix specs 2023-05-10 17:02:13 +05:00
sabhas 3f815e9beb chore: fix specs 2023-05-10 14:35:35 +05:00
sabhas 6c88eeabd2 chore: specs fixed 2023-05-09 15:21:54 +05:00
sabhas 093fe90589 feat: replace ID with UID
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
2023-05-09 15:01:56 +05:00
67 changed files with 786 additions and 1044 deletions
-7
View File
@@ -1,10 +1,3 @@
## [0.39.8](https://github.com/sasjs/server/compare/v0.39.7...v0.39.8) (2026-07-15)
### Bug Fixes
* **api:** return 200 with embedded log on SAS session failure instead of 400 ([8506950](https://github.com/sasjs/server/commit/850695024f4b7102982c197ec64b5bb45e7d5f90))
## [0.39.7](https://github.com/sasjs/server/compare/v0.39.6...v0.39.7) (2026-07-14)
+2 -3
View File
@@ -30,9 +30,8 @@ fresh per request inside `processProgram`.
code into the session and drives it to completion/failure, per runtime.
- `api/src/controllers/internal/Execution.ts``ExecutionController`,
the entry point controllers call; acquires a session, calls
`processProgram`, reads back `log.log`/`webout.txt`/headers, and builds
the HTTP response. A failed session (any runtime) is embedded in that
same response, not thrown as a separate error.
`processProgram`, reads back `log.log`/`webout.txt`/headers, builds the
HTTP response (or a `SessionExecutionError` on failure).
- `api/src/controllers/internal/create{SAS,JS,Python,R}Program.ts`
per-runtime code templating (wraps the user's submitted code with
boilerplate: variable injection, `_webout` redirection, etc).
+18 -23
View File
@@ -8,7 +8,7 @@ fresh interpreter process per request).
```mermaid
flowchart TD
A["HTTP request<br/>POST /SASjsApi/code/execute<br/>or /SASjsApi/stp/execute"] --> B["Controller<br/>code.ts / stp.ts<br/>try/catch wrapper"]
B --> C["ExecutionController.executeProgram<br/>or .executeFile<br/>Execution.ts:40-77"]
B --> C["ExecutionController.executeProgram<br/>or .executeFile<br/>Execution.ts:58-91"]
C --> D{"session already provided?<br/>(e.g. file-upload flow)"}
D -- no --> E["getSessionController(runTime).getSession()<br/>Session.ts:59-69"]
D -- yes --> F["use provided session"]
@@ -18,28 +18,30 @@ flowchart TD
H --> J["pre-warm: if pool has fewer<br/>than 3 pending, fire off more<br/>createSession() calls (not awaited)<br/>Session.ts:66"]
I --> J
F --> K
J --> K["session.state = running<br/>Execution.ts:78"]
K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:85-89"]
L --> M["processProgram(...)<br/>Execution.ts:96-107"]
J --> K["session.state = running<br/>Execution.ts:96"]
K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:103-107"]
L --> M["processProgram(...)<br/>processProgram.ts:16-27"]
M --> N{"runTime?"}
N -- SAS --> O["createSASProgram():<br/>wrap user code with _webout<br/>filename, macro vars, autoexec"]
O --> P["write code.sas via .bkp + rename<br/>processProgram.ts:48-49"]
P --> Q["poll: while state !== completed<br/>AND state !== failed<br/>processProgram.ts:58-63"]
Q --> R["processProgram() resolves either way -<br/>state was set by the session's own<br/>process exit handler in Session.ts,<br/>see sas-execution-handshake.md"]
P --> Q["poll: while session.state !== completed<br/>processProgram.ts:52-58"]
Q -- "state becomes completed" --> R["resolve"]
Q -- "state becomes failed" --> S["throw Error(session.failureReason)"]
N -- "JS / PY / R" --> T["createJSProgram / createPythonProgram /<br/>createRProgram: wrap user code similarly"]
T --> U["write code file; spawn interpreter<br/>fresh via execFile; pipe stdout/stderr<br/>into a log.log write stream<br/>processProgram.ts:117-124"]
U -- "exit 0" --> Uc["session.state = completed<br/>processProgram.ts:126"]
U -- "exit non-zero" --> Uf["session.state = failed<br/>session.failureReason = err.toString()<br/>processProgram.ts:131-133"]
Uc --> R
Uf --> R
T --> U["write code file; spawn interpreter<br/>fresh via execFile; pipe stdout/stderr<br/>into a log.log write stream<br/>processProgram.ts:108-134"]
U -- "exit 0" --> R
U -- "exit non-zero" --> S
R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:109-113, 121-125"]
V --> W["guard: if state !== failed,<br/>set state = completed<br/>(don't overwrite a failed session)<br/>Execution.ts:127-137"]
W --> X["build httpHeaders + result;<br/>embed log in result if<br/>isDebugOn(vars) or<br/>session.failureReason is set<br/>Execution.ts:139-164"]
X --> Y["Controller: res.send(result)<br/>HTTP 200<br/>(log embedded if the session failed -<br/>same shape as a successful run)"]
R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:128-131"]
V --> W["build httpHeaders + result<br/>return ExecuteReturnRaw<br/>Execution.ts:128-151"]
W --> X["Controller: res.send(result)<br/>HTTP 200"]
S --> Y["catch in Execution.ts:109-126:<br/>read whatever log exists,<br/>throw SessionExecutionError(message, log)"]
Y --> Z["Controller catch block<br/>rethrow { code: 400, status: 'failure',<br/>message, error, log }"]
Z --> AA["res.status(err.code).send(err)<br/>HTTP 400 with complete log"]
```
## Notes
@@ -53,16 +55,9 @@ flowchart TD
`failed` either way, and both paths flow through the same log/webout
reading logic in `Execution.ts` afterward. The mechanics of *how* that
state gets set differ substantially (see `session-lifecycle.md`).
- **A failed session never throws.** `processProgram()` resolves normally
whether the session completed or failed - a failed session (e.g. SAS
`%abort;`, or a non-zero JS/PY/R exit) is a normal outcome of running
arbitrary user code, not a request-shape/server problem. There is no
separate error path: the same `Execution.ts:139-164` logic that embeds
the log for a debug-mode successful run also embeds it when
`session.failureReason` is set, and the controller always responds 200.
- **`includePrintOutput`** (SAS only) additionally appends `output.lst`
content to the result when debug mode is on - omitted above for brevity;
see `Execution.ts:149-156`.
see `Execution.ts:135-142`.
- **`triggerProgram`/`triggerCode`** (fire-and-forget variants, not shown)
call the same `ExecutionController` methods without awaiting them and
immediately return `{ sessionId }`; the client polls
+10 -10
View File
@@ -33,9 +33,9 @@ sequenceDiagram
Client->>Ctrl: POST .../execute { code, runTime: sas }
Ctrl->>Exec: executeProgram({ program, runTime: SAS, ... })
Exec->>Pool: getSession() returns the pending session (Session.ts:59-69)
Exec->>Exec: session.state = running (Execution.ts:78)
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:85-89)
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:96-107)
Exec->>Exec: session.state = running (Execution.ts:96)
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:103-107)
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:110-121)
Pool->>FS: createSASProgram() wraps user code with macro vars,<br/>_webout filename, autoexec injection (createSASProgram.ts)
Pool->>FS: write code.sas.bkp, then rename to code.sas (processProgram.ts:48-49)
Note over FS: write-then-rename, not a direct write,<br/>so SAS never reads a partial file
@@ -45,24 +45,24 @@ sequenceDiagram
Note over SAS: -SYSIN has pointed at code.sas from the start,<br/>so SAS now executes ITS CONTENT as the main job
SAS->>FS: writes log.log, output.lst, webout.txt while running
Pool->>Pool: processProgram poll loop:<br/>while state !== completed AND state !== failed (processProgram.ts:58-63)
Pool->>Pool: processProgram poll loop:<br/>while session.state !== completed (processProgram.ts:52-58)
alt program reaches EOF normally
SAS->>SAS: exits with code 0
SAS-->>Pool: execFilePromise resolves - .then() (Session.ts:148-152)
Pool->>Pool: session.state = completed
else program aborts (e.g. %abort, fatal error, license failure, hard STOP)
Pool-->>Exec: processProgram() resolves
else program aborts (fatal error, license failure, hard STOP)
SAS->>SAS: exits with non-zero code
SAS-->>Pool: execFilePromise rejects - .catch() (Session.ts:153-163)
Pool->>Pool: session.state = failed<br/>session.failureReason = err.toString()
Pool-->>Exec: processProgram() throws (processProgram.ts:53-55)
end
deactivate SAS
Note over Pool,Exec: either way processProgram() just RESOLVES here -<br/>a failed session is a normal outcome of running user<br/>code, not a request-shape/server problem, so it never throws<br/>(processProgram.ts:58-63, same as the JS/PY/R branch below)
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:109-112, 121-125)
Note over Exec: if session.failureReason is set, the log is folded into<br/>result the same way isDebugOn(vars) already does for a<br/>successful run - no separate error shape (Execution.ts:158-164)
Exec-->>Ctrl: { httpHeaders, result }
Ctrl-->>Client: 200 + result<br/>(log embedded in result if the session failed)
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:123, 128-131)
Exec-->>Ctrl: { httpHeaders, result }<br/>or throws SessionExecutionError{ message, log } (Execution.ts:109-126)
Ctrl-->>Client: 200 + result<br/>or 400 { status, message, error, log }
```
## Why this design
+6 -13
View File
@@ -14,10 +14,10 @@ stateDiagram-v2
initialising --> pending: dummy SYSIN file deleted<br/>by SAS's autoexec<br/>waitForSession(), Session.ts:175-192
initialising --> failed: spawned SAS process exits<br/>before handshake completes<br/>Session.ts:153-163, 184-188
pending --> running: session picked for a request<br/>executeProgram(), Execution.ts:76-78
pending --> running: session picked for a request<br/>executeProgram(), Execution.ts:94-96
running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:124-129 (fresh process)
running --> failed: process exits non-zero<br/>SAS: Session.ts:153-163<br/>JS/PY/R: processProgram.ts:130-135<br/>failureReason = err.toString()<br/>neither branch throws - see request-execution-flow.md
running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:116-120 (fresh process)
running --> failed: process exits non-zero<br/>SAS: Session.ts:153-163<br/>JS/PY/R: processProgram.ts:121-131<br/>failureReason = err.toString()
completed --> [*]: deleteSession()<br/>scheduleSessionDestroy(), Session.ts:194-236
failed --> [*]: deleteSession()<br/>scheduleSessionDestroy()
@@ -55,7 +55,7 @@ stateDiagram-v2
| Reader | Location | Watches for |
|---|---|---|
| `waitForSession` | `Session.ts:175-192` | `failed` (breaks early) or the dummy SYSIN file disappearing (implies session survived init) |
| `processProgram` (SAS branch poll loop) | `processProgram.ts:58-63` | `completed` or `failed` - either just stops the loop and returns normally; `failed` does **not** throw. `Execution.ts` reads `session.failureReason` afterward to fold the log into a normal (200) result, same as it already does for JS/PY/R. |
| `processProgram` (SAS branch poll loop) | `processProgram.ts:52-58` | `completed` (success exit) or `failed` (throws, carrying `session.failureReason`) |
| `scheduleSessionDestroy` | `Session.ts:204-236` | `running` (extends death timer instead of destroying) |
## Asymmetry between runtimes
@@ -65,13 +65,6 @@ stateDiagram-v2
`running`/`completed`/`failed` are all driven by that *same* process's
eventual exit.
- **JS/PY/R**: a session is cheap (folder + id, no process). The interpreter
process is spawned fresh, per request, inside `processProgram.ts:124` and
process is spawned fresh, per request, inside `processProgram.ts:115` and
its exit drives `completed`/`failed` directly in the same function - there
is no separate poll loop for these runtimes (SAS needs one because it's
polling a state change happening in a process spawned earlier, at session
creation; JS/PY/R just `await` the process they spawn right there).
- **Both runtimes resolve `processProgram()` normally on `failed`, they
never throw for it** - a failed session (any runtime) is a normal outcome
of running arbitrary user code, not a request-shape/server problem. See
`request-execution-flow.md` for how `Execution.ts` turns that into a
response.
is no separate poll loop for these runtimes.
+87 -100
View File
@@ -40,8 +40,7 @@ components:
clientId:
type: string
userId:
type: number
format: double
type: string
required:
- clientId
- userId
@@ -315,9 +314,8 @@ components:
additionalProperties: false
UserResponse:
properties:
id:
type: number
format: double
uid:
type: string
username:
type: string
displayName:
@@ -325,7 +323,7 @@ components:
isAdmin:
type: boolean
required:
- id
- uid
- username
- displayName
- isAdmin
@@ -333,32 +331,30 @@ components:
additionalProperties: false
GroupResponse:
properties:
groupId:
type: number
format: double
uid:
type: string
name:
type: string
description:
type: string
required:
- groupId
- uid
- name
- description
type: object
additionalProperties: false
UserDetailsResponse:
properties:
id:
type: number
format: double
displayName:
uid:
type: string
username:
type: string
isActive:
type: boolean
displayName:
type: string
isAdmin:
type: boolean
isActive:
type: boolean
autoExec:
type: string
groups:
@@ -366,11 +362,11 @@ components:
$ref: '#/components/schemas/GroupResponse'
type: array
required:
- id
- displayName
- uid
- username
- isActive
- displayName
- isAdmin
- isActive
type: object
additionalProperties: false
UserPayload:
@@ -406,9 +402,8 @@ components:
additionalProperties: false
GroupDetailsResponse:
properties:
groupId:
type: number
format: double
uid:
type: string
name:
type: string
description:
@@ -420,7 +415,7 @@ components:
$ref: '#/components/schemas/UserResponse'
type: array
required:
- groupId
- uid
- name
- description
- isActive
@@ -489,9 +484,8 @@ components:
additionalProperties: false
PermissionDetailsResponse:
properties:
permissionId:
type: number
format: double
uid:
type: string
path:
type: string
type:
@@ -503,7 +497,7 @@ components:
group:
$ref: '#/components/schemas/GroupDetailsResponse'
required:
- permissionId
- uid
- path
- type
- setting
@@ -542,10 +536,8 @@ components:
description: 'Indicates the type of principal'
example: user
principalId:
type: number
format: double
type: string
description: 'The id of user or group to which a rule is assigned.'
example: 123
required:
- path
- type
@@ -564,25 +556,37 @@ components:
- setting
type: object
additionalProperties: false
SessionResponse:
Pick_UserResponse.Exclude_keyofUserResponse.uid__:
properties:
id:
type: number
format: double
username:
type: string
displayName:
type: string
isAdmin:
type: boolean
needsToUpdatePassword:
type: boolean
required:
- id
- username
- displayName
- isAdmin
- needsToUpdatePassword
type: object
description: 'From T, pick a set of properties whose keys are in the union K'
SessionResponse:
properties:
username:
type: string
displayName:
type: string
isAdmin:
type: boolean
id:
type: string
needsToUpdatePassword:
type: boolean
required:
- username
- displayName
- isAdmin
- id
type: object
additionalProperties: false
SessionState:
@@ -1278,7 +1282,7 @@ paths:
type: array
examples:
'Example 1':
value: [{id: 123, username: johnusername, displayName: John, isAdmin: false}, {id: 456, username: starkusername, displayName: Stark, isAdmin: true}]
value: [{uid: userIdString, username: johnusername, displayName: John, isAdmin: false}, {uid: anotherUserIdString, username: starkusername, displayName: Stark, isAdmin: true}]
summary: 'Get list of all users (username, displayname). All users can request this.'
tags:
- User
@@ -1297,7 +1301,7 @@ paths:
$ref: '#/components/schemas/UserDetailsResponse'
examples:
'Example 1':
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
summary: 'Create user with the following attributes: UserId, UserName, Password, isAdmin, isActive. Admin only task.'
tags:
- User
@@ -1348,7 +1352,7 @@ paths:
$ref: '#/components/schemas/UserDetailsResponse'
examples:
'Example 1':
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.'
tags:
- User
@@ -1399,7 +1403,7 @@ paths:
password:
type: string
type: object
'/SASjsApi/user/{userId}':
'/SASjsApi/user/{uid}':
get:
operationId: GetUser
responses:
@@ -1418,14 +1422,12 @@ paths:
bearerAuth: []
parameters:
-
description: 'The user''s identifier'
in: path
name: userId
name: uid
required: true
schema:
format: double
type: number
example: 1234
type: string
'/SASjsApi/user/{userId}':
patch:
operationId: UpdateUser
responses:
@@ -1437,7 +1439,7 @@ paths:
$ref: '#/components/schemas/UserDetailsResponse'
examples:
'Example 1':
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.'
tags:
- User
@@ -1451,8 +1453,7 @@ paths:
name: userId
required: true
schema:
format: double
type: number
type: string
example: '1234'
requestBody:
required: true
@@ -1478,8 +1479,7 @@ paths:
name: userId
required: true
schema:
format: double
type: number
type: string
example: 1234
requestBody:
required: true
@@ -1504,7 +1504,7 @@ paths:
type: array
examples:
'Example 1':
value: [{groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users'}]
value: [{uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users'}]
summary: 'Get list of all groups (groupName and groupDescription). All users can request this.'
tags:
- Group
@@ -1523,7 +1523,7 @@ paths:
$ref: '#/components/schemas/GroupDetailsResponse'
examples:
'Example 1':
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
summary: 'Create a new group. Admin only.'
tags:
- Group
@@ -1539,7 +1539,7 @@ paths:
$ref: '#/components/schemas/GroupPayload'
'/SASjsApi/group/by/groupname/{name}':
get:
operationId: GetGroupByGroupName
operationId: GetGroupByName
responses:
'200':
description: Ok
@@ -1561,7 +1561,7 @@ paths:
required: true
schema:
type: string
'/SASjsApi/group/{groupId}':
'/SASjsApi/group/{uid}':
get:
operationId: GetGroup
responses:
@@ -1581,12 +1581,11 @@ paths:
-
description: 'The group''s identifier'
in: path
name: groupId
name: uid
required: true
schema:
format: double
type: number
example: 1234
type: string
example: 12ByteString
delete:
operationId: DeleteGroup
responses:
@@ -1608,13 +1607,12 @@ paths:
-
description: 'The group''s identifier'
in: path
name: groupId
name: uid
required: true
schema:
format: double
type: number
example: 1234
'/SASjsApi/group/{groupId}/{userId}':
type: string
example: 12ByteString
'/SASjsApi/group/{groupUid}/{userUid}':
post:
operationId: AddUserToGroup
responses:
@@ -1626,7 +1624,7 @@ paths:
$ref: '#/components/schemas/GroupDetailsResponse'
examples:
'Example 1':
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
summary: 'Add a user to a group. Admin task only.'
tags:
- Group
@@ -1637,21 +1635,18 @@ paths:
-
description: 'The group''s identifier'
in: path
name: groupId
name: groupUid
required: true
schema:
format: double
type: number
example: '1234'
type: string
example: 12ByteString
-
description: 'The user''s identifier'
in: path
name: userId
name: userUid
required: true
schema:
format: double
type: number
example: '6789'
type: string
delete:
operationId: RemoveUserFromGroup
responses:
@@ -1663,8 +1658,8 @@ paths:
$ref: '#/components/schemas/GroupDetailsResponse'
examples:
'Example 1':
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
summary: 'Remove a user to a group. Admin task only.'
value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
summary: 'Remove a user from a group. Admin task only.'
tags:
- Group
security:
@@ -1674,21 +1669,19 @@ paths:
-
description: 'The group''s identifier'
in: path
name: groupId
name: groupUid
required: true
schema:
format: double
type: number
example: '1234'
type: string
example: 12ByteString
-
description: 'The user''s identifier'
in: path
name: userId
name: userUid
required: true
schema:
format: double
type: number
example: '6789'
type: string
example: 12ByteString
/SASjsApi/info:
get:
operationId: Info
@@ -1739,7 +1732,7 @@ paths:
type: array
examples:
'Example 1':
value: [{permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}, {permissionId: 124, path: /SASjsApi/code/execute, type: Route, setting: Grant, group: {groupId: 1, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}}]
value: [{uid: permissionId1String, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: user1-id, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}, {uid: permissionId2String, path: /SASjsApi/code/execute, type: Route, setting: Grant, group: {uid: group1-id, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}}]
description: "Get the list of permission rules applicable the authenticated user.\nIf the user is an admin, all rules are returned."
summary: 'Get the list of permission rules. If the user is admin, all rules are returned.'
tags:
@@ -1759,7 +1752,7 @@ paths:
$ref: '#/components/schemas/PermissionDetailsResponse'
examples:
'Example 1':
value: {permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
value: {uid: permissionIdString, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: userIdString, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
summary: 'Create a new permission. Admin only.'
tags:
- Permission
@@ -1773,7 +1766,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/RegisterPermissionPayload'
'/SASjsApi/permission/{permissionId}':
'/SASjsApi/permission/{uid}':
patch:
operationId: UpdatePermission
responses:
@@ -1785,7 +1778,7 @@ paths:
$ref: '#/components/schemas/PermissionDetailsResponse'
examples:
'Example 1':
value: {permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
value: {uid: permissionIdString, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: userIdString, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
summary: 'Update permission setting. Admin only'
tags:
- Permission
@@ -1794,14 +1787,11 @@ paths:
bearerAuth: []
parameters:
-
description: 'The permission''s identifier'
in: path
name: permissionId
name: uid
required: true
schema:
format: double
type: number
example: 1234
type: string
requestBody:
required: true
content:
@@ -1821,14 +1811,11 @@ paths:
bearerAuth: []
parameters:
-
description: 'The user''s identifier'
in: path
name: permissionId
name: uid
required: true
schema:
format: double
type: number
example: 1234
type: string
/SASjsApi/session:
get:
operationId: Session
@@ -1841,7 +1828,7 @@ paths:
$ref: '#/components/schemas/SessionResponse'
examples:
'Example 1':
value: {id: 123, username: johnusername, displayName: John, isAdmin: false}
value: {id: userIdString, username: johnusername, displayName: John, isAdmin: false, needsToUpdatePassword: false}
summary: 'Get session info (username).'
tags:
- Session
@@ -2012,7 +1999,7 @@ paths:
application/json:
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}
loggedIn: {type: boolean}
required:
- user
+4 -14
View File
@@ -27,14 +27,14 @@ import User from '../model/User'
@Tags('Auth')
export class AuthController {
static authCodes: { [key: string]: { [key: string]: string } } = {}
static saveCode = (userId: number, clientId: string, code: string) => {
static saveCode = (userId: string, clientId: string, code: string) => {
if (AuthController.authCodes[userId])
return (AuthController.authCodes[userId][clientId] = code)
AuthController.authCodes[userId] = { [clientId]: code }
return AuthController.authCodes[userId][clientId]
}
static deleteCode = (userId: number, clientId: string) =>
static deleteCode = (userId: string, clientId: string) =>
delete AuthController.authCodes[userId][clientId]
/**
@@ -100,13 +100,8 @@ const token = async (data: any): Promise<TokenResponse> => {
AuthController.deleteCode(userInfo.userId, clientId)
// Re-exchanging a code for the same user/client while a still-valid token
// pair already exists returns that pair instead of minting a new one -
// keeps other tabs/sessions using the old tokens alive instead of
// silently invalidating them (saveTokensInDB below overwrites, it doesn't
// append).
// get tokens from DB
const existingTokens = await getTokensFromDB(userInfo.userId, clientId)
if (existingTokens) {
return {
accessToken: existingTokens.accessToken,
@@ -114,12 +109,7 @@ const token = async (data: any): Promise<TokenResponse> => {
}
}
// Only used to look up token expirations - clientSecret is intentionally
// not checked here. The credential for this exchange is the auth code
// itself (single-use, 30s-lived, only obtainable by a caller that already
// held a valid session - see verifyAuthCode below and web.ts's authorize()).
const client = await Client.findOne({ clientId })
if (!client) throw new Error('Invalid clientId.')
const accessToken = generateAccessToken(
@@ -169,7 +159,7 @@ const updatePassword = async (
) => {
const { currentPassword, newPassword } = data
const userId = req.user?.userId
const dbUser = await User.findOne({ id: userId })
const dbUser = await User.findOne({ _id: userId })
if (!dbUser)
throw {
+2 -1
View File
@@ -114,7 +114,8 @@ const executeCode = async (
code: 400,
status: 'failure',
message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err
error: typeof err === 'object' ? err.toString() : err,
log: err?.log
}
}
}
+60 -62
View File
@@ -12,28 +12,29 @@ import {
import Group, { GroupPayload, PUBLIC_GROUP_NAME } from '../model/Group'
import User from '../model/User'
import { AuthProviderType } from '../utils'
import { UserResponse } from './user'
import { GetUserBy, UserResponse } from './user'
export interface GroupResponse {
groupId: number
uid: string
name: string
description: string
}
export interface GroupDetailsResponse {
groupId: number
name: string
description: string
export interface GroupDetailsResponse extends GroupResponse {
isActive: boolean
users: UserResponse[]
}
interface GetGroupBy {
groupId?: number
_id?: string
name?: string
}
enum GroupAction {
AddUser = 'addUser',
RemoveUser = 'removeUser'
}
@Security('bearerAuth')
@Route('SASjsApi/group')
@Tags('Group')
@@ -44,7 +45,7 @@ export class GroupController {
*/
@Example<GroupResponse[]>([
{
groupId: 123,
uid: 'groupIdString',
name: 'DCGroup',
description: 'This group represents Data Controller Users'
}
@@ -59,7 +60,7 @@ export class GroupController {
*
*/
@Example<GroupDetailsResponse>({
groupId: 123,
uid: 'groupIdString',
name: 'DCGroup',
description: 'This group represents Data Controller Users',
isActive: true,
@@ -78,7 +79,7 @@ export class GroupController {
* @example dcgroup
*/
@Get('by/groupname/{name}')
public async getGroupByGroupName(
public async getGroupByName(
@Path() name: string
): Promise<GroupDetailsResponse> {
return getGroup({ name })
@@ -86,68 +87,66 @@ export class GroupController {
/**
* @summary Get list of members of a group (userName). All users can request this.
* @param groupId The group's identifier
* @example groupId 1234
* @param uid The group's identifier
* @example uid "12ByteString"
*/
@Get('{groupId}')
public async getGroup(
@Path() groupId: number
): Promise<GroupDetailsResponse> {
return getGroup({ groupId })
@Get('{uid}')
public async getGroup(@Path() uid: string): Promise<GroupDetailsResponse> {
return getGroup({ _id: uid })
}
/**
* @summary Add a user to a group. Admin task only.
* @param groupId The group's identifier
* @example groupId "1234"
* @param userId The user's identifier
* @example userId "6789"
* @param groupUid The group's identifier
* @example groupUid "12ByteString"
* @param userUid The user's identifier
* @example userId "12ByteString"
*/
@Example<GroupDetailsResponse>({
groupId: 123,
uid: 'groupIdString',
name: 'DCGroup',
description: 'This group represents Data Controller Users',
isActive: true,
users: []
})
@Post('{groupId}/{userId}')
@Post('{groupUid}/{userUid}')
public async addUserToGroup(
@Path() groupId: number,
@Path() userId: number
@Path() groupUid: string,
@Path() userUid: string
): Promise<GroupDetailsResponse> {
return addUserToGroup(groupId, userId)
return addUserToGroup(groupUid, userUid)
}
/**
* @summary Remove a user to a group. Admin task only.
* @param groupId The group's identifier
* @example groupId "1234"
* @param userId The user's identifier
* @example userId "6789"
* @summary Remove a user from a group. Admin task only.
* @param groupUid The group's identifier
* @example groupUid "12ByteString"
* @param userUid The user's identifier
* @example userUid "12ByteString"
*/
@Example<GroupDetailsResponse>({
groupId: 123,
uid: 'groupIdString',
name: 'DCGroup',
description: 'This group represents Data Controller Users',
isActive: true,
users: []
})
@Delete('{groupId}/{userId}')
@Delete('{groupUid}/{userUid}')
public async removeUserFromGroup(
@Path() groupId: number,
@Path() userId: number
@Path() groupUid: string,
@Path() userUid: string
): Promise<GroupDetailsResponse> {
return removeUserFromGroup(groupId, userId)
return removeUserFromGroup(groupUid, userUid)
}
/**
* @summary Delete a group. Admin task only.
* @param groupId The group's identifier
* @example groupId 1234
* @param uid The group's identifier
* @example uid "12ByteString"
*/
@Delete('{groupId}')
public async deleteGroup(@Path() groupId: number) {
const group = await Group.findOne({ groupId })
@Delete('{uid}')
public async deleteGroup(@Path() uid: string) {
const group = await Group.findOne({ _id: uid })
if (!group)
throw {
code: 404,
@@ -160,9 +159,7 @@ export class GroupController {
}
const getAllGroups = async (): Promise<GroupResponse[]> =>
await Group.find({})
.select({ _id: 0, groupId: 1, name: 1, description: 1 })
.exec()
await Group.find({}).select('uid name description').exec()
const createGroup = async ({
name,
@@ -187,7 +184,7 @@ const createGroup = async ({
const savedGroup = await group.save()
return {
groupId: savedGroup.groupId,
uid: savedGroup.uid,
name: savedGroup.name,
description: savedGroup.description,
isActive: savedGroup.isActive,
@@ -198,11 +195,12 @@ const createGroup = async ({
const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
const group = (await Group.findOne(
findBy,
'groupId name description isActive users -_id'
'uid name description isActive users'
).populate(
'users',
'id username displayName isAdmin -_id'
'uid username displayName isAdmin'
)) as unknown as GroupDetailsResponse
if (!group)
throw {
code: 404,
@@ -211,7 +209,7 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
}
return {
groupId: group.groupId,
uid: group.uid,
name: group.name,
description: group.description,
isActive: group.isActive,
@@ -220,23 +218,23 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
}
const addUserToGroup = async (
groupId: number,
userId: number
groupUid: string,
userUid: string
): Promise<GroupDetailsResponse> =>
updateUsersListInGroup(groupId, userId, 'addUser')
updateUsersListInGroup(groupUid, userUid, GroupAction.AddUser)
const removeUserFromGroup = async (
groupId: number,
userId: number
groupUid: string,
userUid: string
): Promise<GroupDetailsResponse> =>
updateUsersListInGroup(groupId, userId, 'removeUser')
updateUsersListInGroup(groupUid, userUid, GroupAction.RemoveUser)
const updateUsersListInGroup = async (
groupId: number,
userId: number,
action: 'addUser' | 'removeUser'
groupUid: string,
userUid: string,
action: GroupAction
): Promise<GroupDetailsResponse> => {
const group = await Group.findOne({ groupId })
const group = await Group.findOne({ _id: groupUid })
if (!group)
throw {
code: 404,
@@ -258,7 +256,7 @@ const updateUsersListInGroup = async (
message: `Can't add/remove user to group created by external auth provider.`
}
const user = await User.findOne({ id: userId })
const user = await User.findOne({ _id: userUid })
if (!user)
throw {
code: 404,
@@ -274,7 +272,7 @@ const updateUsersListInGroup = async (
}
const updatedGroup =
action === 'addUser'
action === GroupAction.AddUser
? await group.addUser(user)
: await group.removeUser(user)
@@ -286,7 +284,7 @@ const updateUsersListInGroup = async (
}
return {
groupId: updatedGroup.groupId,
uid: updatedGroup.uid,
name: updatedGroup.name,
description: updatedGroup.description,
isActive: updatedGroup.isActive,
+36 -17
View File
@@ -15,6 +15,24 @@ export interface ExecutionVars {
[key: string]: string | number | undefined
}
// Thrown when the session itself fails (e.g. SAS exits abnormally via
// %abort;). Carries the complete log - by the time this is thrown, the
// session's process has already exited, so the log file it wrote is final,
// not a partial/truncated snapshot.
export class SessionExecutionError extends Error {
constructor(
message: string,
public log?: string
) {
super(message)
// required for `instanceof` to work when compiling to ES5, since the
// default __extends helper does not preserve the prototype chain for
// classes extending built-ins like Error
Object.setPrototypeOf(this, SessionExecutionError.prototype)
}
}
export interface ExecuteReturnRaw {
httpHeaders: HTTPHeaders
result: string | Buffer
@@ -88,23 +106,24 @@ export class ExecutionController {
preProgramVariables?.httpHeaders.join('\n') ?? ''
)
// A failed session (e.g. SAS via %abort;, or a non-zero JS/PY/R exit)
// is a normal outcome of running arbitrary user code, not a
// request-shape/server problem - processProgram resolves rather than
// throwing in that case, and the session.failureReason check below
// folds the log into the same result shape a successful run returns.
await processProgram(
program,
preProgramVariables,
vars,
session,
weboutPath,
headersPath,
tokenFile,
runTime,
logPath,
otherArgs
)
try {
await processProgram(
program,
preProgramVariables,
vars,
session,
weboutPath,
headersPath,
tokenFile,
runTime,
logPath,
otherArgs
)
} catch (err: any) {
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
throw new SessionExecutionError(err.message, log)
}
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
const headersContent = (await fileExists(headersPath))
+6 -11
View File
@@ -48,17 +48,12 @@ export const processProgram = async (
await createFile(codePath + '.bkp', program)
await moveFile(codePath + '.bkp', codePath)
// we now need to poll the session status. A failed session (e.g. from
// %abort;) is not a request-shape/server problem - it's a normal
// outcome of running arbitrary user code, same as a SAS ERROR: in the
// log without %abort;. So we just stop polling rather than throwing;
// Execution.ts already knows how to turn session.failureReason into a
// 200 response with the log embedded, matching how JS/PY/R (the else
// branch below) has always handled a failed session.
while (
session.state !== SessionState.completed &&
session.state !== SessionState.failed
) {
// we now need to poll the session status
while (session.state !== SessionState.completed) {
if (session.state === SessionState.failed) {
throw new Error(session.failureReason || 'SAS session failed')
}
await delay(50)
}
} else {
@@ -2,13 +2,13 @@ import path from 'path'
import os from 'os'
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
import * as ProcessProgramModule from '../processProgram'
import { ExecutionController } from '../Execution'
import { ExecutionController, SessionExecutionError } from '../Execution'
import { Session, SessionState, PreProgramVars } from '../../../types'
import { RunTimeType } from '../../../utils'
const preProgramVariables: PreProgramVars = {
username: 'testUser',
userId: 1,
userId: '1',
displayName: 'Test User',
serverUrl: 'http://localhost:5000',
httpHeaders: []
@@ -83,15 +83,8 @@ describe('ExecutionController.executeProgram', () => {
})
})
// A failed SAS session (e.g. from %abort;) is a normal outcome of
// running arbitrary user code, not a request-shape/server problem - the
// same way a plain SAS ERROR: in the log (without %abort;) already
// returns 200 with the log embedded. This mirrors the JS/PY/R failure
// path above: resolve normally, don't throw, and let the existing
// session.failureReason check below produce the same result shape as a
// successful run.
describe('SAS failure path', () => {
it('returns the complete log embedded in a normal result instead of throwing, when the session fails', async () => {
it('throws a SessionExecutionError carrying the complete log when the session fails', async () => {
const logPath = path.join(session.path, 'log.log')
const logContent =
'NOTE: SAS session\nERROR: SAS session terminated. See log for details.\n'
@@ -101,17 +94,12 @@ describe('ExecutionController.executeProgram', () => {
jest
.spyOn(ProcessProgramModule, 'processProgram')
.mockImplementation(async () => {
// mirrors the real SAS branch after the fix: sets
// state/failureReason and resolves, exactly like the JS/PY/R
// branch already does - it does not throw
session.state = SessionState.failed
session.failureReason =
'ERROR: SAS session terminated. See log for details.'
throw new Error('ERROR: SAS session terminated. See log for details.')
})
const controller = new ExecutionController()
const { result } = await controller.executeProgram({
const resultPromise = controller.executeProgram({
program: '%abort;',
preProgramVariables,
vars: {},
@@ -119,8 +107,11 @@ describe('ExecutionController.executeProgram', () => {
runTime: RunTimeType.SAS
})
expect(session.state).toBe(SessionState.failed)
expect(result).toEqual(expect.stringContaining(logContent))
await expect(resultPromise).rejects.toBeInstanceOf(SessionExecutionError)
await expect(resultPromise).rejects.toMatchObject({
log: logContent,
message: expect.stringContaining('SAS session terminated')
})
})
})
})
@@ -11,7 +11,7 @@ import {
const preProgramVariables: PreProgramVars = {
username: 'testUser',
userId: 1,
userId: '1',
displayName: 'Test User',
serverUrl: 'http://localhost:5000',
httpHeaders: []
@@ -65,13 +65,7 @@ describe('processProgram (SAS runtime)', () => {
await deleteFolder(session.path)
})
it('resolves instead of hanging when the session fails (e.g. %abort;)', async () => {
// mirrors the JS/PY/R branch below: a session failure is a normal
// outcome of running arbitrary user code (like a SAS ERROR: in the
// log without %abort;), not a server-side/request-shape problem - so
// processProgram must not throw here, just stop polling. Execution.ts
// is responsible for turning session.failureReason into a 200 response
// with the log embedded, the same way it already does for JS/PY/R.
it('rejects instead of hanging when the session fails (e.g. %abort;)', async () => {
setTimeout(() => {
session.state = SessionState.failed
session.failureReason =
@@ -90,7 +84,7 @@ describe('processProgram (SAS runtime)', () => {
RunTimeType.SAS,
logPath
)
).resolves.toBeUndefined()
).rejects.toThrow(/SAS session terminated/)
}, 3000)
it('resolves without throwing when the session completes normally', async () => {
+34 -40
View File
@@ -56,9 +56,9 @@ interface RegisterPermissionPayload {
principalType: PrincipalType
/**
* The id of user or group to which a rule is assigned.
* @example 123
* @example 'groupIdString'
*/
principalId: number
principalId: string
}
interface UpdatePermissionPayload {
@@ -70,7 +70,7 @@ interface UpdatePermissionPayload {
}
export interface PermissionDetailsResponse {
permissionId: number
uid: string
path: string
type: string
setting: string
@@ -91,24 +91,24 @@ export class PermissionController {
*/
@Example<PermissionDetailsResponse[]>([
{
permissionId: 123,
uid: 'permissionId1String',
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
user: {
id: 1,
uid: 'user1-id',
username: 'johnSnow01',
displayName: 'John Snow',
isAdmin: false
}
},
{
permissionId: 124,
uid: 'permissionId2String',
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
group: {
groupId: 1,
uid: 'group1-id',
name: 'DCGroup',
description: 'This group represents Data Controller Users',
isActive: true,
@@ -128,12 +128,12 @@ export class PermissionController {
*
*/
@Example<PermissionDetailsResponse>({
permissionId: 123,
uid: 'permissionIdString',
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
user: {
id: 1,
uid: 'userIdString',
username: 'johnSnow01',
displayName: 'John Snow',
isAdmin: false
@@ -149,36 +149,36 @@ export class PermissionController {
/**
* @summary Update permission setting. Admin only
* @param permissionId The permission's identifier
* @example permissionId 1234
* @example permissionId "permissionIdString"
*/
@Example<PermissionDetailsResponse>({
permissionId: 123,
uid: 'permissionIdString',
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
user: {
id: 1,
uid: 'userIdString',
username: 'johnSnow01',
displayName: 'John Snow',
isAdmin: false
}
})
@Patch('{permissionId}')
@Patch('{uid}')
public async updatePermission(
@Path() permissionId: number,
@Path() uid: string,
@Body() body: UpdatePermissionPayload
): Promise<PermissionDetailsResponse> {
return updatePermission(permissionId, body)
return updatePermission(uid, body)
}
/**
* @summary Delete a permission. Admin only.
* @param permissionId The user's identifier
* @example permissionId 1234
* @example permissionId "permissionIdString"
*/
@Delete('{permissionId}')
public async deletePermission(@Path() permissionId: number) {
return deletePermission(permissionId)
@Delete('{uid}')
public async deletePermission(@Path() uid: string) {
return deletePermission(uid)
}
}
@@ -191,7 +191,7 @@ const getAllPermissions = async (
else {
const permissions: PermissionDetailsResponse[] = []
const dbUser = await User.findOne({ id: user?.userId })
const dbUser = await User.findOne({ _id: user?.userId })
if (!dbUser)
throw {
code: 404,
@@ -227,7 +227,7 @@ const createPermission = async ({
switch (principalType) {
case PrincipalType.user: {
const userInDB = await User.findOne({ id: principalId })
const userInDB = await User.findOne({ _id: principalId })
if (!userInDB)
throw {
code: 404,
@@ -259,7 +259,7 @@ const createPermission = async ({
permission.user = userInDB._id
user = {
id: userInDB.id,
uid: userInDB.uid,
username: userInDB.username,
displayName: userInDB.displayName,
isAdmin: userInDB.isAdmin
@@ -267,7 +267,7 @@ const createPermission = async ({
break
}
case PrincipalType.group: {
const groupInDB = await Group.findOne({ groupId: principalId })
const groupInDB = await Group.findOne({ _id: principalId })
if (!groupInDB)
throw {
code: 404,
@@ -291,13 +291,13 @@ const createPermission = async ({
permission.group = groupInDB._id
group = {
groupId: groupInDB.groupId,
uid: groupInDB.uid,
name: groupInDB.name,
description: groupInDB.description,
isActive: groupInDB.isActive,
users: groupInDB.populate({
path: 'users',
select: 'id username displayName isAdmin -_id',
select: 'uid username displayName isAdmin -_id',
options: { limit: 15 }
}) as unknown as UserResponse[]
}
@@ -314,7 +314,7 @@ const createPermission = async ({
const savedPermission = await permission.save()
return {
permissionId: savedPermission.permissionId,
uid: savedPermission.uid,
path: savedPermission.path,
type: savedPermission.type,
setting: savedPermission.setting,
@@ -324,27 +324,21 @@ const createPermission = async ({
}
const updatePermission = async (
id: number,
uid: string,
data: UpdatePermissionPayload
): Promise<PermissionDetailsResponse> => {
const { setting } = data
const updatedPermission = (await Permission.findOneAndUpdate(
{ permissionId: id },
{ _id: uid },
{ setting },
{ new: true }
)
.select({
_id: 0,
permissionId: 1,
path: 1,
type: 1,
setting: 1
})
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
.select('uid path type setting')
.populate({ path: 'user', select: 'uid username displayName isAdmin' })
.populate({
path: 'group',
select: 'groupId name description -_id'
select: 'groupId name description'
})) as unknown as PermissionDetailsResponse
if (!updatedPermission)
throw {
@@ -356,13 +350,13 @@ const updatePermission = async (
return updatedPermission
}
const deletePermission = async (id: number) => {
const permission = await Permission.findOne({ permissionId: id })
const deletePermission = async (uid: string) => {
const permission = await Permission.findOne({ _id: uid })
if (!permission)
throw {
code: 404,
status: 'Not Found',
message: 'Permission not found.'
}
await Permission.deleteOne({ permissionId: id })
await Permission.deleteOne({ _id: uid })
}
+6 -5
View File
@@ -5,7 +5,7 @@ import { getSessionController } from './internal'
import { SessionState } from '../types'
interface SessionResponse extends UserResponse {
needsToUpdatePassword: boolean
needsToUpdatePassword?: boolean
}
@Security('bearerAuth')
@@ -16,11 +16,12 @@ export class SessionController {
* @summary Get session info (username).
*
*/
@Example<UserResponse>({
id: 123,
@Example<SessionResponse>({
uid: 'userIdString',
username: 'johnusername',
displayName: 'John',
isAdmin: false
isAdmin: false,
needsToUpdatePassword: false
})
@Get('/')
public async session(
@@ -43,7 +44,7 @@ export class SessionController {
}
const session = (req: express.Request) => ({
id: req.user!.userId,
uid: req.user!.userId,
username: req.user!.username,
displayName: req.user!.displayName,
isAdmin: req.user!.isAdmin,
+2 -1
View File
@@ -166,7 +166,8 @@ const execute = async (
code: 400,
status: 'failure',
message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err
error: typeof err === 'object' ? err.toString() : err,
log: err?.log
}
}
}
+31 -37
View File
@@ -26,18 +26,14 @@ import {
import { GroupController, GroupResponse } from './group'
export interface UserResponse {
id: number
uid: string
username: string
displayName: string
isAdmin: boolean
}
export interface UserDetailsResponse {
id: number
displayName: string
username: string
export interface UserDetailsResponse extends UserResponse {
isActive: boolean
isAdmin: boolean
autoExec?: string
groups?: GroupResponse[]
}
@@ -52,13 +48,13 @@ export class UserController {
*/
@Example<UserResponse[]>([
{
id: 123,
uid: 'userIdString',
username: 'johnusername',
displayName: 'John',
isAdmin: false
},
{
id: 456,
uid: 'anotherUserIdString',
username: 'starkusername',
displayName: 'Stark',
isAdmin: true
@@ -74,7 +70,7 @@ export class UserController {
*
*/
@Example<UserDetailsResponse>({
id: 1234,
uid: 'userIdString',
displayName: 'John Snow',
username: 'johnSnow01',
isAdmin: false,
@@ -111,20 +107,20 @@ export class UserController {
* Only Admin or user itself will get user autoExec code.
* @summary Get user properties - such as group memberships, userName, displayName.
* @param userId The user's identifier
* @example userId 1234
* @example userId "userIdString"
*/
@Get('{userId}')
@Get('{uid}')
public async getUser(
@Request() req: express.Request,
@Path() userId: number
@Path() uid: string
): Promise<UserDetailsResponse> {
const { MODE } = process.env
if (MODE === ModeType.Desktop) return getDesktopAutoExec()
const { user } = req
const getAutoExec = user!.isAdmin || user!.userId == userId
return getUser({ id: userId }, getAutoExec)
const getAutoExec = user!.isAdmin || user!.userId === uid
return getUser({ _id: uid }, getAutoExec)
}
/**
@@ -133,7 +129,7 @@ export class UserController {
* @example username "johnSnow01"
*/
@Example<UserDetailsResponse>({
id: 1234,
uid: 'userIdString',
displayName: 'John Snow',
username: 'johnSnow01',
isAdmin: false,
@@ -158,7 +154,7 @@ export class UserController {
* @example userId "1234"
*/
@Example<UserDetailsResponse>({
id: 1234,
uid: 'userIdString',
displayName: 'John Snow',
username: 'johnSnow01',
isAdmin: false,
@@ -166,7 +162,7 @@ export class UserController {
})
@Patch('{userId}')
public async updateUser(
@Path() userId: number,
@Path() userId: string,
@Body() body: UserPayload
): Promise<UserDetailsResponse> {
const { MODE } = process.env
@@ -174,7 +170,7 @@ export class UserController {
if (MODE === ModeType.Desktop)
return updateDesktopAutoExec(body.autoExec ?? '')
return updateUser({ id: userId }, body)
return updateUser({ _id: userId }, body)
}
/**
@@ -198,18 +194,16 @@ export class UserController {
*/
@Delete('{userId}')
public async deleteUser(
@Path() userId: number,
@Path() userId: string,
@Body() body: { password?: string },
@Query() @Hidden() isAdmin: boolean = false
) {
return deleteUser({ id: userId }, isAdmin, body)
return deleteUser({ _id: userId }, isAdmin, body)
}
}
const getAllUsers = async (): Promise<UserResponse[]> =>
await User.find({})
.select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 })
.exec()
await User.find({}).select('uid username displayName isAdmin').exec()
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
const { displayName, username, password, isAdmin, isActive, autoExec } = data
@@ -239,15 +233,15 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
const groupController = new GroupController()
const allUsersGroup = await groupController
.getGroupByGroupName(ALL_USERS_GROUP.name)
.getGroupByName(ALL_USERS_GROUP.name)
.catch(() => {})
if (allUsersGroup) {
await groupController.addUserToGroup(allUsersGroup.groupId, savedUser.id)
await groupController.addUserToGroup(allUsersGroup.uid, savedUser.uid)
}
return {
id: savedUser.id,
uid: savedUser.uid,
displayName: savedUser.displayName,
username: savedUser.username,
isActive: savedUser.isActive,
@@ -256,8 +250,8 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
}
}
interface GetUserBy {
id?: number
export interface GetUserBy {
_id?: string
username?: string
}
@@ -267,10 +261,10 @@ const getUser = async (
): Promise<UserDetailsResponse> => {
const user = (await User.findOne(
findBy,
`id displayName username isActive isAdmin autoExec -_id`
`uid displayName username isActive isAdmin autoExec`
).populate(
'groups',
'groupId name description -_id'
'uid name description'
)) as unknown as UserDetailsResponse
if (!user)
@@ -280,7 +274,7 @@ const getUser = async (
}
return {
id: user.id,
uid: user.uid,
displayName: user.displayName,
username: user.username,
isActive: user.isActive,
@@ -293,7 +287,7 @@ const getUser = async (
const getDesktopAutoExec = async () => {
return {
...desktopUser,
id: desktopUser.userId,
uid: desktopUser.userId,
autoExec: await getUserAutoExec()
}
}
@@ -329,8 +323,8 @@ const updateUser = async (
const usernameExist = await User.findOne({ username })
if (usernameExist) {
if (
(findBy.id && usernameExist.id != findBy.id) ||
(findBy.username && usernameExist.username != findBy.username)
(findBy._id && usernameExist.uid !== findBy._id) ||
(findBy.username && usernameExist.username !== findBy.username)
)
throw {
code: 409,
@@ -350,11 +344,11 @@ const updateUser = async (
if (!updatedUser)
throw {
code: 404,
message: `Unable to find user with ${findBy.id || findBy.username}`
message: `Unable to find user with ${findBy._id || findBy.username}`
}
return {
id: updatedUser.id,
uid: updatedUser.uid,
username: updatedUser.username,
displayName: updatedUser.displayName,
isAdmin: updatedUser.isAdmin,
@@ -367,7 +361,7 @@ const updateDesktopAutoExec = async (autoExec: string) => {
await updateUserAutoExec(autoExec)
return {
...desktopUser,
id: desktopUser.userId,
uid: desktopUser.userId,
autoExec
}
}
+2 -2
View File
@@ -121,7 +121,7 @@ const login = async (
req.session.loggedIn = true
req.session.user = {
userId: user.id,
userId: user.uid,
clientId: 'web_app',
username: user.username,
displayName: user.displayName,
@@ -134,7 +134,7 @@ const login = async (
return {
loggedIn: true,
user: {
id: user.id,
uid: user.uid,
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin,
+1 -4
View File
@@ -76,7 +76,7 @@ const authenticateToken = async (
const { MODE } = process.env
if (MODE === ModeType.Desktop) {
req.user = {
userId: 1234,
userId: '1234',
clientId: 'desktopModeClientId',
username: 'desktopModeUsername',
displayName: 'desktopModeDisplayName',
@@ -113,9 +113,6 @@ const authenticateToken = async (
throw 'Unauthorized'
} catch (error) {
// A missing/invalid/expired token doesn't necessarily mean 401 - if an
// admin has granted the built-in Public group access to this exact
// path, an unauthenticated caller still gets through as publicUser.
if (await isPublicRoute(req)) {
req.user = publicUser
return next()
+1 -1
View File
@@ -18,7 +18,7 @@ export const authorize: RequestHandler = async (req, res, next) => {
// no need to check for permissions when route is Public
if (await isPublicRoute(req)) return next()
const dbUser = await User.findOne({ id: user.userId })
const dbUser = await User.findOne({ _id: user.userId })
if (!dbUser) return res.sendStatus(401)
const path = getPath(req)
+1 -6
View File
@@ -5,11 +5,6 @@ import { ModeType } from '../utils'
const regexUser = /^\/SASjsApi\/user\/[0-9]*$/ // /SASjsApi/user/1
// Desktop mode has no login/logout (see authenticateAccessToken's desktop
// bypass) and every request runs as the single fixed desktopUser, but the
// desktop UI still needs to read/update that user's own profile (e.g.
// autoExec) - so these two routes stay reachable while every other
// /SASLogon/* and user-management route is blocked below.
const allowedInDesktopMode: { [key: string]: RegExp[] } = {
GET: [regexUser],
PATCH: [regexUser]
@@ -33,7 +28,7 @@ export const desktopRestrict: RequestHandler = (req, res, next) => {
}
export const desktopUser: RequestUser = {
userId: 12345,
userId: '12345',
clientId: 'desktop_app',
username: userInfo().username,
displayName: userInfo().username,
+2 -2
View File
@@ -8,8 +8,8 @@ export const verifyAdminIfNeeded: RequestHandler = (req, res, next) => {
if (!user?.isAdmin) {
let adminAccountRequired: boolean = true
if (req.params.userId) {
adminAccountRequired = user?.userId !== parseInt(req.params.userId)
if (req.params.uid) {
adminAccountRequired = user?.userId !== req.params.uid
} else if (req.params.username) {
adminAccountRequired = user?.username !== req.params.username
}
-15
View File
@@ -1,15 +0,0 @@
import mongoose, { Schema } from 'mongoose'
const CounterSchema = new Schema({
id: {
type: String,
required: true,
unique: true
},
seq: {
type: Number,
required: true
}
})
export default mongoose.model('Counter', CounterSchema)
+41 -33
View File
@@ -1,9 +1,9 @@
import { Schema, model, Document, Model } from 'mongoose'
import { GroupDetailsResponse } from '../controllers'
import User, { IUser } from './User'
import { AuthProviderType, getSequenceNextValue } from '../utils'
import { AuthProviderType } from '../utils'
export const PUBLIC_GROUP_NAME = 'Public'
export const PUBLIC_GROUP_NAME = 'public'
export interface GroupPayload {
/**
@@ -24,10 +24,12 @@ export interface GroupPayload {
}
interface IGroupDocument extends GroupPayload, Document {
groupId: number
isActive: boolean
users: Schema.Types.ObjectId[]
authProvider?: AuthProviderType
// Declare virtual properties as read-only properties
readonly uid: string
}
interface IGroup extends IGroupDocument {
@@ -37,40 +39,46 @@ interface IGroup extends IGroupDocument {
}
interface IGroupModel extends Model<IGroup> {}
const groupSchema = new Schema<IGroupDocument>({
name: {
type: String,
required: true,
unique: true
},
groupId: {
type: Number,
unique: true
},
description: {
type: String,
default: 'Group description.'
},
authProvider: {
type: String,
enum: AuthProviderType
},
isActive: {
type: Boolean,
default: true
},
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
})
// Hooks
groupSchema.pre('save', async function () {
if (this.isNew) {
this.groupId = await getSequenceNextValue('groupId')
const opts = {
toJSON: {
virtuals: true,
transform: function (doc: any, ret: any, options: any) {
delete ret._id
delete ret.id
return ret
}
}
}
const groupSchema = new Schema<IGroupDocument>(
{
name: {
type: String,
required: true,
unique: true
},
description: {
type: String,
default: 'Group description.'
},
authProvider: {
type: String,
enum: AuthProviderType
},
isActive: {
type: Boolean,
default: true
},
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
},
opts
)
groupSchema.virtual('uid').get(function () {
return this._id.toString()
})
groupSchema.post('save', function (group: IGroup, next: Function) {
group.populate('users', 'id username displayName -_id').then(function () {
group.populate('users', 'uid username displayName').then(function () {
next()
})
})
+39 -37
View File
@@ -1,6 +1,5 @@
import { Schema, model, Document, Model } from 'mongoose'
import { PermissionDetailsResponse } from '../controllers'
import { getSequenceNextValue } from '../utils'
interface GetPermissionBy {
user?: Schema.Types.ObjectId
@@ -11,9 +10,11 @@ interface IPermissionDocument extends Document {
path: string
type: string
setting: string
permissionId: number
user: Schema.Types.ObjectId
group: Schema.Types.ObjectId
// Declare virtual properties as read-only properties
readonly uid: string
}
interface IPermission extends IPermissionDocument {}
@@ -22,32 +23,39 @@ interface IPermissionModel extends Model<IPermission> {
get(getBy: GetPermissionBy): Promise<PermissionDetailsResponse[]>
}
const permissionSchema = new Schema<IPermissionDocument>({
permissionId: {
type: Number,
unique: true
},
path: {
type: String,
required: true
},
type: {
type: String,
required: true
},
setting: {
type: String,
required: true
},
user: { type: Schema.Types.ObjectId, ref: 'User' },
group: { type: Schema.Types.ObjectId, ref: 'Group' }
})
// Hooks
permissionSchema.pre('save', async function () {
if (this.isNew) {
this.permissionId = await getSequenceNextValue('permissionId')
const opts = {
toJSON: {
virtuals: true,
transform: function (doc: any, ret: any, options: any) {
delete ret._id
delete ret.id
return ret
}
}
}
const permissionSchema = new Schema<IPermissionDocument>(
{
path: {
type: String,
required: true
},
type: {
type: String,
required: true
},
setting: {
type: String,
required: true
},
user: { type: Schema.Types.ObjectId, ref: 'User' },
group: { type: Schema.Types.ObjectId, ref: 'Group' }
},
opts
)
permissionSchema.virtual('uid').get(function () {
return this._id.toString()
})
// Static Methods
@@ -55,20 +63,14 @@ permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
PermissionDetailsResponse[]
> {
return (await this.find(getBy)
.select({
_id: 0,
permissionId: 1,
path: 1,
type: 1,
setting: 1
})
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
.select('uid path type setting')
.populate({ path: 'user', select: 'uid username displayName isAdmin' })
.populate({
path: 'group',
select: 'groupId name description -_id',
select: 'uid name description',
populate: {
path: 'users',
select: 'id username displayName isAdmin -_id',
select: 'uid username displayName isAdmin',
options: { limit: 15 }
}
})) as unknown as PermissionDetailsResponse[]
+70 -64
View File
@@ -1,6 +1,6 @@
import { Schema, model, Document, Model } from 'mongoose'
import { Schema, model, Document, Model, ObjectId } from 'mongoose'
import bcrypt from 'bcryptjs'
import { AuthProviderType, getSequenceNextValue } from '../utils'
import { AuthProviderType } from '../utils'
export interface UserPayload {
/**
@@ -36,7 +36,6 @@ export interface UserPayload {
interface IUserDocument extends UserPayload, Document {
_id: Schema.Types.ObjectId
id: number
isAdmin: boolean
isActive: boolean
needsToUpdatePassword: boolean
@@ -44,6 +43,9 @@ interface IUserDocument extends UserPayload, Document {
groups: Schema.Types.ObjectId[]
tokens: [{ [key: string]: string }]
authProvider?: AuthProviderType
// Declare virtual properties as read-only properties
readonly uid: string
}
export interface IUser extends IUserDocument {
@@ -54,70 +56,74 @@ export interface IUser extends IUserDocument {
interface IUserModel extends Model<IUser> {
hashPassword(password: string): string
}
const userSchema = new Schema<IUserDocument>({
displayName: {
type: String,
required: true
},
username: {
type: String,
required: true,
unique: true
},
id: {
type: Number,
unique: true
},
password: {
type: String,
required: true
},
authProvider: {
type: String,
enum: AuthProviderType
},
isAdmin: {
type: Boolean,
default: false
},
isActive: {
type: Boolean,
default: true
},
needsToUpdatePassword: {
type: Boolean,
default: true
},
autoExec: {
type: String
},
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
tokens: [
{
clientId: {
type: String,
required: true
},
accessToken: {
type: String,
required: true
},
refreshToken: {
type: String,
required: true
}
const opts = {
toJSON: {
virtuals: true,
transform: function (doc: any, ret: any, options: any) {
delete ret._id
delete ret.id
return ret
}
]
})
// Hooks
userSchema.pre('save', async function (next) {
if (this.isNew) {
this.id = await getSequenceNextValue('id')
}
}
next()
const userSchema = new Schema<IUserDocument>(
{
displayName: {
type: String,
required: true
},
username: {
type: String,
required: true,
unique: true
},
password: {
type: String,
required: true
},
authProvider: {
type: String,
enum: AuthProviderType
},
isAdmin: {
type: Boolean,
default: false
},
isActive: {
type: Boolean,
default: true
},
needsToUpdatePassword: {
type: Boolean,
default: true
},
autoExec: {
type: String
},
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
tokens: [
{
clientId: {
type: String,
required: true
},
accessToken: {
type: String,
required: true
},
refreshToken: {
type: String,
required: true
}
}
]
},
opts
)
userSchema.virtual('uid').get(function () {
return this._id.toString()
})
// Static Methods
-8
View File
@@ -5,16 +5,8 @@ import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
const clientRouter = express.Router()
// Neither this route nor the router itself declares auth middleware - both
// POST and GET are actually gated by authenticateAccessToken/verifyAdmin
// applied at the mount point in routes/api/index.ts (`router.use('/client',
// ..., authenticateAccessToken, verifyAdmin, clientRouter)`), which runs for
// every method under /client before requests reach the handlers below. GET's
// own authenticateAccessToken/verifyAdmin a few lines down is therefore
// redundant, not the thing actually protecting it.
clientRouter.post('/', async (req, res) => {
const { error, value: body } = registerClientValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const controller = new ClientController()
+24 -20
View File
@@ -1,7 +1,11 @@
import express from 'express'
import { GroupController } from '../../controllers/'
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
import { getGroupValidation, registerGroupValidation } from '../../utils'
import {
getGroupValidation,
registerGroupValidation,
uidValidation
} from '../../utils'
const groupRouter = express.Router()
@@ -33,12 +37,15 @@ groupRouter.get('/', authenticateAccessToken, async (req, res) => {
}
})
groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => {
const { groupId } = req.params
groupRouter.get('/:uid', authenticateAccessToken, async (req, res) => {
const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
const { uid } = params
const controller = new GroupController()
try {
const response = await controller.getGroup(parseInt(groupId))
const response = await controller.getGroup(uid)
res.send(response)
} catch (err: any) {
res.status(err.code).send(err.message)
@@ -56,7 +63,7 @@ groupRouter.get(
const controller = new GroupController()
try {
const response = await controller.getGroupByGroupName(name)
const response = await controller.getGroupByName(name)
res.send(response)
} catch (err: any) {
res.status(err.code).send(err.message)
@@ -65,18 +72,15 @@ groupRouter.get(
)
groupRouter.post(
'/:groupId/:userId',
'/:groupUid/:userUid',
authenticateAccessToken,
verifyAdmin,
async (req, res) => {
const { groupId, userId } = req.params
const { groupUid, userUid } = req.params
const controller = new GroupController()
try {
const response = await controller.addUserToGroup(
parseInt(groupId),
parseInt(userId)
)
const response = await controller.addUserToGroup(groupUid, userUid)
res.send(response)
} catch (err: any) {
res.status(err.code).send(err.message)
@@ -85,18 +89,15 @@ groupRouter.post(
)
groupRouter.delete(
'/:groupId/:userId',
'/:groupUid/:userUid',
authenticateAccessToken,
verifyAdmin,
async (req, res) => {
const { groupId, userId } = req.params
const { groupUid, userUid } = req.params
const controller = new GroupController()
try {
const response = await controller.removeUserFromGroup(
parseInt(groupId),
parseInt(userId)
)
const response = await controller.removeUserFromGroup(groupUid, userUid)
res.send(response)
} catch (err: any) {
res.status(err.code).send(err.message)
@@ -105,15 +106,18 @@ groupRouter.delete(
)
groupRouter.delete(
'/:groupId',
'/:uid',
authenticateAccessToken,
verifyAdmin,
async (req, res) => {
const { groupId } = req.params
const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
const { uid } = params
const controller = new GroupController()
try {
await controller.deleteGroup(parseInt(groupId))
await controller.deleteGroup(uid)
res.status(200).send('Group Deleted!')
} catch (err: any) {
res.status(err.code).send(err.message)
+19 -17
View File
@@ -3,6 +3,7 @@ import { PermissionController } from '../../controllers/'
import { verifyAdmin } from '../../middlewares'
import {
registerPermissionValidation,
uidValidation,
updatePermissionValidation
} from '../../utils'
@@ -34,14 +35,17 @@ permissionRouter.post('/', verifyAdmin, async (req, res) => {
}
})
permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
const { permissionId } = req.params
permissionRouter.patch('/:uid', verifyAdmin, async (req: any, res) => {
const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
const { uid } = params
const { error, value: body } = updatePermissionValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.updatePermission(permissionId, body)
const response = await controller.updatePermission(uid, body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
@@ -50,20 +54,18 @@ permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
}
})
permissionRouter.delete(
'/:permissionId',
verifyAdmin,
async (req: any, res) => {
const { permissionId } = req.params
permissionRouter.delete('/:uid', verifyAdmin, async (req: any, res) => {
const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
try {
await controller.deletePermission(permissionId)
res.status(200).send('Permission Deleted!')
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
const { uid } = params
try {
await controller.deletePermission(uid)
res.status(200).send('Permission Deleted!')
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
)
})
export default permissionRouter
+6 -6
View File
@@ -13,6 +13,7 @@ import {
generateAccessToken,
generateAuthCode,
generateRefreshToken,
randomBytesHexString,
saveTokensInDB,
verifyTokenInDB
} from '../../../utils'
@@ -20,7 +21,6 @@ import {
const clientId = 'someclientID'
const clientSecret = 'someclientSecret'
const user = {
id: 1234,
displayName: 'Test User',
username: 'testUsername',
password: '87654321',
@@ -52,7 +52,7 @@ describe('auth', () => {
describe('token', () => {
const userInfo: InfoJWT = {
clientId,
userId: user.id
userId: randomBytesHexString(12)
}
beforeAll(async () => {
await userController.createUser(user)
@@ -151,10 +151,10 @@ describe('auth', () => {
currentUser = await userController.createUser(user)
refreshToken = generateRefreshToken({
clientId,
userId: currentUser.id
userId: currentUser.uid
})
await saveTokensInDB(
currentUser.id,
currentUser.uid,
clientId,
'accessToken',
refreshToken
@@ -202,11 +202,11 @@ describe('auth', () => {
currentUser = await userController.createUser(user)
accessToken = generateAccessToken({
clientId,
userId: currentUser.id
userId: currentUser.uid
})
await saveTokensInDB(
currentUser.id,
currentUser.uid,
clientId,
accessToken,
'refreshToken'
+6 -6
View File
@@ -40,10 +40,10 @@ describe('client', () => {
const dbUser = await userController.createUser(adminUser)
adminAccessToken = generateAccessToken({
clientId: client.clientId,
userId: dbUser.id
userId: dbUser.uid
})
await saveTokensInDB(
dbUser.id,
dbUser.uid,
client.clientId,
adminAccessToken,
'refreshToken'
@@ -95,10 +95,10 @@ describe('client', () => {
const dbUser = await userController.createUser(user)
const accessToken = generateAccessToken({
clientId: client.clientId,
userId: dbUser.id
userId: dbUser.uid
})
await saveTokensInDB(
dbUser.id,
dbUser.uid,
client.clientId,
accessToken,
'refreshToken'
@@ -212,10 +212,10 @@ describe('client', () => {
const dbUser = await userController.createUser(user)
const accessToken = generateAccessToken({
clientId: client.clientId,
userId: dbUser.id
userId: dbUser.uid
})
await saveTokensInDB(
dbUser.id,
dbUser.uid,
client.clientId,
accessToken,
'refreshToken'
+10 -16
View File
@@ -71,13 +71,13 @@ describe('code', () => {
permissionController = new PermissionController()
const dbUser = await userController.createUser(user)
accessToken = await generateAndSaveToken(dbUser.id)
accessToken = await generateAndSaveToken(dbUser.uid)
await permissionController.createPermission({
path: '/SASjsApi/code/execute',
type: PermissionType.route,
principalType: PrincipalType.user,
principalId: dbUser.id,
principalId: dbUser.uid,
setting: PermissionSettingForRoute.grant
})
@@ -104,31 +104,25 @@ describe('code', () => {
)
}, 30000)
// A failed SAS session (e.g. %abort;) is a normal outcome of running
// arbitrary user code, not a request-shape/server problem - the HTTP
// request itself was fine, so this must respond exactly like a
// successful run (200, same body shape, no hang), with the log simply
// reflecting what happened. Regression test for #388's follow-up: the
// original #388 fix stopped the hang but over-corrected into a 400
// with a bespoke error shape, which broke Studio's log-tab rendering.
it('returns 200 with the same shape as a successful run when the SAS session fails (%abort;), not a hang or an error shape', async () => {
it('returns a prompt 400 (not a hang) with the complete log when the SAS session fails (%abort;)', async () => {
const response = await request(app)
.post('/SASjsApi/code/execute')
.auth(accessToken, { type: 'bearer' })
.send({ code: '%abort;', runTime: 'sas' })
.expect(200)
.expect(400)
expect(response.text).toEqual(
expect(response.body).toMatchObject({
status: 'failure',
message: 'Job execution failed.'
})
expect(response.body.log).toEqual(
expect.stringContaining('mock SAS execution')
)
expect(response.text).toEqual(
expect.stringContaining('SAS session terminated')
)
}, 30000)
})
})
const generateAndSaveToken = async (userId: number) => {
const generateAndSaveToken = async (userId: string) => {
const accessToken = generateAccessToken({
clientId,
userId
+7 -7
View File
@@ -80,31 +80,31 @@ describe('drive', () => {
con = await mongoose.connect(mongoServer.getUri())
const dbUser = await controller.createUser(user)
accessToken = await generateAndSaveToken(dbUser.id)
accessToken = await generateAndSaveToken(dbUser.uid)
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/deploy',
principalId: dbUser.id
principalId: dbUser.uid
})
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/deploy/upload',
principalId: dbUser.id
principalId: dbUser.uid
})
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/file',
principalId: dbUser.id
principalId: dbUser.uid
})
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/folder',
principalId: dbUser.id
principalId: dbUser.uid
})
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/rename',
principalId: dbUser.id
principalId: dbUser.uid
})
})
@@ -1206,7 +1206,7 @@ const getExampleService = (): ServiceMember =>
((getTreeExample().members[0] as FolderMember).members[0] as FolderMember)
.members[0] as ServiceMember
const generateAndSaveToken = async (userId: number) => {
const generateAndSaveToken = async (userId: string) => {
const adminAccessToken = generateAccessToken({
clientId,
userId
+2 -10
View File
@@ -93,19 +93,11 @@ if (!readResult.ok) {
const code = readResult.value
// real SAS writes errors/aborts directly into the log file itself, not
// just to stderr - mirror that so tests asserting on log content (what
// the API actually returns to the caller) are meaningful
const isAbort = code.includes('%abort;')
const logContent = isAbort
? `NOTE: mock SAS execution\n${code}\nERROR: SAS session terminated. See log for details.\n`
: `NOTE: mock SAS execution\n${code}\n`
if (logPath) {
retry(() => fs.writeFileSync(logPath, logContent))
retry(() => fs.writeFileSync(logPath, `NOTE: mock SAS execution\n${code}\n`))
}
if (isAbort) {
if (code.includes('%abort;')) {
process.stderr.write('ERROR: SAS session terminated. See log for details.\n')
process.exit(1)
}
+60 -47
View File
@@ -11,6 +11,7 @@ import {
} from '../../../utils'
import Group, { PUBLIC_GROUP_NAME } from '../../../model/Group'
import User from '../../../model/User'
import { randomBytes } from 'crypto'
const clientId = 'someclientID'
const adminUser = {
@@ -75,7 +76,7 @@ describe('group', () => {
.send(group)
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
@@ -155,7 +156,7 @@ describe('group', () => {
const dbGroup = await groupController.createGroup(group)
const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
.delete(`/SASjsApi/group/${dbGroup.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
@@ -174,17 +175,17 @@ describe('group', () => {
username: 'deletegroup2'
})
await groupController.addUserToGroup(dbGroup.groupId, dbUser1.id)
await groupController.addUserToGroup(dbGroup.groupId, dbUser2.id)
await groupController.addUserToGroup(dbGroup.uid, dbUser1.uid)
await groupController.addUserToGroup(dbGroup.uid, dbUser2.uid)
await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
.delete(`/SASjsApi/group/${dbGroup.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const res1 = await request(app)
.get(`/SASjsApi/user/${dbUser1.id}`)
.get(`/SASjsApi/user/${dbUser1.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
@@ -192,7 +193,7 @@ describe('group', () => {
expect(res1.body.groups).toEqual([])
const res2 = await request(app)
.get(`/SASjsApi/user/${dbUser2.id}`)
.get(`/SASjsApi/user/${dbUser2.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
@@ -201,8 +202,10 @@ describe('group', () => {
})
it('should respond with Not Found if groupId is incorrect', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app)
.delete(`/SASjsApi/group/1234`)
.delete(`/SASjsApi/group/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
@@ -229,7 +232,7 @@ describe('group', () => {
})
const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
.delete(`/SASjsApi/group/${dbGroup.uid}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
@@ -245,15 +248,15 @@ describe('group', () => {
})
it('should respond with group', async () => {
const { groupId } = await groupController.createGroup(group)
const { uid } = await groupController.createGroup(group)
const res = await request(app)
.get(`/SASjsApi/group/${groupId}`)
.get(`/SASjsApi/group/${uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
@@ -266,15 +269,15 @@ describe('group', () => {
username: 'get' + user.username
})
const { groupId } = await groupController.createGroup(group)
const { uid } = await groupController.createGroup(group)
const res = await request(app)
.get(`/SASjsApi/group/${groupId}`)
.get(`/SASjsApi/group/${uid}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
@@ -292,8 +295,10 @@ describe('group', () => {
})
it('should respond with Not Found if groupId is incorrect', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app)
.get('/SASjsApi/group/1234')
.get(`/SASjsApi/group/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
@@ -312,7 +317,7 @@ describe('group', () => {
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
@@ -333,7 +338,7 @@ describe('group', () => {
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
@@ -379,7 +384,7 @@ describe('group', () => {
expect(res.body).toEqual([
{
groupId: expect.anything(),
uid: expect.anything(),
name: group.name,
description: group.description
}
@@ -401,7 +406,7 @@ describe('group', () => {
expect(res.body).toEqual([
{
groupId: expect.anything(),
uid: expect.anything(),
name: group.name,
description: group.description
}
@@ -426,18 +431,18 @@ describe('group', () => {
const dbUser = await userController.createUser(user)
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([
{
id: expect.anything(),
uid: expect.anything(),
username: user.username,
displayName: user.displayName
}
@@ -452,20 +457,20 @@ describe('group', () => {
})
await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const res = await request(app)
.get(`/SASjsApi/user/${dbUser.id}`)
.get(`/SASjsApi/user/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groups).toEqual([
{
groupId: expect.anything(),
uid: expect.anything(),
name: group.name,
description: group.description
}
@@ -478,21 +483,21 @@ describe('group', () => {
...user,
username: 'addUserRandomUser'
})
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([
{
id: expect.anything(),
uid: expect.anything(),
username: 'addUserRandomUser',
displayName: user.displayName
}
@@ -526,8 +531,10 @@ describe('group', () => {
})
it('should respond with Not Found if groupId is incorrect', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app)
.post('/SASjsApi/group/123/123')
.post(`/SASjsApi/group/${hexValue}/123`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
@@ -538,8 +545,10 @@ describe('group', () => {
it('should respond with Not Found if userId is incorrect', async () => {
const dbGroup = await groupController.createGroup(group)
const hexValue = randomBytes(12).toString('hex')
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/123`)
.post(`/SASjsApi/group/${dbGroup.uid}/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
@@ -556,7 +565,7 @@ describe('group', () => {
})
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(400)
@@ -577,7 +586,7 @@ describe('group', () => {
})
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(405)
@@ -596,7 +605,7 @@ describe('group', () => {
})
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(405)
@@ -618,15 +627,15 @@ describe('group', () => {
...user,
username: 'removeUserRandomUser'
})
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
@@ -639,16 +648,16 @@ describe('group', () => {
...user,
username: 'removeGroupFromUser'
})
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const res = await request(app)
.get(`/SASjsApi/user/${dbUser.id}`)
.get(`/SASjsApi/user/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
@@ -667,7 +676,7 @@ describe('group', () => {
})
const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(405)
@@ -686,7 +695,7 @@ describe('group', () => {
})
const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(405)
@@ -723,8 +732,10 @@ describe('group', () => {
})
it('should respond with Not Found if groupId is incorrect', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app)
.delete('/SASjsApi/group/123/123')
.delete(`/SASjsApi/group/${hexValue}/123`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
@@ -735,8 +746,10 @@ describe('group', () => {
it('should respond with Not Found if userId is incorrect', async () => {
const dbGroup = await groupController.createGroup(group)
const hexValue = randomBytes(12).toString('hex')
const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/123`)
.delete(`/SASjsApi/group/${dbGroup.uid}/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
@@ -752,10 +765,10 @@ const generateSaveTokenAndCreateUser = async (
): Promise<string> => {
const dbUser = await userController.createUser(someUser ?? adminUser)
return generateAndSaveToken(dbUser.id)
return generateAndSaveToken(dbUser.uid)
}
const generateAndSaveToken = async (userId: number) => {
const generateAndSaveToken = async (userId: string) => {
const adminAccessToken = generateAccessToken({
clientId,
userId
+38 -32
View File
@@ -17,6 +17,7 @@ import {
PermissionDetailsResponse
} from '../../../controllers'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
import { randomBytes } from 'crypto'
const deployPayload = {
appLoc: 'string',
@@ -103,10 +104,10 @@ describe('permission', () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...permission, principalId: dbUser.id })
.send({ ...permission, principalId: dbUser.uid })
.expect(200)
expect(res.body.permissionId).toBeTruthy()
expect(res.body.uid).toBeTruthy()
expect(res.body.path).toEqual(permission.path)
expect(res.body.type).toEqual(permission.type)
expect(res.body.setting).toEqual(permission.setting)
@@ -122,11 +123,11 @@ describe('permission', () => {
.send({
...permission,
principalType: 'group',
principalId: dbGroup.groupId
principalId: dbGroup.uid
})
.expect(200)
expect(res.body.permissionId).toBeTruthy()
expect(res.body.uid).toBeTruthy()
expect(res.body.path).toEqual(permission.path)
expect(res.body.type).toEqual(permission.type)
expect(res.body.setting).toEqual(permission.setting)
@@ -144,7 +145,7 @@ describe('permission', () => {
})
it('should respond with Unauthorized if access token is not of an admin account', async () => {
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app)
.post('/SASjsApi/permission')
@@ -281,17 +282,19 @@ describe('permission', () => {
expect(res.body).toEqual({})
})
it('should respond with Bad Request if principalId is not a number', async () => {
it('should respond with Bad Request if principalId is not a string of 24 hex characters', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: 'someCharacters'
principalId: randomBytes(10).toString('hex')
})
.expect(400)
expect(res.text).toEqual('"principalId" must be a number')
expect(res.text).toEqual(
'"principalId" length must be 24 characters long'
)
expect(res.body).toEqual({})
})
@@ -307,7 +310,7 @@ describe('permission', () => {
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: adminUser.id
principalId: adminUser.uid
})
.expect(400)
@@ -321,7 +324,7 @@ describe('permission', () => {
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: 123
principalId: randomBytes(12).toString('hex')
})
.expect(404)
@@ -336,7 +339,7 @@ describe('permission', () => {
.send({
...permission,
principalType: 'group',
principalId: 123
principalId: randomBytes(12).toString('hex')
})
.expect(404)
@@ -347,13 +350,13 @@ describe('permission', () => {
it('should respond with Conflict (409) if permission already exists', async () => {
await permissionController.createPermission({
...permission,
principalId: dbUser.id
principalId: dbUser.uid
})
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...permission, principalId: dbUser.id })
.send({ ...permission, principalId: dbUser.uid })
.expect(409)
expect(res.text).toEqual(
@@ -368,7 +371,7 @@ describe('permission', () => {
beforeAll(async () => {
dbPermission = await permissionController.createPermission({
...permission,
principalId: dbUser.id
principalId: dbUser.uid
})
})
@@ -378,7 +381,7 @@ describe('permission', () => {
it('should respond with updated permission', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ setting: PermissionSettingForRoute.deny })
.expect(200)
@@ -388,7 +391,7 @@ describe('permission', () => {
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
.send()
.expect(401)
@@ -403,7 +406,7 @@ describe('permission', () => {
})
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
@@ -414,7 +417,7 @@ describe('permission', () => {
it('should respond with Bad Request if setting is missing', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(400)
@@ -425,7 +428,7 @@ describe('permission', () => {
it('should respond with Bad Request if setting is invalid', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({
setting: 'invalid'
@@ -437,8 +440,9 @@ describe('permission', () => {
})
it('should respond with not found (404) if permission with provided id does not exist', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app)
.patch('/SASjsApi/permission/123')
.patch(`/SASjsApi/permission/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({
setting: PermissionSettingForRoute.deny
@@ -454,10 +458,10 @@ describe('permission', () => {
it('should delete permission', async () => {
const dbPermission = await permissionController.createPermission({
...permission,
principalId: dbUser.id
principalId: dbUser.uid
})
const res = await request(app)
.delete(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.delete(`/SASjsApi/permission/${dbPermission?.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
@@ -466,8 +470,10 @@ describe('permission', () => {
})
it('should respond with not found (404) if permission with provided id does not exists', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app)
.delete('/SASjsApi/permission/123')
.delete(`/SASjsApi/permission/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
@@ -481,12 +487,12 @@ describe('permission', () => {
await permissionController.createPermission({
...permission,
path: '/test-1',
principalId: dbUser.id
principalId: dbUser.uid
})
await permissionController.createPermission({
...permission,
path: '/test-2',
principalId: dbUser.id
principalId: dbUser.uid
})
})
@@ -505,12 +511,12 @@ describe('permission', () => {
...user,
username: 'get' + user.username
})
const accessToken = await generateAndSaveToken(nonAdminUser.id)
const accessToken = await generateAndSaveToken(nonAdminUser.uid)
await permissionController.createPermission({
path: '/test-1',
type: PermissionType.route,
principalType: PrincipalType.user,
principalId: nonAdminUser.id,
principalId: nonAdminUser.uid,
setting: PermissionSettingForRoute.grant
})
@@ -531,7 +537,7 @@ describe('permission', () => {
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/deploy',
principalId: dbUser.id
principalId: dbUser.uid
})
})
@@ -551,7 +557,7 @@ describe('permission', () => {
})
it('should create files in SASJS drive', async () => {
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
await request(app)
.get('/SASjsApi/drive/deploy')
@@ -561,7 +567,7 @@ describe('permission', () => {
})
it('should respond unauthorized', async () => {
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
await request(app)
.get('/SASjsApi/drive/deploy/upload')
@@ -577,10 +583,10 @@ const generateSaveTokenAndCreateUser = async (
): Promise<string> => {
const dbUser = await userController.createUser(someUser ?? adminUser)
return generateAndSaveToken(dbUser.id)
return generateAndSaveToken(dbUser.uid)
}
const generateAndSaveToken = async (userId: number) => {
const generateAndSaveToken = async (userId: string) => {
const adminAccessToken = generateAccessToken({
clientId,
userId
+3 -3
View File
@@ -58,12 +58,12 @@ describe('stp', () => {
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
const dbUser = await userController.createUser(user)
accessToken = await generateAndSaveToken(dbUser.id)
accessToken = await generateAndSaveToken(dbUser.uid)
await permissionController.createPermission({
path: '/SASjsApi/stp/execute',
type: PermissionType.route,
principalType: PrincipalType.user,
principalId: dbUser.id,
principalId: dbUser.uid,
setting: PermissionSettingForRoute.grant
})
})
@@ -456,7 +456,7 @@ const makeRequestAndAssert = async (
)
}
const generateAndSaveToken = async (userId: number) => {
const generateAndSaveToken = async (userId: string) => {
const accessToken = generateAccessToken({
clientId,
userId
+43 -40
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'crypto'
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
@@ -101,9 +102,9 @@ describe('user', () => {
const dbUser = await controller.createUser(user)
const accessToken = generateAccessToken({
clientId,
userId: dbUser.id
userId: dbUser.uid
})
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
await saveTokensInDB(dbUser.uid, clientId, accessToken, 'refreshToken')
const res = await request(app)
.post('/SASjsApi/user')
@@ -187,7 +188,7 @@ describe('user', () => {
const newDisplayName = 'My new display Name'
const res = await request(app)
.patch(`/SASjsApi/user/${dbUser.id}`)
.patch(`/SASjsApi/user/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...user, displayName: newDisplayName })
.expect(200)
@@ -200,11 +201,11 @@ describe('user', () => {
it('should respond with updated user when user himself requests', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const newDisplayName = 'My new display Name'
const res = await request(app)
.patch(`/SASjsApi/user/${dbUser.id}`)
.patch(`/SASjsApi/user/${dbUser.uid}`)
.auth(accessToken, { type: 'bearer' })
.send({
displayName: newDisplayName,
@@ -221,11 +222,11 @@ describe('user', () => {
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const newDisplayName = 'My new display Name'
await request(app)
.patch(`/SASjsApi/user/${dbUser.id}`)
.patch(`/SASjsApi/user/${dbUser.uid}`)
.auth(accessToken, { type: 'bearer' })
.send({ ...user, displayName: newDisplayName })
.expect(400)
@@ -277,10 +278,10 @@ describe('user', () => {
...user,
username: 'randomUser'
})
const accessToken = await generateAndSaveToken(dbUser2.id)
const accessToken = await generateAndSaveToken(dbUser2.uid)
const res = await request(app)
.patch(`/SASjsApi/user/${dbUser1.id}`)
.patch(`/SASjsApi/user/${dbUser1.uid}`)
.auth(accessToken, { type: 'bearer' })
.send(user)
.expect(401)
@@ -297,7 +298,7 @@ describe('user', () => {
})
const res = await request(app)
.patch(`/SASjsApi/user/${dbUser1.id}`)
.patch(`/SASjsApi/user/${dbUser1.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ username: dbUser2.username })
.expect(409)
@@ -325,7 +326,7 @@ describe('user', () => {
it('should respond with updated user when user himself requests', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const newDisplayName = 'My new display Name'
const res = await request(app)
@@ -346,7 +347,7 @@ describe('user', () => {
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const newDisplayName = 'My new display Name'
await request(app)
@@ -372,10 +373,10 @@ describe('user', () => {
...user,
username: 'randomUser'
})
const accessToken = await generateAndSaveToken(dbUser2.id)
const accessToken = await generateAndSaveToken(dbUser2.uid)
const res = await request(app)
.patch(`/SASjsApi/user/${dbUser1.id}`)
.patch(`/SASjsApi/user/${dbUser1.uid}`)
.auth(accessToken, { type: 'bearer' })
.send(user)
.expect(401)
@@ -418,7 +419,7 @@ describe('user', () => {
const dbUser = await controller.createUser(user)
const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`)
.delete(`/SASjsApi/user/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
@@ -428,10 +429,10 @@ describe('user', () => {
it('should respond with OK when user himself requests', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`)
.delete(`/SASjsApi/user/${dbUser.uid}`)
.auth(accessToken, { type: 'bearer' })
.send({ password: user.password })
.expect(200)
@@ -441,10 +442,10 @@ describe('user', () => {
it('should respond with Bad Request when user himself requests and password is missing', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`)
.delete(`/SASjsApi/user/${dbUser.uid}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(400)
@@ -469,10 +470,10 @@ describe('user', () => {
...user,
username: 'randomUser'
})
const accessToken = await generateAndSaveToken(dbUser2.id)
const accessToken = await generateAndSaveToken(dbUser2.uid)
const res = await request(app)
.delete(`/SASjsApi/user/${dbUser1.id}`)
.delete(`/SASjsApi/user/${dbUser1.uid}`)
.auth(accessToken, { type: 'bearer' })
.send(user)
.expect(401)
@@ -483,10 +484,10 @@ describe('user', () => {
it('should respond with Unauthorized when user himself requests and password is incorrect', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`)
.delete(`/SASjsApi/user/${dbUser.uid}`)
.auth(accessToken, { type: 'bearer' })
.send({ password: 'incorrectpassword' })
.expect(401)
@@ -510,7 +511,7 @@ describe('user', () => {
it('should respond with OK when user himself requests', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
@@ -523,7 +524,7 @@ describe('user', () => {
it('should respond with Bad Request when user himself requests and password is missing', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
@@ -551,7 +552,7 @@ describe('user', () => {
...user,
username: 'randomUser'
})
const accessToken = await generateAndSaveToken(dbUser2.id)
const accessToken = await generateAndSaveToken(dbUser2.uid)
const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser1.username}`)
@@ -565,7 +566,7 @@ describe('user', () => {
it('should respond with Unauthorized when user himself requests and password is incorrect', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
@@ -592,7 +593,7 @@ describe('user', () => {
it('should respond with user autoExec when same user requests', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const userId = dbUser.uid
const accessToken = await generateAndSaveToken(userId)
const res = await request(app)
@@ -611,7 +612,7 @@ describe('user', () => {
it('should respond with user autoExec when admin user requests', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const userId = dbUser.uid
const res = await request(app)
.get(`/SASjsApi/user/${userId}`)
@@ -634,7 +635,7 @@ describe('user', () => {
})
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const userId = dbUser.uid
const res = await request(app)
.get(`/SASjsApi/user/${userId}`)
@@ -652,7 +653,7 @@ describe('user', () => {
it('should respond with user along with associated groups', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const userId = dbUser.uid
const accessToken = await generateAndSaveToken(userId)
const group = {
@@ -661,7 +662,7 @@ describe('user', () => {
}
const groupController = new GroupController()
const dbGroup = await groupController.createGroup(group)
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
const res = await request(app)
.get(`/SASjsApi/user/${userId}`)
@@ -690,8 +691,10 @@ describe('user', () => {
it('should respond with Not Found if userId is incorrect', async () => {
await controller.createUser(user)
const hexValue = randomBytes(12).toString('hex')
const res = await request(app)
.get('/SASjsApi/user/1234')
.get(`/SASjsApi/user/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
@@ -703,7 +706,7 @@ describe('user', () => {
describe('by username', () => {
it('should respond with user autoExec when same user requests', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const userId = dbUser.uid
const accessToken = await generateAndSaveToken(userId)
const res = await request(app)
@@ -803,13 +806,13 @@ describe('user', () => {
expect(res.body).toEqual([
{
id: expect.anything(),
uid: expect.anything(),
username: adminUser.username,
displayName: adminUser.displayName,
isAdmin: adminUser.isAdmin
},
{
id: expect.anything(),
uid: expect.anything(),
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin
@@ -831,13 +834,13 @@ describe('user', () => {
expect(res.body).toEqual([
{
id: expect.anything(),
uid: expect.anything(),
username: adminUser.username,
displayName: adminUser.displayName,
isAdmin: adminUser.isAdmin
},
{
id: expect.anything(),
uid: expect.anything(),
username: 'randomUser',
displayName: user.displayName,
isAdmin: user.isAdmin
@@ -859,10 +862,10 @@ const generateSaveTokenAndCreateUser = async (
): Promise<string> => {
const dbUser = await controller.createUser(someUser ?? adminUser)
return generateAndSaveToken(dbUser.id)
return generateAndSaveToken(dbUser.uid)
}
const generateAndSaveToken = async (userId: number) => {
const generateAndSaveToken = async (userId: string) => {
const adminAccessToken = generateAccessToken({
clientId,
userId
+1 -1
View File
@@ -145,7 +145,7 @@ describe('web', () => {
expect(res.body.loggedIn).toBeTruthy()
expect(res.body.user).toEqual({
id: expect.any(Number),
uid: expect.any(String),
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin,
+21 -9
View File
@@ -9,6 +9,7 @@ import {
deleteUserValidation,
getUserValidation,
registerUserValidation,
uidValidation,
updateUserValidation
} from '../../utils'
@@ -56,12 +57,15 @@ userRouter.get(
}
)
userRouter.get('/:userId', authenticateAccessToken, async (req, res) => {
const { userId } = req.params
userRouter.get('/:uid', authenticateAccessToken, async (req, res) => {
const { error, value: params } = uidValidation(req.params)
if (error) return res.status(400).send(error.details[0].message)
const { uid } = params
const controller = new UserController()
try {
const response = await controller.getUser(req, parseInt(userId))
const response = await controller.getUser(req, uid)
res.send(response)
} catch (err: any) {
res.status(err.code).send(err.message)
@@ -97,12 +101,16 @@ userRouter.patch(
)
userRouter.patch(
'/:userId',
'/:uid',
authenticateAccessToken,
verifyAdminIfNeeded,
async (req, res) => {
const { user } = req
const { userId } = req.params
const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
const { uid } = params
// only an admin can update `isActive` and `isAdmin` fields
const { error, value: body } = updateUserValidation(req.body, user!.isAdmin)
@@ -110,7 +118,7 @@ userRouter.patch(
const controller = new UserController()
try {
const response = await controller.updateUser(parseInt(userId), body)
const response = await controller.updateUser(uid, body)
res.send(response)
} catch (err: any) {
res.status(err.code).send(err.message)
@@ -147,12 +155,16 @@ userRouter.delete(
)
userRouter.delete(
'/:userId',
'/:uid',
authenticateAccessToken,
verifyAdminIfNeeded,
async (req, res) => {
const { user } = req
const { userId } = req.params
const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
const { uid } = params
// only an admin can delete user without providing password
const { error, value: data } = deleteUserValidation(req.body, user!.isAdmin)
@@ -160,7 +172,7 @@ userRouter.delete(
const controller = new UserController()
try {
await controller.deleteUser(parseInt(userId), data, user!.isAdmin)
await controller.deleteUser(uid, data, user!.isAdmin)
res.status(200).send('Account Deleted!')
} catch (err: any) {
res.status(err.code).send(err.message)
+1 -1
View File
@@ -1,4 +1,4 @@
export interface InfoJWT {
clientId: string
userId: number
userId: string
}
+1 -1
View File
@@ -1,6 +1,6 @@
export interface PreProgramVars {
username: string
userId: number
userId: string
displayName: string
serverUrl: string
httpHeaders: string[]
+1 -1
View File
@@ -1,5 +1,5 @@
export interface RequestUser {
userId: number
userId: string
clientId: string
username: string
displayName: string
+4
View File
@@ -0,0 +1,4 @@
import { randomBytes } from 'crypto'
export const randomBytesHexString = (bytesCount: number) =>
randomBytes(bytesCount).toString('hex')
-4
View File
@@ -2,10 +2,6 @@ import { Request } from 'express'
export const TopLevelRoutes = ['/AppStream', '/SASjsApi']
// Being authenticated is enough for most routes. These specifically also
// require a granted Permission (checked by the authorize middleware) because
// they run arbitrary submitted code or manipulate arbitrary files on disk -
// a narrower blast radius than everything else a logged-in user can do.
const StaticAuthorizedRoutes = [
'/SASjsApi/code/execute',
'/SASjsApi/stp/execute',
+1 -1
View File
@@ -22,7 +22,7 @@ export const getPreProgramVariables = (req: Request): PreProgramVars => {
//So this is workaround.
return {
username: user ? user.username : 'demo',
userId: user ? user.userId : 0,
userId: user ? user.userId : 'demoId',
displayName: user ? user.displayName : 'demo',
serverUrl: protocol + host,
httpHeaders
-15
View File
@@ -1,15 +0,0 @@
import Counter from '../model/Counter'
export const getSequenceNextValue = async (seqName: string) => {
const seqDoc = await Counter.findOne({ id: seqName })
if (!seqDoc) {
await Counter.create({ id: seqName, seq: 1 })
return 1
}
seqDoc.seq += 1
await seqDoc.save()
return seqDoc.seq
}
+3 -3
View File
@@ -5,7 +5,7 @@ import { InfoJWT } from '../types/InfoJWT'
const isValidToken = async (
token: string,
key: string,
userId: number,
userId: string,
clientId: string
) => {
const promise = new Promise<boolean>((resolve, reject) =>
@@ -24,8 +24,8 @@ const isValidToken = async (
return await promise.then(() => true).catch(() => false)
}
export const getTokensFromDB = async (userId: number, clientId: string) => {
const user = await User.findOne({ id: userId })
export const getTokensFromDB = async (userId: string, clientId: string) => {
const user = await User.findOne({ _id: userId })
if (!user) return
const currentTokenObj = user.tokens.find(
+1 -1
View File
@@ -2,6 +2,7 @@ export * from './appStreamConfig'
export * from './connectDB'
export * from './copySASjsCore'
export * from './createWeboutSasFile'
export * from './crypto'
export * from './desktopAutoExec'
export * from './extractHeaders'
export * from './extractName'
@@ -14,7 +15,6 @@ export * from './getCertificates'
export * from './getDesktopFields'
export * from './getPreProgramVariables'
export * from './getRunTimeAndFilePath'
export * from './getSequenceNextValue'
export * from './getServerUrl'
export * from './getTokensFromDB'
export * from './instantiateLogger'
+1 -1
View File
@@ -22,7 +22,7 @@ export const isPublicRoute = async (req: Request): Promise<boolean> => {
}
export const publicUser: RequestUser = {
userId: 0,
userId: 'public_user_id',
clientId: 'public_app',
username: 'publicUser',
displayName: 'Public User',
+2 -2
View File
@@ -1,7 +1,7 @@
import User from '../model/User'
export const removeTokensInDB = async (userId: number, clientId: string) => {
const user = await User.findOne({ id: userId })
export const removeTokensInDB = async (userId: string, clientId: string) => {
const user = await User.findOne({ _id: userId })
if (!user) return
const tokenObjIndex = user.tokens.findIndex(
+2 -2
View File
@@ -1,12 +1,12 @@
import User from '../model/User'
export const saveTokensInDB = async (
userId: number,
userId: string,
clientId: string,
accessToken: string,
refreshToken: string
) => {
const user = await User.findOne({ id: userId })
const user = await User.findOne({ _id: userId })
if (!user) return
const currentTokenObj = user.tokens.find(
+1 -1
View File
@@ -82,7 +82,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
}
export const ALL_USERS_GROUP = {
name: 'AllUsers',
name: 'all-users',
description: 'Group contains all users'
}
+6 -1
View File
@@ -12,6 +12,11 @@ const groupnameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
export const blockFileRegex = /\.(exe|sh|htaccess)$/i
export const uidValidation = (data: any) =>
Joi.object({
uid: Joi.string().length(24).hex().required()
}).validate(data)
export const getUserValidation = (data: any): Joi.ValidationResult =>
Joi.object({
username: usernameSchema.required()
@@ -113,7 +118,7 @@ export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
principalType: Joi.string()
.required()
.valid(...Object.values(PrincipalType)),
principalId: Joi.number().required()
principalId: Joi.string().length(24).hex().required()
}).validate(data)
export const updatePermissionValidation = (data: any): Joi.ValidationResult =>
+4 -11
View File
@@ -4,7 +4,7 @@ import { RequestUser } from '../types'
export const fetchLatestAutoExec = async (
reqUser: RequestUser
): Promise<RequestUser | undefined> => {
const dbUser = await User.findOne({ id: reqUser.userId })
const dbUser = await User.findOne({ _id: reqUser.userId })
if (!dbUser) return undefined
@@ -20,20 +20,13 @@ export const fetchLatestAutoExec = async (
}
}
// Access/refresh tokens are JWTs, so they're self-verifying - but that
// alone would make them impossible to revoke before expiry. Requiring the
// exact token string to still be the one stored on the user's tokens array
// (written by saveTokensInDB, cleared by removeTokensInDB) turns them into
// revocable credentials: logout, or issuing a fresh pair via /auth/refresh,
// immediately invalidates the old token even though its signature is still
// valid.
export const verifyTokenInDB = async (
userId: number,
userId: string,
clientId: string,
token: string,
tokenType: 'accessToken' | 'refreshToken'
): Promise<RequestUser | undefined> => {
const dbUser = await User.findOne({ id: userId })
const dbUser = await User.findOne({ _id: userId })
if (!dbUser) return undefined
@@ -43,7 +36,7 @@ export const verifyTokenInDB = async (
return currentTokenObj?.[tokenType] === token
? {
userId: dbUser.id,
userId: dbUser.uid,
clientId,
username: dbUser.username,
displayName: dbUser.displayName,
-188
View File
@@ -1,188 +0,0 @@
# Authentication flow
How a client (browser SPA or CLI/SDK) turns a username/password into an
authenticated `SASjsApi` request, and how that request is subsequently
authorized. The mechanism is a self-hosted session-then-authorization-code
flow, optionally backed by LDAP for credential verification.
```mermaid
sequenceDiagram
participant Browser as Client<br/>(browser SPA, or CLI/SDK<br/>simulating one with a cookie jar)
participant Web as Web routes + controller<br/>(routes/web/web.ts,<br/>controllers/web.ts)
participant Mid as authenticateAccessToken<br/>(middlewares/authenticateToken.ts)
participant Authz as authorize<br/>(middlewares/authorize.ts)
participant AuthCtrl as Auth routes + controller<br/>(routes/api/auth.ts,<br/>controllers/auth.ts)
participant Sess as Session store<br/>(MongoDB via connect-mongo)
participant DB as MongoDB<br/>(User / Client / Permission)
participant LDAP as LDAP server<br/>(optional, AUTH_PROVIDERS=ldap)
participant API as Any SASjsApi route
Note over Browser,Web: STEP 0 - load the app, get a CSRF token
Browser->>Web: GET /
Web-->>Browser: index.html with an inline script that sets<br/>document.cookie = XSRF-TOKEN=...<br/>(routes/web/web.ts:14-32, csrfProtection.ts:7)
Note over Browser,LDAP: STEP 1 - username/password login, establishes a session
Browser->>Web: POST /SASLogon/login { username, password }
Web->>Web: desktopRestrict - 403 if MODE=desktop (desktop.ts:19-28)
Web->>Web: bruteForceProtection + RateLimiter.consume(ip, username)<br/>(routes/web/web.ts:37, controllers/web.ts:106-113)
Web->>DB: User.findOne({ username }) (controllers/web.ts:86)
alt AUTH_PROVIDERS=ldap and user.authProvider=ldap
Web->>LDAP: LDAPClient.verifyUser(username, password) -<br/>looks up the user's DN, then binds as them<br/>(controllers/web.ts:95-98, utils/ldapClient.ts)
LDAP-->>Web: bind succeeds or rejects
else local user (default)
Web->>Web: user.comparePassword(password) - bcrypt<br/>compare against the stored hash (controllers/web.ts:100)
end
Web->>Sess: req.session.loggedIn = true<br/>req.session.user = { userId, clientId: "web_app",<br/>username, isAdmin, autoExec, ... } (controllers/web.ts:122-132)
Sess-->>Browser: Set-Cookie: connect.sid=...<br/>(httpOnly, 24h maxAge, connect-mongo backed,<br/>app-modules/configureExpressSession.ts:30-46)
Web-->>Browser: 200 { loggedIn: true, user: {...} }<br/>(controllers/web.ts:134-143) - note: no token yet
Note over Browser,DB: STEP 2 - exchange the session for a short-lived auth code
Browser->>Web: POST /SASLogon/authorize { clientId }<br/>Cookie: connect.sid=...<br/>X-XSRF-TOKEN: <value read from the XSRF-TOKEN cookie>
Web->>Mid: authenticateAccessToken (routes/web/web.ts:58)
Mid->>Mid: req.session.loggedIn is true, so this request<br/>is validated via the session branch, not a JWT<br/>(authenticateToken.ts:32-44)
Mid->>DB: fetchLatestAutoExec(req.session.user) - re-reads<br/>the User row so isActive/isAdmin/autoExec are current,<br/>not whatever was cached at login time (authenticateToken.ts:34,<br/>utils/verifyTokenInDB.ts:4-21)
Mid->>Mid: csrfProtection - verify the submitted token<br/>against the server-held secret (authenticateToken.ts:39,<br/>csrfProtection.ts:9-32)
Mid-->>Web: next()
Web->>DB: Client.findOne({ clientId }) (controllers/web.ts:153)
Web->>Web: generateAuthCode({ clientId, userId }) - a JWT<br/>signed with AUTH_CODE_SECRET, 30s expiry<br/>(utils/generateAuthCode.ts:4-7)
Web->>Web: AuthController.saveCode(userId, clientId, code) -<br/>kept in an in-memory map on the Node process,<br/>not persisted to DB (controllers/auth.ts:30-36)
Web-->>Browser: 200 { code }
Note over Browser,DB: STEP 3 - exchange the auth code for access/refresh tokens
Browser->>AuthCtrl: POST /SASjsApi/auth/token { clientId, code }
AuthCtrl->>AuthCtrl: verifyAuthCode() - jwt.verify(code, AUTH_CODE_SECRET),<br/>then check the payload's clientId matches (controllers/auth.ts:229-248)
AuthCtrl->>AuthCtrl: code must equal AuthController.authCodes[userId][clientId],<br/>then it is deleted - single use (controllers/auth.ts:92-101)
AuthCtrl->>DB: Client.findOne({ clientId }) - reads<br/>accessTokenExpiration/refreshTokenExpiration (controllers/auth.ts:112-113)
Note right of AuthCtrl: no clientSecret check happens here -<br/>the short-lived, single-use auth code from<br/>step 2 is the actual credential
AuthCtrl->>AuthCtrl: generateAccessToken() / generateRefreshToken() -<br/>JWTs signed with ACCESS_TOKEN_SECRET / REFRESH_TOKEN_SECRET<br/>(utils/generateAccessToken.ts, utils/generateRefreshToken.ts)
AuthCtrl->>DB: saveTokensInDB() - persists both tokens on<br/>the User document's tokens array, keyed by clientId<br/>(utils/saveTokensInDB.ts, controllers/auth.ts:124)
AuthCtrl-->>Browser: 200 { accessToken, refreshToken }
Note over Browser,API: STEP 4 - authenticated API request (this is also how<br/>CLI/SDK clients authenticate on every call after<br/>obtaining a token once via steps 0-3)
Browser->>API: any /SASjsApi/... request<br/>Authorization: Bearer <accessToken>
API->>Mid: authenticateAccessToken - no session cookie this<br/>time, so it falls through to JWT verification (authenticateToken.ts:46-52)
Mid->>Mid: jwt.verify(token, ACCESS_TOKEN_SECRET) (authenticateToken.ts:97)
Mid->>DB: verifyTokenInDB(userId, clientId, token, "accessToken") -<br/>token must still equal the one on file for this user+client,<br/>not just be a validly-signed JWT (authenticateToken.ts:99-104,<br/>utils/verifyTokenInDB.ts:23-49)
alt token missing, invalid, or no longer matches DB (e.g. after logout)
Mid->>DB: isPublicRoute(req)? - is there a Permission<br/>granting the built-in Public group access to this path?<br/>(utils/isPublicRoute.ts:8-22)
Mid-->>API: req.user = publicUser and continue,<br/>or 401 Unauthorized if not public (authenticateToken.ts:116-121)
else token valid and user.isActive
Mid->>Mid: req.user = user, req.accessToken = token (authenticateToken.ts:107-110)
opt request path is in getAuthorizedRoutes()<br/>(utils/getAuthorizedRoutes.ts:16-20)
Mid->>Authz: authorize middleware (authenticateToken.ts:26-27)
Authz->>DB: Permission lookups, first grant wins:<br/>admin bypass, public bypass, then user-specific<br/>route permission, user top-level permission,<br/>each of the user's groups at route level,<br/>each of the user's groups at top level (authorize.ts:10-87)
Authz-->>API: next(), or 401 if nothing grants access
end
end
API-->>Browser: 200 + response
Note over Browser,DB: Token refresh, once the access token nears/hits expiry
Browser->>AuthCtrl: POST /SASjsApi/auth/refresh<br/>Authorization: Bearer <refreshToken>
AuthCtrl->>Mid: authenticateRefreshToken - same JWT + DB<br/>cross-check as step 4, against REFRESH_TOKEN_SECRET<br/>and the stored refreshToken (authenticateToken.ts:55-67)
AuthCtrl->>AuthCtrl: generateAccessToken() / generateRefreshToken() again<br/>(controllers/auth.ts:133-140)
AuthCtrl->>DB: saveTokensInDB() - overwrites the stored pair,<br/>the previous refreshToken stops working (controllers/auth.ts:142-147)
AuthCtrl-->>Browser: 200 { accessToken, refreshToken }
Note over Browser,Sess: Logout
Browser->>AuthCtrl: DELETE /SASjsApi/auth/logout (bearer clients)
AuthCtrl->>DB: removeTokensInDB() - clears this clientId's<br/>entry from User.tokens (utils/removeTokensInDB.ts, controllers/auth.ts:152-153)
AuthCtrl-->>Browser: 204
Browser->>Web: GET /SASLogon/logout (session/browser clients)
Web->>Sess: req.session.destroy() (controllers/web.ts:63-67)
Web-->>Browser: 200 OK! - the session document is deleted<br/>server-side and connect.sid is no longer valid
```
## Two credentials, not one
Unlike a typical single-token OAuth setup, this system layers two distinct
credential checks:
1. **The session cookie** (`connect.sid`) - proves "this browser already
logged in with a password (or LDAP bind)". Only used for the
`/SASLogon/*` routes. Stored server-side in MongoDB via `connect-mongo`
(`app-modules/configureExpressSession.ts`), so it can't be forged without
the `SESSION_SECRET`, and expires after 24 hours regardless of activity.
2. **The bearer JWT** (`accessToken`/`refreshToken`) - proves "this caller
was issued a token by exchanging a valid auth code". Used for every
`SASjsApi` route. Despite being a self-verifying JWT, it is *also*
cross-checked against a copy stored on the `User` document
(`utils/verifyTokenInDB.ts:23-49`) - this is what makes server-side
revocation possible (logout, or issuing a new token pair via refresh,
immediately invalidates the old token even though its JWT signature is
still technically valid until expiry).
A CLI/SDK client (e.g. `@sasjs/adapter`) does not have a real browser, so it
plays the role of "Browser" in the diagram above by keeping its own cookie
jar for steps 0-2, then switches to bearer-token auth for everything after -
it does not need a session for any request beyond obtaining the initial
token pair.
## Branches and edge cases
- **Desktop mode** (`MODE=desktop`): `authenticateAccessToken` short-circuits
to a fixed, always-admin `desktopUser` before any session/token logic runs
(`authenticateToken.ts:20-24`, `middlewares/desktop.ts:30-38`) - no
password, session, or CSRF check ever happens. `desktopRestrict`
additionally blocks all three `/SASLogon/*` routes outright in this mode
(`middlewares/desktop.ts:19-28`), since there is no concept of logging in
or out of a single-user desktop install.
- **Public routes**: if token/session auth fails outright, the request is
not necessarily rejected - `isPublicRoute()` checks whether an admin has
granted the built-in Public group access to that specific path
(`utils/isPublicRoute.ts`). If so, the request proceeds as the fixed
`publicUser` (`userId: 0`, `isAdmin: false`) instead of getting a 401.
- **LDAP**: when `AUTH_PROVIDERS=ldap`, only users whose `User.authProvider`
is `ldap` take the LDAP bind path at login; this flag is set when an admin
runs `POST /SASjsApi/authConfig/synchroniseWithLDAP`
(`controllers/authConfig.ts`), which provisions local `User`/`Group`
documents from the LDAP directory. Users created directly in this app
(`authProvider` unset) always use the local bcrypt path, even if LDAP is
configured.
- **`Client` registration** (`model/Client.ts`) is a separate, lightweight
concept from end-user accounts: a `clientId`/`clientSecret` pair with
configurable access/refresh token lifetimes, used only to look up token
expirations during the exchange in step 3 - `clientSecret` is stored but
never actually checked in the token exchange (see the note in the
diagram). Both `POST` and `GET /SASjsApi/client` (`routes/api/client.ts`)
require an authenticated admin - not via anything in that file, but via
`authenticateAccessToken`/`verifyAdmin` applied to the whole `/client`
prefix at the mount point in `routes/api/index.ts:28-34`, which runs
before any request reaches `clientRouter`'s own handlers.
- **`authorize` middleware permission model** (`middlewares/authorize.ts`):
admins and requests to already-public routes always pass. Otherwise,
the first matching `Permission` wins, checked in this order: the specific
user on the exact route, the specific user on the route's top-level
prefix (`/SASjsApi` or `/AppStream`), then each of the user's groups on
the exact route, then each of the user's groups on the top-level prefix.
This middleware only runs at all for routes listed in
`getAuthorizedRoutes()` (`utils/getAuthorizedRoutes.ts:5-20`) - a fixed
set of sensitive routes (code/STP execution, drive file operations) plus
`/SASjsApi`, `/AppStream`, and any configured streaming app sub-routes.
Routes outside that list only require authentication, not a granted
permission.
## Key source files
- `api/src/routes/web/web.ts`, `api/src/controllers/web.ts` - `/SASLogon/login`,
`/SASLogon/authorize`, `/SASLogon/logout`; the browser-facing,
session-based half of the flow.
- `api/src/routes/api/auth.ts`, `api/src/controllers/auth.ts` - `/SASjsApi/auth/token`,
`/refresh`, `/logout`, `/updatePassword`; the token-based half used by
every API caller.
- `api/src/middlewares/authenticateToken.ts` - `authenticateAccessToken` /
`authenticateRefreshToken`, the single entry point that decides between
desktop bypass, session validation, and JWT validation.
- `api/src/middlewares/authorize.ts` - permission checks layered on top of
authentication, for routes in `getAuthorizedRoutes()`.
- `api/src/middlewares/csrfProtection.ts` - CSRF token generation/verification
for session-authenticated (cookie-based) requests.
- `api/src/utils/verifyTokenInDB.ts` - `verifyTokenInDB` (DB-backed
revocation check) and `fetchLatestAutoExec` (refreshes session-cached user
fields).
- `api/src/utils/isPublicRoute.ts`, `api/src/utils/getAuthorizedRoutes.ts` -
the Public-group fallback and the authorization-required route list.
- `api/src/app-modules/configureExpressSession.ts` - session cookie/store
configuration (MongoDB via `connect-mongo`).
- `api/src/model/User.ts`, `api/src/model/Client.ts`,
`api/src/model/Permission.ts`, `api/src/model/Group.ts` - the underlying
data model.
+1 -1
View File
@@ -40,7 +40,7 @@ const Login = () => {
})
if (loggedIn) {
appContext.setUserId?.(user.id)
appContext.setUserId?.(user.uid)
appContext.setUsername?.(user.username)
appContext.setDisplayName?.(user.displayName)
appContext.setIsAdmin?.(user.isAdmin)
@@ -99,8 +99,8 @@ const AddPermissionModal = ({
principalType: principalType.toLowerCase(),
principalId:
principalType.toLowerCase() === 'user'
? userPrincipal?.id
: groupPrincipal?.groupId
? userPrincipal?.uid
: groupPrincipal?.uid
}
permissions.push(addPermissionPayload)
@@ -61,7 +61,7 @@ const PermissionTable = ({
</TableHead>
<TableBody>
{permissions.map((permission) => (
<TableRow key={permission.permissionId}>
<TableRow key={permission.uid}>
<BootstrapTableCell>{permission.path}</BootstrapTableCell>
<BootstrapTableCell>{permission.type}</BootstrapTableCell>
<BootstrapTableCell>
@@ -69,7 +69,7 @@ const useAddPermission = () => {
for (const permission of updatingPermissions) {
await axios
.patch(`/SASjsApi/permission/${permission.permissionId}`, {
.patch(`/SASjsApi/permission/${permission.uid}`, {
setting: permission.setting === 'Grant' ? 'Deny' : 'Grant'
})
.then((res) => {
@@ -24,7 +24,7 @@ const useDeletePermissionModal = () => {
setDeleteConfirmationModalOpen(false)
setIsLoading(true)
axios
.delete(`/SASjsApi/permission/${selectedPermission?.permissionId}`)
.delete(`/SASjsApi/permission/${selectedPermission?.uid}`)
.then((res: any) => {
fetchPermissions()
setSnackbarMessage('Permission deleted!')
@@ -62,21 +62,17 @@ const useFilterPermissions = () => {
: permissions
let filteredArray = uriFilteredPermissions.filter((permission) =>
principalFilteredPermissions.some(
(item) => item.permissionId === permission.permissionId
)
principalFilteredPermissions.some((item) => item.uid === permission.uid)
)
filteredArray = filteredArray.filter((permission) =>
principalTypeFilteredPermissions.some(
(item) => item.permissionId === permission.permissionId
(item) => item.uid === permission.uid
)
)
filteredArray = filteredArray.filter((permission) =>
settingFilteredPermissions.some(
(item) => item.permissionId === permission.permissionId
)
settingFilteredPermissions.some((item) => item.uid === permission.uid)
)
setFilteredPermissions(filteredArray)
@@ -24,7 +24,7 @@ const useUpdatePermissionModal = () => {
setUpdatePermissionModalOpen(false)
setIsLoading(true)
axios
.patch(`/SASjsApi/permission/${selectedPermission?.permissionId}`, {
.patch(`/SASjsApi/permission/${selectedPermission?.uid}`, {
setting
})
.then((res: any) => {
+14 -12
View File
@@ -26,18 +26,20 @@ const Profile = () => {
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
useEffect(() => {
setIsLoading(true)
axios
.get(`/SASjsApi/user/${appContext.userId}`)
.then((res: any) => {
setUser(res.data)
})
.catch((err) => {
console.log(err)
})
.finally(() => {
setIsLoading(false)
})
if (appContext.userId) {
setIsLoading(true)
axios
.get(`/SASjsApi/user/${appContext.userId}`)
.then((res: any) => {
setUser(res.data)
})
.catch((err) => {
console.log(err)
})
.finally(() => {
setIsLoading(false)
})
}
}, [appContext.userId])
const handleChange = (event: any) => {
+12 -19
View File
@@ -24,39 +24,32 @@ export enum RunTimeType {
interface AppContextProps {
checkingSession: boolean
loggedIn: boolean
setLoggedIn: Dispatch<SetStateAction<boolean>> | null
setLoggedIn?: Dispatch<SetStateAction<boolean>>
needsToUpdatePassword: boolean
setNeedsToUpdatePassword: Dispatch<SetStateAction<boolean>> | null
userId: number
setUserId: Dispatch<SetStateAction<number>> | null
setNeedsToUpdatePassword?: Dispatch<SetStateAction<boolean>>
userId?: string
setUserId?: Dispatch<SetStateAction<string | undefined>>
username: string
setUsername: Dispatch<SetStateAction<string>> | null
setUsername?: Dispatch<SetStateAction<string>>
displayName: string
setDisplayName: Dispatch<SetStateAction<string>> | null
setDisplayName?: Dispatch<SetStateAction<string>>
isAdmin: boolean
setIsAdmin: Dispatch<SetStateAction<boolean>> | null
setIsAdmin?: Dispatch<SetStateAction<boolean>>
mode: ModeType
runTimes: RunTimeType[]
logout: (() => void) | null
logout?: () => void
}
export const AppContext = createContext<AppContextProps>({
checkingSession: false,
loggedIn: false,
setLoggedIn: null,
needsToUpdatePassword: false,
setNeedsToUpdatePassword: null,
userId: 0,
setUserId: null,
userId: '',
username: '',
setUsername: null,
displayName: '',
setDisplayName: null,
isAdmin: false,
setIsAdmin: null,
mode: ModeType.Server,
runTimes: [],
logout: null
runTimes: []
})
const AppContextProvider = (props: { children: ReactNode }) => {
@@ -64,7 +57,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
const [checkingSession, setCheckingSession] = useState(false)
const [loggedIn, setLoggedIn] = useState(false)
const [needsToUpdatePassword, setNeedsToUpdatePassword] = useState(false)
const [userId, setUserId] = useState(0)
const [userId, setUserId] = useState<string>()
const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
@@ -79,7 +72,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
.then((res) => res.data)
.then((data: any) => {
setCheckingSession(false)
setUserId(data.id)
setUserId(data.uid)
setUsername(data.username)
setDisplayName(data.displayName)
setIsAdmin(data.isAdmin)
+4 -4
View File
@@ -6,13 +6,13 @@ export const findExistingPermission = (
) => {
for (const permission of existingPermissions) {
if (
permission.user?.id === newPermission.principalId &&
permission.user?.uid === newPermission.principalId &&
hasSameCombination(permission, newPermission)
)
return permission
if (
permission.group?.groupId === newPermission.principalId &&
permission.group?.uid === newPermission.principalId &&
hasSameCombination(permission, newPermission)
)
return permission
@@ -27,13 +27,13 @@ export const findUpdatingPermission = (
) => {
for (const permission of existingPermissions) {
if (
permission.user?.id === newPermission.principalId &&
permission.user?.uid === newPermission.principalId &&
hasDifferentSetting(permission, newPermission)
)
return permission
if (
permission.group?.groupId === newPermission.principalId &&
permission.group?.uid === newPermission.principalId &&
hasDifferentSetting(permission, newPermission)
)
return permission
+4 -4
View File
@@ -1,12 +1,12 @@
export interface UserResponse {
id: number
uid: string
username: string
displayName: string
isAdmin: boolean
}
export interface GroupResponse {
groupId: number
uid: string
name: string
description: string
}
@@ -17,7 +17,7 @@ export interface GroupDetailsResponse extends GroupResponse {
}
export interface PermissionResponse {
permissionId: number
uid: string
path: string
type: string
setting: string
@@ -30,7 +30,7 @@ export interface RegisterPermissionPayload {
type: string
setting: string
principalType: string
principalId: number
principalId: string
}
export interface TreeNode {