mirror of
https://github.com/sasjs/server.git
synced 2026-07-24 13:42:14 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5421600e | ||
|
|
1c4f5fc421 |
@@ -1 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
run: npm run lint-web
|
run: npm run lint-web
|
||||||
|
|
||||||
build-api:
|
build-api:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
CI: true
|
CI: true
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@@ -1,60 +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)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **jsonwebtoken:** bumped version to avoid vulnerability ([1473925](https://github.com/sasjs/server/commit/1473925896db0e4472c9ef5ba64955527a1be2de))
|
|
||||||
|
|
||||||
## [0.39.6](https://github.com/sasjs/server/compare/v0.39.5...v0.39.6) (2026-07-14)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **api:** isolate drive.spec.ts's files folder from the real drive ([dec5191](https://github.com/sasjs/server/commit/dec51914911c10b66b43ebe95f77b0af63e2d03e))
|
|
||||||
* **api:** stop overwriting a failed JS/PY/R session back to completed ([4858245](https://github.com/sasjs/server/commit/4858245372fa19929b6e59c28148f5d004239c9d))
|
|
||||||
|
|
||||||
## [0.39.5](https://github.com/sasjs/server/compare/v0.39.4...v0.39.5) (2026-07-14)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **api:** harden mock SAS executable and timeouts for CI ([67fce47](https://github.com/sasjs/server/commit/67fce475a3c53b3c20919902edb45dce809dffb9))
|
|
||||||
* **api:** return prompt error response instead of hanging when a SAS session fails ([f84af4a](https://github.com/sasjs/server/commit/f84af4ac0639625fee4508e764f0eed07d7b4e8a))
|
|
||||||
* fix code.spec.ts failing on a fresh checkout (CI) ([0af6b63](https://github.com/sasjs/server/commit/0af6b63ffa770bfa2b77505d74778be517f8d631))
|
|
||||||
|
|
||||||
## [0.39.4](https://github.com/sasjs/server/compare/v0.39.3...v0.39.4) (2025-12-21)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **deps:** bump dependencies and resolve audit issues ([4f78202](https://github.com/sasjs/server/commit/4f782025dbcdfcbae6ca1fabb42ce1bc385e0162))
|
|
||||||
|
|
||||||
## [0.39.3](https://github.com/sasjs/server/compare/v0.39.2...v0.39.3) (2025-11-25)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* (deps) bump @sasjs/core to 4.59.7 ([ab96653](https://github.com/sasjs/server/commit/ab966535642d08d4e8e984007b98c8fdffbe30f7))
|
|
||||||
* (deps) rerun npm i to sync ([225f381](https://github.com/sasjs/server/commit/225f381bdf8ad5aa2af8d75648df1dd5175e12e0))
|
|
||||||
|
|
||||||
## [0.39.2](https://github.com/sasjs/server/compare/v0.39.1...v0.39.2) (2025-09-25)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* addressing test fail ([e51b204](https://github.com/sasjs/server/commit/e51b20421adc1598ea267c79b1fb4dbc085f97b9))
|
|
||||||
* packages missmatch ([379ea60](https://github.com/sasjs/server/commit/379ea604bcb5686b5299fae6a32f759c45b275ea))
|
|
||||||
* type libs ([6d123c3](https://github.com/sasjs/server/commit/6d123c3e23628c1d703eaa13142c77f0da970a55))
|
|
||||||
* typescript errors ([631e956](https://github.com/sasjs/server/commit/631e95604b64b1a96f2abade659348618f3b00b2))
|
|
||||||
* typescript errors ([198cd79](https://github.com/sasjs/server/commit/198cd79354254511c21ac1acfbf7b6bcfdab2af7))
|
|
||||||
|
|
||||||
## [0.39.1](https://github.com/sasjs/server/compare/v0.39.0...v0.39.1) (2025-03-13)
|
## [0.39.1](https://github.com/sasjs/server/compare/v0.39.0...v0.39.1) (2025-03-13)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Code execution & session diagrams
|
|
||||||
|
|
||||||
Mermaid diagrams describing how `@sasjs/server` executes submitted code
|
|
||||||
(SAS/JS/PY/R) against pooled "sessions". Written for fast context-loading by
|
|
||||||
an AI coding agent: each diagram is self-contained, node/edge labels carry
|
|
||||||
the actual file:line references, and prose is kept to the minimum needed to
|
|
||||||
disambiguate the diagram.
|
|
||||||
|
|
||||||
| File | Covers |
|
|
||||||
|---|---|
|
|
||||||
| [session-lifecycle.md](session-lifecycle.md) | `SessionState` state machine; how it differs between the SAS runtime and the JS/PY/R runtimes |
|
|
||||||
| [sas-execution-handshake.md](sas-execution-handshake.md) | The SYSIN/AUTOEXEC file-swap mechanism a SAS session uses to turn one long-lived `sas` process into a single-use execution slot |
|
|
||||||
| [request-execution-flow.md](request-execution-flow.md) | End-to-end flowchart from HTTP request to response, covering session-pool acquisition and both runtime branches (SAS vs JS/PY/R) |
|
|
||||||
|
|
||||||
## Core concept
|
|
||||||
|
|
||||||
A "session" is not a request-scoped object. It is a pooled, reusable
|
|
||||||
execution slot with a filesystem folder (`session.path`) and a state
|
|
||||||
(`SessionState`). For the SAS runtime specifically, a session also owns one
|
|
||||||
real OS process, spawned at session-*creation* time and consumed by exactly
|
|
||||||
one code submission (see `sas-execution-handshake.md`). For JS/PY/R, a
|
|
||||||
session is just an ID + folder; the actual interpreter process is spawned
|
|
||||||
fresh per request inside `processProgram`.
|
|
||||||
|
|
||||||
## Key source files
|
|
||||||
|
|
||||||
- `api/src/controllers/internal/Session.ts` — session pool + lifecycle
|
|
||||||
(`SessionController`, `SASSessionController`), `SessionState` transitions.
|
|
||||||
- `api/src/controllers/internal/processProgram.ts` — writes the submitted
|
|
||||||
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.
|
|
||||||
- `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).
|
|
||||||
- `api/src/types/Session.ts` — `SessionState` enum and `Session` interface.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Request execution flow (end-to-end)
|
|
||||||
|
|
||||||
Full path from an inbound HTTP request to the HTTP response, covering
|
|
||||||
session-pool acquisition and the two structurally different runtime
|
|
||||||
branches: SAS (reuses a parked long-lived process) vs JS/PY/R (spawns a
|
|
||||||
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"]
|
|
||||||
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"]
|
|
||||||
E --> G{"pending session<br/>available in pool?"}
|
|
||||||
G -- yes --> H["reuse it"]
|
|
||||||
G -- no --> I["createSession()<br/>SAS: spawns a real sas process, see<br/>sas-execution-handshake.md<br/>JS/PY/R: cheap - folder + id only"]
|
|
||||||
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"]
|
|
||||||
|
|
||||||
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"]
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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)"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- **Session pool is per-runtime.** SAS sessions and JS/PY/R sessions live in
|
|
||||||
separate controllers/pools (`process.sasSessionController` vs
|
|
||||||
`process.sessionController`, `Session.ts:239-253`) - a pending SAS session
|
|
||||||
is never handed out for a JS request or vice versa.
|
|
||||||
- **The SAS branch and the JS/PY/R branch converge** on the same
|
|
||||||
success/failure signal shape: `session.state` becomes `completed` or
|
|
||||||
`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`.
|
|
||||||
- **`triggerProgram`/`triggerCode`** (fire-and-forget variants, not shown)
|
|
||||||
call the same `ExecutionController` methods without awaiting them and
|
|
||||||
immediately return `{ sessionId }`; the client polls
|
|
||||||
`GET /SASjsApi/session/{sessionId}/state` separately.
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# SAS session execution handshake
|
|
||||||
|
|
||||||
How one long-lived `sas` process is turned into a single-use execution slot
|
|
||||||
via a SYSIN file-swap trick. This is the mechanism specific to
|
|
||||||
`RunTimeType.SAS`; JS/PY/R sessions spawn a fresh interpreter process per
|
|
||||||
request instead (see `request-execution-flow.md`).
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Client as HTTP client
|
|
||||||
participant Ctrl as Controller<br/>(code.ts / stp.ts)
|
|
||||||
participant Exec as ExecutionController<br/>(Execution.ts)
|
|
||||||
participant Pool as SASSessionController<br/>(Session.ts)
|
|
||||||
participant FS as Filesystem<br/>(session.path)
|
|
||||||
participant SAS as spawned "sas" process
|
|
||||||
|
|
||||||
Note over Pool,SAS: SESSION CREATION - happens ahead of any request,<br/>pool pre-warms up to 3 sessions (Session.ts:59-69)
|
|
||||||
Pool->>FS: createFile(code.sas, "") - empty dummy SYSIN (Session.ts:117-118)
|
|
||||||
Pool->>FS: readFile(sysInitCompiledPath) - compiled system-init<br/>macros, produced by `npm run compileSysInit` (Session.ts:105)
|
|
||||||
Note over Pool: throws if this file doesn't exist yet -<br/>session creation never reaches execFile below
|
|
||||||
Pool->>FS: createFile(autoexec.sas, autoExecContent) (Session.ts:108-114)
|
|
||||||
Pool->>SAS: execFile(sasLoc, -SYSIN code.sas -AUTOEXEC autoexec.sas -LOG log.log ...) (Session.ts:127-147)
|
|
||||||
activate SAS
|
|
||||||
Note right of SAS: process stays active (this box)<br/>from spawn until it finally exits below
|
|
||||||
SAS->>FS: autoexec step 1: delete code.sas, the dummy SYSIN (Session.ts:257-261)
|
|
||||||
Pool->>FS: waitForSession() polls fileExists(code.sas) (Session.ts:175-192)
|
|
||||||
FS-->>Pool: code.sas no longer exists
|
|
||||||
Pool->>Pool: session.state = pending (Session.ts:190)
|
|
||||||
SAS->>SAS: autoexec step 2: busy-wait for code.sas to reappear,<br/>up to 15 minutes (Session.ts:263-274)
|
|
||||||
Note over SAS: process is now idle, parked mid-autoexec,<br/>waiting on the filesystem.<br/>Counts as "pending" in the pool.
|
|
||||||
|
|
||||||
Note over Client,SAS: REQUEST ARRIVES - reuses the parked process above
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
|
|
||||||
FS-->>SAS: code.sas exists again
|
|
||||||
SAS->>SAS: autoexec step 2 loop exits, sleeps 0.01s, autoexec ends (Session.ts:267-271)
|
|
||||||
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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
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()
|
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why this design
|
|
||||||
|
|
||||||
- SAS has meaningful startup cost (loading the engine, running system init
|
|
||||||
macros). Spawning a fresh `sas` process per request would pay that cost
|
|
||||||
every time. Instead, a process is spawned once and parked, ready to run
|
|
||||||
exactly one job the moment code shows up at a path it's already watching.
|
|
||||||
- The pool (`SessionController.getSession`, `Session.ts:59-69`) keeps up to
|
|
||||||
3 such parked processes ready, so most requests get an instant handoff
|
|
||||||
instead of waiting through the ~15-minute autoexec spin-wait or a cold
|
|
||||||
start.
|
|
||||||
- A session is single-use: once its process consumes the real SYSIN content
|
|
||||||
and exits (success or failure), that process is gone. The session object
|
|
||||||
itself is deleted later by `scheduleSessionDestroy` (`Session.ts:204-236`),
|
|
||||||
not reused for a second job.
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Session lifecycle (`SessionState`)
|
|
||||||
|
|
||||||
Enum defined in `api/src/types/Session.ts`. A `Session` is `{ id, state,
|
|
||||||
path, creationTimeStamp, deathTimeStamp, expiresAfterMins?, failureReason? }`.
|
|
||||||
Sessions live in an in-memory array on a per-runtime singleton controller
|
|
||||||
(`process.sasSessionController` for SAS, `process.sessionController` shared
|
|
||||||
by JS/PY/R — see `getSessionController()` in `Session.ts:239-253`).
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> initialising: SAS runtime<br/>SASSessionController.createSession()<br/>Session.ts:77-95
|
|
||||||
[*] --> pending: JS/PY/R runtime, no process yet<br/>SessionController.createSession()<br/>Session.ts:30-57
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
completed --> [*]: deleteSession()<br/>scheduleSessionDestroy(), Session.ts:194-236
|
|
||||||
failed --> [*]: deleteSession()<br/>scheduleSessionDestroy()
|
|
||||||
|
|
||||||
note right of initialising
|
|
||||||
SAS only. The real sas
|
|
||||||
executable is already
|
|
||||||
running, spin-waiting
|
|
||||||
inside its AUTOEXEC for
|
|
||||||
real code to arrive.
|
|
||||||
See sas-execution-handshake.md
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of pending
|
|
||||||
Session sits in the pool,
|
|
||||||
reusable. Pre-warmed up to
|
|
||||||
3 pending sessions per
|
|
||||||
runtime (getSession(),
|
|
||||||
Session.ts:59-69)
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of running
|
|
||||||
SAS: no new process spawned
|
|
||||||
here, request just feeds
|
|
||||||
code to the already-running
|
|
||||||
process.
|
|
||||||
JS/PY/R: a brand new
|
|
||||||
interpreter process is
|
|
||||||
spawned right now.
|
|
||||||
end note
|
|
||||||
```
|
|
||||||
|
|
||||||
## Consumers of `state`
|
|
||||||
|
|
||||||
| 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. |
|
|
||||||
| `scheduleSessionDestroy` | `Session.ts:204-236` | `running` (extends death timer instead of destroying) |
|
|
||||||
|
|
||||||
## Asymmetry between runtimes
|
|
||||||
|
|
||||||
- **SAS**: one OS process per session, spawned once at `createSession()`
|
|
||||||
time and reused for exactly one job (see `sas-execution-handshake.md`).
|
|
||||||
`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
|
|
||||||
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.
|
|
||||||
Generated
+14725
-4996
File diff suppressed because it is too large
Load Diff
+25
-24
@@ -6,9 +6,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore && npm run downloadMacros",
|
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore && npm run downloadMacros",
|
||||||
"prestart": "npm run initial",
|
"prestart": "npm run initial",
|
||||||
|
"prebuild": "npm run initial",
|
||||||
"start": "NODE_ENV=development nodemon ./src/server.ts",
|
"start": "NODE_ENV=development nodemon ./src/server.ts",
|
||||||
"start:prod": "node ./build/src/server.js",
|
"start:prod": "node ./build/src/server.js",
|
||||||
"build": "npm run initial && rimraf build && tsc && npm run copy:files",
|
"build": "rimraf build && tsc",
|
||||||
|
"postbuild": "npm run copy:files",
|
||||||
"swagger": "tsoa spec",
|
"swagger": "tsoa spec",
|
||||||
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
|
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
|
||||||
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage",
|
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage",
|
||||||
@@ -16,10 +18,10 @@
|
|||||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||||
"exe": "npm run build && pkg .",
|
"exe": "npm run build && pkg .",
|
||||||
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
|
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
|
||||||
"public:copy": "cpr ./public/ ./build/public/",
|
"public:copy": "cp -r ./public/ ./build/public/",
|
||||||
"sasjsbuild:copy": "cpr ./sasjsbuild/ ./build/sasjsbuild/",
|
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
|
||||||
"sas:copy": "cpr ./sas/ ./build/sas/",
|
"sas:copy": "cp -r ./sas/ ./build/sas/",
|
||||||
"web:copy": "rimraf web && mkdir web && cpr ../web/build/ ./web/build/",
|
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
|
||||||
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
|
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
|
||||||
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
|
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
|
||||||
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
|
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
|
||||||
@@ -46,25 +48,25 @@
|
|||||||
},
|
},
|
||||||
"author": "4GL Ltd",
|
"author": "4GL Ltd",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/core": "^4.59.10",
|
"@sasjs/core": "^4.40.1",
|
||||||
"@sasjs/utils": "^3.5.6",
|
"@sasjs/utils": "3.2.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"connect-mongo": "^5.1.0",
|
"connect-mongo": "^4.6.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.17.1",
|
||||||
"express-session": "^1.18.2",
|
"express-session": "^1.17.2",
|
||||||
"helmet": "^5.0.2",
|
"helmet": "^5.0.2",
|
||||||
"joi": "^17.4.2",
|
"joi": "^17.4.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"ldapjs": "2.3.3",
|
"ldapjs": "2.3.3",
|
||||||
"mongoose": "^6.13.8",
|
"mongoose": "^6.0.12",
|
||||||
"morgan": "^1.10.1",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"rate-limiter-flexible": "2.4.1",
|
"rate-limiter-flexible": "2.4.1",
|
||||||
"rotating-file-stream": "^3.0.4",
|
"rotating-file-stream": "^3.0.4",
|
||||||
"swagger-ui-express": "4.3.0",
|
"swagger-ui-express": "4.3.0",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.10.11",
|
||||||
"url": "^0.10.3"
|
"url": "^0.10.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -74,33 +76,32 @@
|
|||||||
"@types/cors": "^2.8.12",
|
"@types/cors": "^2.8.12",
|
||||||
"@types/express": "^4.17.12",
|
"@types/express": "^4.17.12",
|
||||||
"@types/express-session": "^1.17.4",
|
"@types/express-session": "^1.17.4",
|
||||||
"@types/jest": "^29.5.0",
|
"@types/jest": "^26.0.24",
|
||||||
"@types/jsonwebtoken": "^8.5.5",
|
"@types/jsonwebtoken": "^8.5.5",
|
||||||
"@types/ldapjs": "^2.2.4",
|
"@types/ldapjs": "^2.2.4",
|
||||||
"@types/morgan": "^1.9.3",
|
"@types/morgan": "^1.9.3",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^15.12.2",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
"@types/swagger-ui-express": "^4.1.3",
|
"@types/swagger-ui-express": "^4.1.3",
|
||||||
"@types/unzipper": "^0.10.5",
|
"@types/unzipper": "^0.10.5",
|
||||||
"adm-zip": "^0.5.9",
|
"adm-zip": "^0.5.9",
|
||||||
"axios": "1.12.2",
|
"axios": "^1.12.2",
|
||||||
"cpr": "^3.0.1",
|
|
||||||
"csrf": "^3.1.0",
|
"csrf": "^3.1.0",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.0.1",
|
||||||
"http-headers-validation": "^0.0.1",
|
"http-headers-validation": "^0.0.1",
|
||||||
"jest": "^29.7.0",
|
"jest": "^27.0.6",
|
||||||
"mongodb-memory-server": "8.11.4",
|
"mongodb-memory-server": "8.11.4",
|
||||||
"nodejs-file-downloader": "4.10.2",
|
"nodejs-file-downloader": "4.10.2",
|
||||||
"nodemon": "^3.0.0",
|
"nodemon": "^2.0.7",
|
||||||
"pkg": "5.6.0",
|
"pkg": "5.6.0",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^2.3.1",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"supertest": "^6.1.3",
|
"supertest": "^6.1.3",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "^27.0.3",
|
||||||
"ts-node": "^10.0.0",
|
"ts-node": "^10.0.0",
|
||||||
"tsoa": "3.14.1",
|
"tsoa": "3.14.1",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^4.3.2"
|
||||||
},
|
},
|
||||||
"nodemonConfig": {
|
"nodemonConfig": {
|
||||||
"ignore": [
|
"ignore": [
|
||||||
|
|||||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -100,13 +100,8 @@ const token = async (data: any): Promise<TokenResponse> => {
|
|||||||
|
|
||||||
AuthController.deleteCode(userInfo.userId, clientId)
|
AuthController.deleteCode(userInfo.userId, clientId)
|
||||||
|
|
||||||
// Re-exchanging a code for the same user/client while a still-valid token
|
// get tokens from DB
|
||||||
// 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).
|
|
||||||
const existingTokens = await getTokensFromDB(userInfo.userId, clientId)
|
const existingTokens = await getTokensFromDB(userInfo.userId, clientId)
|
||||||
|
|
||||||
if (existingTokens) {
|
if (existingTokens) {
|
||||||
return {
|
return {
|
||||||
accessToken: existingTokens.accessToken,
|
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 })
|
const client = await Client.findOne({ clientId })
|
||||||
|
|
||||||
if (!client) throw new Error('Invalid clientId.')
|
if (!client) throw new Error('Invalid clientId.')
|
||||||
|
|
||||||
const accessToken = generateAccessToken(
|
const accessToken = generateAccessToken(
|
||||||
@@ -244,10 +234,9 @@ const verifyAuthCode = async (
|
|||||||
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
|
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
|
||||||
if (err) return resolve(undefined)
|
if (err) return resolve(undefined)
|
||||||
|
|
||||||
const payload = data as InfoJWT
|
|
||||||
const clientInfo: InfoJWT = {
|
const clientInfo: InfoJWT = {
|
||||||
clientId: payload?.clientId,
|
clientId: data?.clientId,
|
||||||
userId: payload?.userId
|
userId: data?.userId
|
||||||
}
|
}
|
||||||
if (clientInfo.clientId === clientId) {
|
if (clientInfo.clientId === clientId) {
|
||||||
return resolve(clientInfo)
|
return resolve(clientInfo)
|
||||||
|
|||||||
@@ -88,11 +88,6 @@ export class ExecutionController {
|
|||||||
preProgramVariables?.httpHeaders.join('\n') ?? ''
|
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(
|
await processProgram(
|
||||||
program,
|
program,
|
||||||
preProgramVariables,
|
preProgramVariables,
|
||||||
@@ -125,16 +120,7 @@ export class ExecutionController {
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
// it should be deleted by scheduleSessionDestroy
|
// it should be deleted by scheduleSessionDestroy
|
||||||
//
|
|
||||||
// Guarded: for JS/PY/R, processProgram sets state to `failed` itself
|
|
||||||
// (without throwing) when the interpreter process exits non-zero - if
|
|
||||||
// we unconditionally set `completed` here we'd silently overwrite that,
|
|
||||||
// and anything downstream inspecting session.state (e.g.
|
|
||||||
// scheduleSessionDestroy's expiresAfterMins branch in Session.ts) would
|
|
||||||
// see a crashed session mis-reported as successful.
|
|
||||||
if ((session.state as SessionState) !== SessionState.failed) {
|
|
||||||
session.state = SessionState.completed
|
session.state = SessionState.completed
|
||||||
}
|
|
||||||
|
|
||||||
const resultParts = []
|
const resultParts = []
|
||||||
|
|
||||||
|
|||||||
@@ -48,17 +48,8 @@ export const processProgram = async (
|
|||||||
await createFile(codePath + '.bkp', program)
|
await createFile(codePath + '.bkp', program)
|
||||||
await moveFile(codePath + '.bkp', codePath)
|
await moveFile(codePath + '.bkp', codePath)
|
||||||
|
|
||||||
// we now need to poll the session status. A failed session (e.g. from
|
// we now need to poll the session status
|
||||||
// %abort;) is not a request-shape/server problem - it's a normal
|
while (session.state !== SessionState.completed) {
|
||||||
// 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
|
|
||||||
) {
|
|
||||||
await delay(50)
|
await delay(50)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
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 { Session, SessionState, PreProgramVars } from '../../../types'
|
|
||||||
import { RunTimeType } from '../../../utils'
|
|
||||||
|
|
||||||
const preProgramVariables: PreProgramVars = {
|
|
||||||
username: 'testUser',
|
|
||||||
userId: 1,
|
|
||||||
displayName: 'Test User',
|
|
||||||
serverUrl: 'http://localhost:5000',
|
|
||||||
httpHeaders: []
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ExecutionController.executeProgram', () => {
|
|
||||||
let session: Session
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const sessionId = `test-session-${generateTimestamp()}`
|
|
||||||
session = {
|
|
||||||
id: sessionId,
|
|
||||||
state: SessionState.pending,
|
|
||||||
creationTimeStamp: '0',
|
|
||||||
deathTimeStamp: '0',
|
|
||||||
path: path.join(os.tmpdir(), sessionId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
jest.restoreAllMocks()
|
|
||||||
await deleteFolder(session.path)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Regression coverage: for JS/PY/R, processProgram sets session.state to
|
|
||||||
// `failed` itself (without throwing) on a non-zero interpreter exit.
|
|
||||||
// ExecutionController.executeProgram used to unconditionally overwrite
|
|
||||||
// that back to `completed` right after processProgram returned, silently
|
|
||||||
// losing the failure for anything downstream that inspects session.state.
|
|
||||||
describe('JS/PY/R failure path', () => {
|
|
||||||
it('does not overwrite a failed session state back to completed', async () => {
|
|
||||||
// mirrors processProgram's real JS/PY/R branch on a non-zero exit:
|
|
||||||
// it sets state/failureReason itself and resolves - it does not throw
|
|
||||||
jest
|
|
||||||
.spyOn(ProcessProgramModule, 'processProgram')
|
|
||||||
.mockImplementation(async () => {
|
|
||||||
session.state = SessionState.failed
|
|
||||||
session.failureReason = 'Error: process exited with code 1'
|
|
||||||
})
|
|
||||||
|
|
||||||
const controller = new ExecutionController()
|
|
||||||
|
|
||||||
await controller.executeProgram({
|
|
||||||
program: 'throw new Error("boom")',
|
|
||||||
preProgramVariables,
|
|
||||||
vars: {},
|
|
||||||
session,
|
|
||||||
runTime: RunTimeType.JS
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(session.state).toBe(SessionState.failed)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('still marks a genuinely successful session as completed', async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(ProcessProgramModule, 'processProgram')
|
|
||||||
.mockImplementation(async () => {
|
|
||||||
session.state = SessionState.completed
|
|
||||||
})
|
|
||||||
|
|
||||||
const controller = new ExecutionController()
|
|
||||||
|
|
||||||
await controller.executeProgram({
|
|
||||||
program: 'console.log("hello")',
|
|
||||||
preProgramVariables,
|
|
||||||
vars: {},
|
|
||||||
session,
|
|
||||||
runTime: RunTimeType.JS
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(session.state).toBe(SessionState.completed)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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 () => {
|
|
||||||
const logPath = path.join(session.path, 'log.log')
|
|
||||||
const logContent =
|
|
||||||
'NOTE: SAS session\nERROR: SAS session terminated. See log for details.\n'
|
|
||||||
|
|
||||||
await createFile(logPath, logContent)
|
|
||||||
|
|
||||||
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.'
|
|
||||||
})
|
|
||||||
|
|
||||||
const controller = new ExecutionController()
|
|
||||||
|
|
||||||
const { result } = await controller.executeProgram({
|
|
||||||
program: '%abort;',
|
|
||||||
preProgramVariables,
|
|
||||||
vars: {},
|
|
||||||
session,
|
|
||||||
runTime: RunTimeType.SAS
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(session.state).toBe(SessionState.failed)
|
|
||||||
expect(result).toEqual(expect.stringContaining(logContent))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import path from 'path'
|
|
||||||
import os from 'os'
|
|
||||||
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
|
|
||||||
import { processProgram } from '../processProgram'
|
|
||||||
import { Session, SessionState, PreProgramVars } from '../../../types'
|
|
||||||
import {
|
|
||||||
RunTimeType,
|
|
||||||
getSessionsFolder,
|
|
||||||
generateUniqueFileName
|
|
||||||
} from '../../../utils'
|
|
||||||
|
|
||||||
const preProgramVariables: PreProgramVars = {
|
|
||||||
username: 'testUser',
|
|
||||||
userId: 1,
|
|
||||||
displayName: 'Test User',
|
|
||||||
serverUrl: 'http://localhost:5000',
|
|
||||||
httpHeaders: []
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeSession = (): Session => {
|
|
||||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
|
||||||
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
|
||||||
const creationTimeStamp = sessionId.split('-').pop() as string
|
|
||||||
const deathTimeStamp = (
|
|
||||||
parseInt(creationTimeStamp) +
|
|
||||||
15 * 60 * 1000 -
|
|
||||||
1000
|
|
||||||
).toString()
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: sessionId,
|
|
||||||
state: SessionState.running,
|
|
||||||
creationTimeStamp,
|
|
||||||
deathTimeStamp,
|
|
||||||
path: sessionFolder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('processProgram (SAS runtime)', () => {
|
|
||||||
let session: Session
|
|
||||||
let logPath: string
|
|
||||||
let weboutPath: string
|
|
||||||
let headersPath: string
|
|
||||||
let tokenFile: string
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
const root = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`sasjs-processProgram-spec-${generateTimestamp()}`
|
|
||||||
)
|
|
||||||
process.sasjsRoot = root
|
|
||||||
process.driveLoc = path.join(root, 'drive')
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
session = makeSession()
|
|
||||||
logPath = path.join(session.path, 'log.log')
|
|
||||||
weboutPath = path.join(session.path, 'webout.txt')
|
|
||||||
headersPath = path.join(session.path, 'stpsrv_header.txt')
|
|
||||||
tokenFile = path.join(session.path, 'reqHeaders.txt')
|
|
||||||
await createFile(weboutPath, '')
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
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.
|
|
||||||
setTimeout(() => {
|
|
||||||
session.state = SessionState.failed
|
|
||||||
session.failureReason =
|
|
||||||
'ERROR: SAS session terminated. See log for details.'
|
|
||||||
}, 100)
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
processProgram(
|
|
||||||
'%abort;',
|
|
||||||
preProgramVariables,
|
|
||||||
{},
|
|
||||||
session,
|
|
||||||
weboutPath,
|
|
||||||
headersPath,
|
|
||||||
tokenFile,
|
|
||||||
RunTimeType.SAS,
|
|
||||||
logPath
|
|
||||||
)
|
|
||||||
).resolves.toBeUndefined()
|
|
||||||
}, 3000)
|
|
||||||
|
|
||||||
it('resolves without throwing when the session completes normally', async () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
session.state = SessionState.completed
|
|
||||||
}, 100)
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
processProgram(
|
|
||||||
'%put hello world;',
|
|
||||||
preProgramVariables,
|
|
||||||
{},
|
|
||||||
session,
|
|
||||||
weboutPath,
|
|
||||||
headersPath,
|
|
||||||
tokenFile,
|
|
||||||
RunTimeType.SAS,
|
|
||||||
logPath
|
|
||||||
)
|
|
||||||
).resolves.toBeUndefined()
|
|
||||||
}, 3000)
|
|
||||||
})
|
|
||||||
@@ -106,10 +106,7 @@ const login = async (
|
|||||||
const rateLimiter = RateLimiter.getInstance()
|
const rateLimiter = RateLimiter.getInstance()
|
||||||
|
|
||||||
if (!validPass) {
|
if (!validPass) {
|
||||||
const retrySecs = await rateLimiter.consume(
|
const retrySecs = await rateLimiter.consume(req.ip, user?.username)
|
||||||
req.ip || 'unknown',
|
|
||||||
user?.username
|
|
||||||
)
|
|
||||||
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
|
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +114,7 @@ const login = async (
|
|||||||
if (!validPass) throw errors.invalidPassword
|
if (!validPass) throw errors.invalidPassword
|
||||||
|
|
||||||
// Reset on successful authorization
|
// Reset on successful authorization
|
||||||
rateLimiter.resetOnSuccess(req.ip || 'unknown', user.username)
|
rateLimiter.resetOnSuccess(req.ip, user.username)
|
||||||
|
|
||||||
req.session.loggedIn = true
|
req.session.loggedIn = true
|
||||||
req.session.user = {
|
req.session.user = {
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ export const authenticateAccessToken: RequestHandler = async (
|
|||||||
if (user.isActive) {
|
if (user.isActive) {
|
||||||
req.user = user
|
req.user = user
|
||||||
return csrfProtection(req, res, nextFunction)
|
return csrfProtection(req, res, nextFunction)
|
||||||
} else return res.status(401).send('Unauthorized')
|
} else return res.sendStatus(401)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res.status(401).send('Unauthorized')
|
return res.sendStatus(401)
|
||||||
}
|
}
|
||||||
|
|
||||||
await authenticateToken(
|
await authenticateToken(
|
||||||
@@ -113,14 +113,11 @@ const authenticateToken = async (
|
|||||||
|
|
||||||
throw 'Unauthorized'
|
throw 'Unauthorized'
|
||||||
} catch (error) {
|
} 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)) {
|
if (await isPublicRoute(req)) {
|
||||||
req.user = publicUser
|
req.user = publicUser
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).send('Unauthorized')
|
res.sendStatus(401)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { convertSecondsToHms } from '@sasjs/utils'
|
|||||||
import { RateLimiter } from '../utils'
|
import { RateLimiter } from '../utils'
|
||||||
|
|
||||||
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
|
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
|
||||||
const ip = req.ip || 'unknown'
|
const ip = req.ip
|
||||||
const username = req.body.username
|
const username = req.body.username
|
||||||
|
|
||||||
const rateLimiter = RateLimiter.getInstance()
|
const rateLimiter = RateLimiter.getInstance()
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ import { ModeType } from '../utils'
|
|||||||
|
|
||||||
const regexUser = /^\/SASjsApi\/user\/[0-9]*$/ // /SASjsApi/user/1
|
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[] } = {
|
const allowedInDesktopMode: { [key: string]: RegExp[] } = {
|
||||||
GET: [regexUser],
|
GET: [regexUser],
|
||||||
PATCH: [regexUser]
|
PATCH: [regexUser]
|
||||||
|
|||||||
@@ -5,16 +5,8 @@ import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
|||||||
|
|
||||||
const clientRouter = express.Router()
|
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) => {
|
clientRouter.post('/', async (req, res) => {
|
||||||
const { error, value: body } = registerClientValidation(req.body)
|
const { error, value: body } = registerClientValidation(req.body)
|
||||||
|
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
const controller = new ClientController()
|
const controller = new ClientController()
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import path from 'path'
|
|
||||||
import { createFile, fileExists } from '@sasjs/utils'
|
|
||||||
import { Express } from 'express'
|
|
||||||
import mongoose, { Mongoose } from 'mongoose'
|
|
||||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
|
||||||
import request from 'supertest'
|
|
||||||
import {
|
|
||||||
UserController,
|
|
||||||
PermissionController,
|
|
||||||
PermissionType,
|
|
||||||
PermissionSettingForRoute,
|
|
||||||
PrincipalType
|
|
||||||
} from '../../../controllers/'
|
|
||||||
import {
|
|
||||||
generateAccessToken,
|
|
||||||
saveTokensInDB,
|
|
||||||
RunTimeType,
|
|
||||||
sysInitCompiledPath
|
|
||||||
} from '../../../utils'
|
|
||||||
|
|
||||||
// Real, unmocked end-to-end test of the SAS execution pipeline (session
|
|
||||||
// spawn -> autoexec handshake -> exit code -> HTTP response), using a fake
|
|
||||||
// "SAS executable" (mockSas.js) in place of a real SAS install. This is the
|
|
||||||
// regression test for issue #388: SAS code that aborts (%abort;) used to
|
|
||||||
// hang the request forever instead of returning an error response.
|
|
||||||
const mockSasPath = path.join(__dirname, 'files', 'mockSas.js')
|
|
||||||
|
|
||||||
const clientId = 'codeSpecClientID'
|
|
||||||
|
|
||||||
const user = {
|
|
||||||
displayName: 'Code Spec User',
|
|
||||||
username: 'codeSpecUsername',
|
|
||||||
password: '87654321',
|
|
||||||
isAdmin: false,
|
|
||||||
isActive: true
|
|
||||||
}
|
|
||||||
|
|
||||||
let app: Express
|
|
||||||
let accessToken: string
|
|
||||||
|
|
||||||
describe('code', () => {
|
|
||||||
let con: Mongoose
|
|
||||||
let mongoServer: MongoMemoryServer
|
|
||||||
let userController: UserController
|
|
||||||
let permissionController: PermissionController
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// SASSessionController.createSession() unconditionally reads this file
|
|
||||||
// (Session.ts:105) before it ever spawns the SAS process. It's normally
|
|
||||||
// produced by `npm run compileSysInit` (part of the `initial`/`build`
|
|
||||||
// scripts), which `npm test` does not run - so on a fresh checkout
|
|
||||||
// (e.g. CI, where "Run Unit Tests" happens before "Build Package") it
|
|
||||||
// doesn't exist yet. Content is irrelevant here since mockSas.js never
|
|
||||||
// interprets it; it just needs to exist and be readable.
|
|
||||||
if (!(await fileExists(sysInitCompiledPath))) {
|
|
||||||
await createFile(sysInitCompiledPath, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
mongoServer = await MongoMemoryServer.create()
|
|
||||||
process.env.DB_CONNECT = mongoServer.getUri()
|
|
||||||
process.env.MODE = 'server'
|
|
||||||
process.env.RUN_TIMES = 'sas'
|
|
||||||
process.env.SAS_PATH = mockSasPath
|
|
||||||
|
|
||||||
const appPromise = (await import('../../../app')).default
|
|
||||||
app = await appPromise
|
|
||||||
|
|
||||||
con = await mongoose.connect(mongoServer.getUri())
|
|
||||||
|
|
||||||
userController = new UserController()
|
|
||||||
permissionController = new PermissionController()
|
|
||||||
|
|
||||||
const dbUser = await userController.createUser(user)
|
|
||||||
accessToken = await generateAndSaveToken(dbUser.id)
|
|
||||||
|
|
||||||
await permissionController.createPermission({
|
|
||||||
path: '/SASjsApi/code/execute',
|
|
||||||
type: PermissionType.route,
|
|
||||||
principalType: PrincipalType.user,
|
|
||||||
principalId: dbUser.id,
|
|
||||||
setting: PermissionSettingForRoute.grant
|
|
||||||
})
|
|
||||||
|
|
||||||
process.runTimes = [RunTimeType.SAS]
|
|
||||||
process.sasLoc = mockSasPath
|
|
||||||
}, 30000)
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await con.connection.dropDatabase()
|
|
||||||
await con.connection.close()
|
|
||||||
await mongoServer.stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('execute', () => {
|
|
||||||
it('returns 200 with the mock log when the SAS program completes normally', async () => {
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/SASjsApi/code/execute')
|
|
||||||
.auth(accessToken, { type: 'bearer' })
|
|
||||||
.send({ code: '%put hello world;', runTime: 'sas' })
|
|
||||||
.expect(200)
|
|
||||||
|
|
||||||
expect(response.text).toEqual(
|
|
||||||
expect.stringContaining('mock SAS execution')
|
|
||||||
)
|
|
||||||
}, 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 () => {
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/SASjsApi/code/execute')
|
|
||||||
.auth(accessToken, { type: 'bearer' })
|
|
||||||
.send({ code: '%abort;', runTime: 'sas' })
|
|
||||||
.expect(200)
|
|
||||||
|
|
||||||
expect(response.text).toEqual(
|
|
||||||
expect.stringContaining('mock SAS execution')
|
|
||||||
)
|
|
||||||
expect(response.text).toEqual(
|
|
||||||
expect.stringContaining('SAS session terminated')
|
|
||||||
)
|
|
||||||
}, 30000)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const generateAndSaveToken = async (userId: number) => {
|
|
||||||
const accessToken = generateAccessToken({
|
|
||||||
clientId,
|
|
||||||
userId
|
|
||||||
})
|
|
||||||
await saveTokensInDB(userId, clientId, accessToken, 'refreshToken')
|
|
||||||
return accessToken
|
|
||||||
}
|
|
||||||
@@ -28,15 +28,6 @@ jest
|
|||||||
.spyOn(fileUtilModules, 'getUploadsFolder')
|
.spyOn(fileUtilModules, 'getUploadsFolder')
|
||||||
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
|
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
|
||||||
|
|
||||||
// getFilesFolder() resolves via getSasjsDriveFolder()/process.driveLoc, a
|
|
||||||
// separate root from getSasjsRootFolder()/process.sasjsRoot above - without
|
|
||||||
// this, every test in this file that creates content under the drive (e.g.
|
|
||||||
// 'level1', 'my/path/...') writes into the real, shared sasjs_root/drive
|
|
||||||
// instead of this run's isolated tmpFolder, leaking state into later runs.
|
|
||||||
jest
|
|
||||||
.spyOn(fileUtilModules, 'getFilesFolder')
|
|
||||||
.mockImplementation(() => path.join(tmpFolder, 'drive', 'files'))
|
|
||||||
|
|
||||||
import appPromise from '../../../app'
|
import appPromise from '../../../app'
|
||||||
import {
|
import {
|
||||||
UserController,
|
UserController,
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
// Minimal fake "SAS executable" used by tests in place of a real SAS
|
|
||||||
// install. It only fulfils the CLI/filesystem handshake contract that
|
|
||||||
// Session.ts / processProgram.ts rely on:
|
|
||||||
//
|
|
||||||
// -SYSIN <path> the file used as a signal channel: it starts out
|
|
||||||
// containing a dummy (empty) placeholder, we delete it
|
|
||||||
// to signal "session ready", then wait for it to be
|
|
||||||
// rewritten with the real submitted program.
|
|
||||||
// -LOG <path> where we write a fake log so downstream fileExists()/
|
|
||||||
// readFile() calls have something to find.
|
|
||||||
//
|
|
||||||
// It does NOT interpret real SAS syntax. Exit code is the only thing that
|
|
||||||
// matters to the Node side: 0 mimics a normal SAS termination, non-zero
|
|
||||||
// mimics an abnormal one (e.g. %abort;).
|
|
||||||
|
|
||||||
const fs = require('fs')
|
|
||||||
|
|
||||||
const arg = (flag) => {
|
|
||||||
const idx = process.argv.indexOf(flag)
|
|
||||||
|
|
||||||
return idx === -1 ? undefined : process.argv[idx + 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
const sysin = arg('-SYSIN')
|
|
||||||
const logPath = arg('-LOG')
|
|
||||||
|
|
||||||
// Give up waiting for the real program after this long, so an unused
|
|
||||||
// pre-warmed session (sasjs pools up to 3 ready sessions) doesn't linger
|
|
||||||
// forever and keep a test process alive. Generous rather than tight: CI
|
|
||||||
// runners are frequently slower/more contended than a local dev machine
|
|
||||||
// (shared CPU, coverage instrumentation on the Node side slowing down the
|
|
||||||
// round trip this process is waiting on), and this cost is only ever paid
|
|
||||||
// by sessions nothing is actually waiting on - the session actually used
|
|
||||||
// by a request gets its real code written far sooner than this in practice.
|
|
||||||
const GIVE_UP_AFTER_MS = 8000
|
|
||||||
|
|
||||||
const sleepSync = (ms) => {
|
|
||||||
const until = Date.now() + ms
|
|
||||||
|
|
||||||
while (Date.now() < until) {
|
|
||||||
/* busy-wait, mirroring the real autoexec's SAS-side sleep() loop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any of the filesystem calls below can legitimately race against
|
|
||||||
// processProgram's own write-then-rename (e.g. existsSync sees the file,
|
|
||||||
// then a rename mid-flight makes the following read miss). Treat that as
|
|
||||||
// "not ready yet" and retry a few times, rather than letting an uncaught
|
|
||||||
// exception crash this process with a non-zero exit - which would be
|
|
||||||
// indistinguishable, to the Node side, from a genuine SAS failure.
|
|
||||||
const retry = (fn, attempts = 5, delayMs = 20) => {
|
|
||||||
for (let i = 0; i < attempts; i++) {
|
|
||||||
try {
|
|
||||||
return { ok: true, value: fn() }
|
|
||||||
} catch (err) {
|
|
||||||
if (i === attempts - 1) return { ok: false, error: err }
|
|
||||||
|
|
||||||
sleepSync(delayMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. remove the dummy SYSIN, signalling "session ready" to waitForSession()
|
|
||||||
if (sysin) {
|
|
||||||
retry(() => {
|
|
||||||
if (fs.existsSync(sysin)) fs.unlinkSync(sysin)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. wait for the real program to be written back to the same path
|
|
||||||
const deadline = Date.now() + GIVE_UP_AFTER_MS
|
|
||||||
|
|
||||||
while (!sysin || !fs.existsSync(sysin)) {
|
|
||||||
if (Date.now() > deadline) process.exit(0)
|
|
||||||
|
|
||||||
sleepSync(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// small settle delay, mirroring the real autoexec's sleep(0.01,1) after
|
|
||||||
// detecting the file, so a fast-moving rename isn't read mid-write
|
|
||||||
sleepSync(50)
|
|
||||||
|
|
||||||
const readResult = retry(() => fs.readFileSync(sysin, 'utf-8'))
|
|
||||||
|
|
||||||
if (!readResult.ok) {
|
|
||||||
process.stderr.write(
|
|
||||||
`mockSas.js: failed to read ${sysin}: ${readResult.error}\n`
|
|
||||||
)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAbort) {
|
|
||||||
process.stderr.write('ERROR: SAS session terminated. See log for details.\n')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0)
|
|
||||||
@@ -277,10 +277,7 @@ const performLogin = async (
|
|||||||
.set('x-xsrf-token', csrfToken)
|
.set('x-xsrf-token', csrfToken)
|
||||||
.send(credentials)
|
.send(credentials)
|
||||||
|
|
||||||
return {
|
return { authCookies: header['set-cookie'].join() }
|
||||||
authCookies:
|
|
||||||
(header['set-cookie'] as unknown as string[] | undefined)?.join() || ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractCSRF = (text: string) =>
|
const extractCSRF = (text: string) =>
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ import { Request } from 'express'
|
|||||||
|
|
||||||
export const TopLevelRoutes = ['/AppStream', '/SASjsApi']
|
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 = [
|
const StaticAuthorizedRoutes = [
|
||||||
'/SASjsApi/code/execute',
|
'/SASjsApi/code/execute',
|
||||||
'/SASjsApi/stp/execute',
|
'/SASjsApi/stp/execute',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import User from '../model/User'
|
import User from '../model/User'
|
||||||
import { InfoJWT } from '../types/InfoJWT'
|
|
||||||
|
|
||||||
const isValidToken = async (
|
const isValidToken = async (
|
||||||
token: string,
|
token: string,
|
||||||
@@ -12,8 +11,7 @@ const isValidToken = async (
|
|||||||
jwt.verify(token, key, (err, decoded) => {
|
jwt.verify(token, key, (err, decoded) => {
|
||||||
if (err) return reject(false)
|
if (err) return reject(false)
|
||||||
|
|
||||||
const payload = decoded as InfoJWT
|
if (decoded?.userId === userId && decoded?.clientId === clientId) {
|
||||||
if (payload?.userId === userId && payload?.clientId === clientId) {
|
|
||||||
return resolve(true)
|
return resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,6 @@ 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 (
|
export const verifyTokenInDB = async (
|
||||||
userId: number,
|
userId: number,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
|
|||||||
@@ -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.
|
|
||||||
Generated
+5661
-631
File diff suppressed because it is too large
Load Diff
Generated
+1279
-1424
File diff suppressed because it is too large
Load Diff
+5
-7
@@ -4,9 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "webpack-dev-server --config webpack.dev.ts --hot",
|
"start": "webpack-dev-server --config webpack.dev.ts --hot",
|
||||||
"build": "webpack --config webpack.prod.ts",
|
"build": "webpack --config webpack.prod.ts"
|
||||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
|
||||||
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\""
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.4.1",
|
"@emotion/react": "^11.4.1",
|
||||||
@@ -21,8 +19,9 @@
|
|||||||
"@types/jest": "^26.0.24",
|
"@types/jest": "^26.0.24",
|
||||||
"@types/node": "^12.20.28",
|
"@types/node": "^12.20.28",
|
||||||
"@types/react": "^17.0.27",
|
"@types/react": "^17.0.27",
|
||||||
"axios": "1.12.2",
|
"axios": "^1.12.2",
|
||||||
"monaco-editor": "^0.33.0",
|
"monaco-editor": "^0.33.0",
|
||||||
|
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-copy-to-clipboard": "^5.1.0",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
@@ -55,16 +54,15 @@
|
|||||||
"eslint-webpack-plugin": "^3.1.1",
|
"eslint-webpack-plugin": "^3.1.1",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"html-webpack-plugin": "5.5.0",
|
"html-webpack-plugin": "5.5.0",
|
||||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
|
||||||
"path": "0.12.7",
|
"path": "0.12.7",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^2.4.1",
|
||||||
"sass": "^1.44.0",
|
"sass": "^1.44.0",
|
||||||
"sass-loader": "^12.3.0",
|
"sass-loader": "^12.3.0",
|
||||||
"style-loader": "^3.3.1",
|
"style-loader": "^3.3.1",
|
||||||
"ts-loader": "^9.2.6",
|
"ts-loader": "^9.2.6",
|
||||||
"typescript": "^4.5.2",
|
"typescript": "^4.5.2",
|
||||||
"typescript-plugin-css-modules": "^5.0.1",
|
"typescript-plugin-css-modules": "^5.0.1",
|
||||||
"webpack": "5.64.3",
|
"webpack": "^5.101.3",
|
||||||
"webpack-cli": "^4.9.2",
|
"webpack-cli": "^4.9.2",
|
||||||
"webpack-dev-server": "4.7.4"
|
"webpack-dev-server": "4.7.4"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user