Compare commits
128 Commits
net10-port
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 737951548c | |||
| 1393907bc7 | |||
| 541d56fc05 | |||
| 7d440530b3 | |||
| 413af08918 | |||
| 36fe2eaf53 | |||
| 3c3e431306 | |||
| d4bec5b381 | |||
| 910e022016 | |||
| 393a60c57d | |||
| c52273f284 | |||
| be500d2279 | |||
| d34adeb321 | |||
| e8b3486a9e | |||
| 6d3b169f23 | |||
| 91e8f54074 | |||
| ea0a3615ee | |||
| 3b80f019ad | |||
| 2968ae7dc7 | |||
| bd8896fdb6 | |||
| ad5b797854 | |||
| 903f14ba1d | |||
| 3968302f75 | |||
| 557cca3472 | |||
| 05cf4df632 | |||
| 15094275af | |||
| d37688494a | |||
| c19a3b047f | |||
| 1dd6783aff | |||
| be29f49ec1 | |||
| c6c9e6c5d3 | |||
| ad5da2ffa5 | |||
| b9035b93b0 | |||
| 72a64159c4 | |||
| bf1e447eee | |||
| 2bcd51c21d | |||
| 4ca6c9b2e3 | |||
| 5afd804586 | |||
| aca09a8c85 | |||
| 8a2b5d4829 | |||
| 356c73d854 | |||
| 176a90d3f3 | |||
| a715c3913c | |||
| a9be1ba9e7 | |||
| a9d5cab7e5 | |||
| e5b1f1dc14 | |||
| c55b2fc723 | |||
| 64edeea874 | |||
| 1c97678498 | |||
| b3b7fa6a9d | |||
| 4aa9ee54e6 | |||
| 8f7396a021 | |||
| ba84ebf7c9 | |||
| 450acc5ef7 | |||
| 7959ad9cec | |||
| 27b56db6b0 | |||
| ed641c4985 | |||
| a53a4b6ef7 | |||
| b116034dc9 | |||
| a807693187 | |||
| 2f616cba7a | |||
| a6447ecb37 | |||
| ce5fb9e11c | |||
| e9b79242fd | |||
| c04de33af2 | |||
| 344106ae27 | |||
| b99cbf126b | |||
| d084a461ea | |||
| 07d2b07fab | |||
| 1ccbd7745c | |||
| cce07f3e6b | |||
| b0e3933a9f | |||
| 973b64a682 | |||
| fa7affc91c | |||
| 8dc0ffd481 | |||
| 289058bad2 | |||
| eb1deff2ba | |||
| d8be701bcd | |||
| 80f81aea0f | |||
| 4141217c04 | |||
| 94a6f3b653 | |||
| f3ba5156c7 | |||
| 52d66bcf10 | |||
| 7204b9ff6d | |||
| 9fe52514b9 | |||
| 823bd5029a | |||
| d254affc8b | |||
| ee59efff48 | |||
| 6edb57c749 | |||
| 87fdc12dce | |||
| 351eb2d851 | |||
| 443d382394 | |||
| 692fececc7 | |||
| 1e32d59b8f | |||
| d75c3eecea | |||
| bd4494abac | |||
| 2b1adaa496 | |||
| 7b40caec8b | |||
| 445f9b0d0a | |||
| b1944b7ca3 | |||
| e58ab2c100 | |||
| 705a54f9d8 | |||
| a843bd3132 | |||
| 5bf1f2700a | |||
| 141bdeb89c | |||
| 27e187bac6 | |||
| 229fe208a7 | |||
| 67205ccf41 | |||
| ba60825167 | |||
| 8a54c020c5 | |||
| d6a7e024f0 | |||
| 234d96aaa6 | |||
| 40eb36080b | |||
| fc3342df48 | |||
| 52763366c2 | |||
| f1f01b47f1 | |||
| f90dfe98c7 | |||
| 8642d6e627 | |||
| 52c85f3aaa | |||
| 21c55e8d01 | |||
| 07c10f6542 | |||
| 2fca018add | |||
| ee6def1932 | |||
| 2004bf197a | |||
| 26173b3926 | |||
| 2a9664dbe0 | |||
| 67cc5eafad | |||
| 3663301101 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ Desktop.ini
|
||||
/server/AyaNova/wwwroot
|
||||
/logfile
|
||||
build-output.txt
|
||||
# compiled copy of the manuals' MkDocs hook (docs/8.0/rev_date.py)
|
||||
__pycache__/
|
||||
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -55,7 +55,6 @@
|
||||
"env": {
|
||||
//############ PERPETUAL BLOCK ######################
|
||||
// "ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"AYANOVA_JWT_SECRET": "1111111MyRandom32CharacterSecret",
|
||||
"AYANOVA_LOG_LEVEL": "Info",
|
||||
//"AYANOVA_LOG_LEVEL": "Debug",
|
||||
//"AYANOVA_LOG_LEVEL": "Trace",
|
||||
@@ -111,7 +110,6 @@
|
||||
//############ SUBSCRIPTION MODE BLOCK ######################
|
||||
// "ASPNETCORE_ENVIRONMENT": "Development",
|
||||
//"AYANOVA_REPORT_RENDERING_TIMEOUT":"1",
|
||||
"AYANOVA_JWT_SECRET": "1111111MyRandom32CharacterSecret",
|
||||
"AYANOVA_LOG_LEVEL": "Info",
|
||||
//"AYANOVA_LOG_LEVEL": "Debug",
|
||||
//"AYANOVA_LOG_LEVEL": "Trace",
|
||||
|
||||
32
CLAUDE.md
32
CLAUDE.md
@@ -20,10 +20,10 @@ hardcode these paths: do not move or rename the folders.
|
||||
|
||||
| Folder | What | Stack |
|
||||
|---|---|---|
|
||||
| `raven` | **This repo, the master.** Back-end server, customer manual, build and installer scripts | ASP.NET Core 8, C#, EF Core 8, Npgsql, PostgreSQL |
|
||||
| `raven` | **This repo, the master.** Back-end server, customer manual, build and installer scripts | ASP.NET Core 10, C#, EF Core 10, Npgsql, PostgreSQL |
|
||||
| `raven-client` | Front end, in `ayanova/` | Vue 2.6, Vuetify 2.6, Vuex, Vue Router, plain JavaScript, Node 12.22.9 |
|
||||
| `raven-test-integration` | API integration tests against a running server | xUnit, .NET 8 |
|
||||
| `raven-launcher` | Windows standalone launcher | .NET 8, self-contained, trimmed |
|
||||
| `raven-test-integration` | API integration tests against a running server | xUnit, .NET 10 |
|
||||
| `raven-launcher` | Windows standalone launcher | .NET 10, self-contained, trimmed |
|
||||
| `ravenqbi` | QuickBooks Desktop integration | .NET Framework 4.8 WinForms, x86, Visual Studio only |
|
||||
| `raven-test` | Cypress E2E suite in `e2e/` (Cypress 9.7.0, targets remote devtest servers); also JMeter (`load/`) and Locust (`loadlocust/`) load tests | Node, Cypress |
|
||||
| `sockeye` | Internal licensing server, a near-copy of this codebase, **already ported to .NET 10** (tag `8.1.0`, baseline tag `net6-baseline`) | same as `raven` |
|
||||
@@ -36,17 +36,18 @@ hardcode these paths: do not move or rename the folders.
|
||||
- `dist/` — packaging assets, Inno Setup scripts, build outputs.
|
||||
- `build-release.bat` — master release build.
|
||||
|
||||
## Current work: .NET 10 port, branch `net10-port`
|
||||
## Current work: release 8.3.1, on `main`
|
||||
|
||||
Move the server from .NET 8 to .NET 10 and every back-end package to its current release.
|
||||
Front end: minimal or no change.
|
||||
8.3.0 (the .NET 10 port, case 4690) was released 2026-10-29, tagged `8.3.0` in `raven`,
|
||||
`raven-client` and `raven-launcher`. 8.3.1 is a patch release of fixes; new features are planned
|
||||
for a later 8.4.0. Each case has its own commits on `main` (`case NNNN - …`) and, if customers
|
||||
would notice it, a line under "AyaNova 8.3.1" in the changelog. Release steps:
|
||||
`devdocs/build/RELEASE-PROCEDURE.md`.
|
||||
|
||||
**Before any port work, read `devdocs/modernization/NET10-PORT.md`.** It holds the plan,
|
||||
the checklist and the decisions still open. Update its checklist and log in the same
|
||||
commit as the change they record.
|
||||
`devdocs/modernization/NET10-PORT.md` is the record of 8.3.0. Its Deferred list holds candidate
|
||||
cases for later releases: when one is done, mark it there with its case number in the same commit.
|
||||
|
||||
Sockeye went through the same port first. When a port question comes up, look at how
|
||||
Sockeye solved it: `git -C ..\sockeye log --oneline net6-baseline..8.1.0`.
|
||||
Sockeye went through the same port first: `git -C ..\sockeye log --oneline net6-baseline..8.1.0`.
|
||||
|
||||
The rest of the modernization plan: `devdocs/modernization/ROADMAP.md`.
|
||||
|
||||
@@ -54,10 +55,11 @@ The rest of the modernization plan: `devdocs/modernization/ROADMAP.md`.
|
||||
|
||||
- No change to production code without John's explicit approval. Propose a plan first for anything beyond a one-line fix.
|
||||
- One concern per commit, so a regression can be found with `git log` and undone with `git revert`. The version bump is always the first commit on a release branch.
|
||||
- Do not mix refactoring, bug fixes and features. If you spot something worth refactoring during the port, add it to "Deferred" in `NET10-PORT.md` instead of doing it.
|
||||
- Dependency changes: back-end packages only, and only on `net10-port`. No front-end dependency changes (Node is pinned at 12.22.9 because of `fibers`).
|
||||
- Do not mix refactoring, bug fixes and features. If you spot something worth refactoring, add it to "Deferred" in `NET10-PORT.md` instead of doing it.
|
||||
- Dependency changes: back-end packages only, and only with John's approval (the port that updated them all is done). No front-end dependency changes (Node is pinned at 12.22.9 because of `fibers`).
|
||||
- Do not add `[ApiController]` to controllers that lack it. It changes model binding and error responses on routes deployed clients call.
|
||||
- No new features.
|
||||
- Low-level server fixes (`util`, `generator`, `ControllerHelpers`, `DataList`, startup): check whether Sockeye needs the same fix with `..\sockeye\devdocs\compare-with-raven.sh` (names mapped in `NAME-MAP.md` there), and record the answer.
|
||||
- Git: keep it simple and explicit. Do not push, tag, merge or rewrite history unless asked. Tags are annotated and pushed by name.
|
||||
- Nothing is accepted blindly: show the diff and say why.
|
||||
- Secrets and customer data never go in a repo. Fixtures that contain customer data live outside the repo and are found through an environment variable.
|
||||
@@ -91,8 +93,10 @@ dotnet publish server\AyaNova\AyaNova.csproj -c Release -r linux-x64 --no-self-c
|
||||
|---|---|
|
||||
| `devdocs/modernization/NET10-PORT.md` | The current port: plan, checklist, open questions, log |
|
||||
| `devdocs/modernization/ROADMAP.md` | Modernization steps and their status |
|
||||
| `devdocs/modernization/SECURITY-REVIEW.md` | Whole-codebase security review in passes: scope, threat model, route map, findings, pass log. Review only, fixes become cases |
|
||||
| `devdocs/modernization/PROJECT-OVERVIEW.md` | Products, deployment targets, business context |
|
||||
| `devdocs/modernization/CODEBASE-REFERENCE.md` | Stack, structure, configuration, API conventions |
|
||||
| `devdocs/modernization/TESTING.md` | Test suites and their state |
|
||||
| `devdocs/build/BUILD.md` | Release build, packaging, version numbers |
|
||||
| `devdocs/build/BUILD.md` | Release build, packaging |
|
||||
| `devdocs/build/RELEASE-PROCEDURE.md` | Release steps from code-complete to announcement, tagging, subscription droplet upgrades and weekly updates, version bump |
|
||||
| `devdocs/build/DEV-VM-SETUP.md` | Deprecated build VM plan; its tool list still serves for setting up a build machine. Releases are built on the workstation |
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
# Build the image:
|
||||
# docker build -f Dockerfile.docs -t ayanova-docs-build .
|
||||
#
|
||||
# Run a build (mount your docs source and output):
|
||||
# docker run --rm -v "c:/data/code/raven/docs/8.0/ayanova:/docs" -v "c:/data/code/raven/server/AyaNova/wwwroot/docs:/output" ayanova-docs-build
|
||||
# Run a build (mount docs/8.0, not only ayanova: mkdocs.yml uses the shared hook ../rev_date.py):
|
||||
# docker run --rm -v "c:/data/code/raven/docs/8.0:/docs" -w /docs/ayanova -v "c:/data/code/raven/server/AyaNova/wwwroot/docs:/output" ayanova-docs-build
|
||||
#
|
||||
# Serve docs locally for preview (on port 8000):
|
||||
# docker run --rm -p 8000:8000 -v "c:/data/code/raven/docs/8.0/ayanova:/docs" ayanova-docs-build mkdocs serve -a 0.0.0.0:8000
|
||||
# docker run --rm -p 8000:8000 -v "c:/data/code/raven/docs/8.0:/docs" -w /docs/ayanova ayanova-docs-build mkdocs serve -a 0.0.0.0:8000
|
||||
#
|
||||
|
||||
FROM python:3.11-slim-bullseye
|
||||
|
||||
@@ -79,17 +79,6 @@ set m=BUILD PERPETUAL LINUX x64 FAILED
|
||||
goto FAIL
|
||||
)
|
||||
|
||||
@REM ### DISCONTINUED INDEFINITELY ###
|
||||
@REM rem LINUX DOCKER
|
||||
@REM rmdir C:\data\code\raven\dist\docker\linux-x64\ayanovadocker\files /s/q
|
||||
@REM mkdir C:\data\code\raven\dist\docker\linux-x64\ayanovadocker\files
|
||||
@REM xcopy C:\data\code\raven\dist\linux-x64\* C:\data\code\raven\dist\docker\linux-x64\ayanovadocker\files /s /e
|
||||
@REM "C:\Program Files\7-Zip\7z.exe" a -tzip "C:\data\code\raven\dist\installers\ayanova-linux-docker-x64-server.zip" "C:\data\code\raven\dist\docker\linux-x64\*" -r
|
||||
@REM IF %ERRORLEVEL% NEQ 0 (
|
||||
@REM set m=BUILD LINUX x64 DOCKER ZIP FAILED
|
||||
@REM goto FAIL
|
||||
@REM )
|
||||
|
||||
rem LINUX DESKTOP
|
||||
"C:\Program Files\7-Zip\7z.exe" a -tzip "C:\data\code\raven\dist\installers\ayanova-linux-x64-desktop.zip" "C:\data\code\raven\dist\linux-x64\*" -r
|
||||
"C:\Program Files\7-Zip\7z.exe" a -tzip "C:\data\code\raven\dist\installers\ayanova-linux-x64-desktop.zip" "C:\data\code\raven\dist\assets\linux-desktop\config.json"
|
||||
|
||||
@@ -5,5 +5,5 @@ cd C:\data\code\raven
|
||||
call code .
|
||||
cd C:\data\code\raven-client
|
||||
call code .
|
||||
start cmd /k Call C:\data\code\raven-client\ayanova\appdev.bat
|
||||
start "" /D C:\data\code\raven-client\ayanova cmd /k Call C:\data\code\raven-client\ayanova\appdev.bat
|
||||
start cmd /k Call C:\data\code\raven\startsql.bat
|
||||
|
||||
@@ -95,7 +95,7 @@ customer portal docs).
|
||||
|
||||
### Windows installers
|
||||
|
||||
- **Standalone** (`standalone.iss`): self-contained server, launcher, embedded PostgreSQL, empty database template, desktop shortcut to the launcher. App in `{autopf}\ayanova`; data in `{commonappdata}\ayanova\database`; logs in `{commonappdata}\ayanova\logs`. The launcher starts PostgreSQL, starts the server, opens the browser at the login page and waits.
|
||||
- **Standalone** (`standalone.iss`): self-contained server, launcher, embedded PostgreSQL, empty database template, desktop shortcut to the launcher. App in `{autopf}\ayanova`; data in `{commonappdata}\ayanova\database`; logs in `{commonappdata}\ayanova\logs`. The launcher starts PostgreSQL, starts the server, opens the browser at the login page and waits; when the server exits or its window is closed it stops PostgreSQL with `pg_ctl stop` (case 4694). Setup and uninstall also stop a running embedded PostgreSQL first (8.2.4 and older launchers left it running, which blocked upgrades with "files in use"). `postgres-standalone\bin` includes the Visual C++ runtime DLLs PostgreSQL needs (`msvcp140.dll`, `vcruntime140.dll`, `vcruntime140_1.dll`, 14.44.35211.0, Microsoft-signed, app-local; case 4695): Windows Update never updates app-local copies, so replace them when PostgreSQL is updated.
|
||||
- **LAN** (`lan.iss`): framework-dependent server plus network config. No PostgreSQL. Prompts the user to download the ASP.NET Core Hosting Bundle and PostgreSQL.
|
||||
|
||||
### QBI (separate)
|
||||
@@ -112,15 +112,8 @@ timestamp utility.
|
||||
|
||||
## Version numbers
|
||||
|
||||
Set in five places for every release:
|
||||
|
||||
| File | Setting | At 8.2.4 |
|
||||
|---|---|---|
|
||||
| `raven\server\AyaNova\AyaNova.csproj` | `<Version>` | 8.2.4 |
|
||||
| `raven\dist\install\windows\x64\standalone.iss` | `#define MyAppVersion` | 8.2.4 |
|
||||
| `raven\dist\install\windows\x64\lan.iss` | `#define MyAppVersion` | 8.2.4 |
|
||||
| `raven-client\ayanova\package.json` | `"version"` | 8.2.4 |
|
||||
| `ravenqbi\install\qbi.iss` | `#define MyAppVersion` | 8.0.2 (own cycle) |
|
||||
The full list of files to change, and when, is in `RELEASE-PROCEDURE.md`, step 12 (version bump).
|
||||
QBI has its own cycle: `ravenqbi\install\qbi.iss`, `#define MyAppVersion` (8.0.2).
|
||||
|
||||
## Front-end build notes
|
||||
|
||||
@@ -131,8 +124,7 @@ Set in five places for every release:
|
||||
## Docker builds
|
||||
|
||||
`raven-client\ayanova\Dockerfile.frontend` and `raven\Dockerfile.docs` build the front end
|
||||
and the manual in containers. A runtime image and compose file for the Linux server are in
|
||||
`raven\dist\docker\linux-x64\`. The Linux server packages and the Windows
|
||||
and the manual in containers. The Linux server packages and the Windows
|
||||
framework-dependent build could also build in Docker. Windows self-contained builds, Inno
|
||||
Setup, QBI and the embedded PostgreSQL need Windows.
|
||||
|
||||
|
||||
389
devdocs/build/RELEASE-PROCEDURE.md
Normal file
389
devdocs/build/RELEASE-PROCEDURE.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# AyaNova v8 release and server update procedure
|
||||
|
||||
How a release goes from finished code to customers, and how the hosted subscription droplets are
|
||||
updated every week. Replaces John's notes scattered across info cases (the Subversion-era
|
||||
release notes and the subscription upgrade scripts of info case 3450). The build itself (tools,
|
||||
`build-release.bat`, packages) is in `BUILD.md`; release-specific extras (for 8.3.0: the .NET 10
|
||||
runtime, the one-time droplet hardening) are in that release's checklist
|
||||
(`devdocs/modernization/NET10-PORT.md`, section 5).
|
||||
|
||||
Every command is a complete line for the shell named above it: PowerShell on the workstation,
|
||||
bash on a droplet or the ayanova.com web server (logged in as root).
|
||||
|
||||
## Release order at a glance
|
||||
|
||||
1. Code complete: every case committed and pushed, local E2E passes
|
||||
2. Docs: changelog (manual and client copyright years are automatic)
|
||||
3. Build with `build-release.bat`
|
||||
4. Stage the installers and docs in the `next` folders on ayanova.com
|
||||
5. Test on devtest: upgrade it from `next`, remote E2E, check each change
|
||||
6. Tag the release in git
|
||||
7. Publish: docs and downloads go live (not announced yet)
|
||||
8. Download every published file back and check its version
|
||||
9. **Upgrade the subscription droplets** (Thursday, by time zone)
|
||||
10. **Announce**: upload `latest-version.json`
|
||||
11. Tell subscription customers
|
||||
12. Start the next version
|
||||
|
||||
**Subscription droplets are upgraded before the release is announced** (John, 2026-10-26, from
|
||||
8.3.0 on). Uploading `latest-version.json` is what tells every AyaNova installation that a new
|
||||
version exists, so it is the very last publishing step. Between steps 7 and 10 the new docs and
|
||||
downloads are already visible on the website; that gap is accepted.
|
||||
|
||||
## 1. Code complete
|
||||
|
||||
- All "next release" cases done, each committed with its case number and pushed, in every repo.
|
||||
- Every repo clean and in step with its remote. All four must say `nothing to commit, working tree clean` and `up to date with 'origin/main'`:
|
||||
|
||||
```powershell
|
||||
git -C C:\data\code\raven status; git -C C:\data\code\raven-client status; git -C C:\data\code\raven-launcher status; git -C C:\data\code\raven-test-integration status
|
||||
```
|
||||
|
||||
- Integration tests pass against the local server (`raven-test-integration`, see `devdocs/modernization/TESTING.md`).
|
||||
- E2E passes against the local debug server (API on 7575, client dev server on 8080). In `raven-test\e2e`, `cypress.json` `expectedServerVersion` must be the new version:
|
||||
|
||||
```powershell
|
||||
cd C:\data\code\raven-test\e2e; .\local-run.bat
|
||||
```
|
||||
|
||||
## 2. Docs
|
||||
|
||||
**Often missed: check carefully against the previous release.**
|
||||
|
||||
- `docs/8.0/ayanova/docs/changelog.md`: the new version's section is complete and reads well; remove "IN PROGRESS".
|
||||
- The new version's section starts with `Released YYYY-MM-DD`, the planned publication date (step 7), as every earlier version does. The manual is built into every package, so it must be right before the build.
|
||||
- Manual revision stamp and copyright year: set automatically when the manuals are built
|
||||
(`docs/8.0/rev_date.py`, the date of the last commit that changed each manual). After the build,
|
||||
check the bottom of a page in `server/AyaNova/wwwroot/docs`: `REV-` must be that date.
|
||||
- Copyright year on the login page: set automatically when the client is built (`raven-client`
|
||||
`ayanova/vue.config.js`, the year of the build).
|
||||
- `dist/latest-version.json`: the new version and its changelog anchor (for example `#ayanova-830`). Set at the version bump; check it here.
|
||||
|
||||
## 3. Build
|
||||
|
||||
Before the build, update the .NET SDK so the Windows standalone package and the launcher carry the
|
||||
newest .NET runtime (they bundle the runtime of the SDK that builds them; see `BUILD.md`, Tools).
|
||||
If winget finds nothing newer, nothing changes:
|
||||
|
||||
```powershell
|
||||
winget upgrade --id Microsoft.DotNet.SDK.10 -e --source winget
|
||||
```
|
||||
|
||||
Then run the full build; it must end with "COMPLETED WITHOUT ERROR". Details in `BUILD.md`.
|
||||
|
||||
```powershell
|
||||
cd C:\data\code\raven; .\build-release.bat
|
||||
```
|
||||
|
||||
Write down the commit each repo was built from (the tag in step 6 goes on exactly these):
|
||||
|
||||
```powershell
|
||||
git -C C:\data\code\raven log --oneline -1; git -C C:\data\code\raven-client log --oneline -1; git -C C:\data\code\raven-launcher log --oneline -1
|
||||
```
|
||||
|
||||
## 4. Stage in `next`
|
||||
|
||||
On the ayanova.com web server (`ssh root@ayanova.com`), empty the previous release's `next` folders:
|
||||
|
||||
```bash
|
||||
rm -r /var/www/html/ayanova.com/download/next/* /var/www/html/ayanova.com/docs-next/*
|
||||
```
|
||||
|
||||
Then, from PowerShell on the workstation, upload the installers and the built manual (each
|
||||
command passes `scp` the full list of files and folders to copy):
|
||||
|
||||
```powershell
|
||||
scp (Get-ChildItem C:\data\code\raven\dist\installers -File).FullName root@ayanova.com:/var/www/html/ayanova.com/download/next/
|
||||
```
|
||||
|
||||
```powershell
|
||||
scp -r (Get-ChildItem C:\data\code\raven\server\AyaNova\wwwroot\docs).FullName root@ayanova.com:/var/www/html/ayanova.com/docs-next/
|
||||
```
|
||||
|
||||
## 5. Test on devtest
|
||||
|
||||
devtest.onayanova.com mirrors the customer droplets; it is the final confirmation before any
|
||||
customer is touched. For earlier, riskier tests, create a temporary droplet when needed and
|
||||
destroy it when done, so it does not run up costs (for 8.3.0 that was `devtestn10`).
|
||||
|
||||
1. Bring its OS up to date as in the [weekly update](#weekly-os-update), reboot.
|
||||
2. Any one-time steps this release needs (for 8.3.0: [the .NET 10 runtime and hardening](#one-time-steps-for-830)).
|
||||
3. Upgrade it with the [next version upgrade commands](#upgrade-commands).
|
||||
4. Remote E2E:
|
||||
|
||||
```powershell
|
||||
cd C:\data\code\raven-test\e2e; .\remote-run.bat
|
||||
```
|
||||
|
||||
5. Check each item in the changelog on it, and that the new version number shows everywhere (login page, About form).
|
||||
|
||||
If anything fails: fix it as a case, rebuild (step 3), restage (step 4) and test again. Nothing
|
||||
is tagged or published until devtest passes.
|
||||
|
||||
## 6. Tag the release in git
|
||||
|
||||
A tag is a permanent name on one commit, so `8.3.0` always points at exactly the code that was
|
||||
shipped, years later and whatever happens on `main` afterwards. An **annotated** tag (`-a`) also
|
||||
records who tagged it, when, and a message. Tags are not sent by an ordinary `git push`, so each
|
||||
one is pushed by name. Tag the repos whose code is in the release, at the commits noted in step 3
|
||||
(if nothing was committed since, that is the current `main`):
|
||||
|
||||
```powershell
|
||||
git -C C:\data\code\raven tag -a 8.3.0 -m "AyaNova 8.3.0"; git -C C:\data\code\raven push origin 8.3.0
|
||||
```
|
||||
|
||||
```powershell
|
||||
git -C C:\data\code\raven-client tag -a 8.3.0 -m "AyaNova 8.3.0"; git -C C:\data\code\raven-client push origin 8.3.0
|
||||
```
|
||||
|
||||
```powershell
|
||||
git -C C:\data\code\raven-launcher tag -a 8.3.0 -m "AyaNova 8.3.0"; git -C C:\data\code\raven-launcher push origin 8.3.0
|
||||
```
|
||||
|
||||
Check that the tags are on the server (each lists `refs/tags/8.3.0`):
|
||||
|
||||
```powershell
|
||||
git -C C:\data\code\raven ls-remote --tags origin; git -C C:\data\code\raven-client ls-remote --tags origin; git -C C:\data\code\raven-launcher ls-remote --tags origin
|
||||
```
|
||||
|
||||
A pushed tag is never moved or deleted: if a problem turns up after release, the fix becomes the
|
||||
next patch version (8.3.1) with its own tag. To see later what any release contained:
|
||||
`git -C C:\data\code\raven show 8.3.0`, or compare two releases with
|
||||
`git -C C:\data\code\raven log --oneline 8.2.4..8.3.0` (from 8.3.0 on; earlier releases were not tagged in git).
|
||||
|
||||
## 7. Publish (not announced yet)
|
||||
|
||||
The download folders on the ayanova.com web server:
|
||||
|
||||
- `download/` always holds the current release, plus files that are not part of a release (for
|
||||
example `QBFC14_0installer.exe`), which stay.
|
||||
- `download/next/` is staging: the release as built and tested in steps 4 and 5.
|
||||
- `download/old/` archives the three most recent releases, including the current one, each in a
|
||||
folder named for its release date and version. The current release is therefore in both
|
||||
`download/` and `download/old/`, on purpose.
|
||||
|
||||
On the ayanova.com web server, in this order:
|
||||
|
||||
1. Replace the manual with the new one:
|
||||
|
||||
```bash
|
||||
rm -r /var/www/html/ayanova.com/docs/* && cp -r /var/www/html/ayanova.com/docs-next/* /var/www/html/ayanova.com/docs
|
||||
```
|
||||
|
||||
2. Replace this release's files in `download/` with the tested ones from `next/`. Only the files
|
||||
in `next/` are overwritten; everything else in `download/` stays. The same command keeps a copy
|
||||
of the current `latest-version.json` (it is not in `next/` and is replaced only in step 10):
|
||||
|
||||
```bash
|
||||
cp /var/www/html/ayanova.com/download/next/* /var/www/html/ayanova.com/download && cp /var/www/html/ayanova.com/download/latest-version.json /var/www/html/ayanova.com/download/previous-latest-version.json
|
||||
```
|
||||
|
||||
3. Archive the release in `download/old/`, in a folder named `YYYY-MM-DD-vX.Y.Z` with the release
|
||||
date and version (for 8.3.0, released 2026-10-29):
|
||||
|
||||
```bash
|
||||
mkdir /var/www/html/ayanova.com/download/old/2026-10-29-v8.3.0 && cp /var/www/html/ayanova.com/download/next/* /var/www/html/ayanova.com/download/old/2026-10-29-v8.3.0
|
||||
```
|
||||
|
||||
4. Remove the oldest release folder in `download/old`, so three remain (kept since December 2023;
|
||||
customers whose maintenance lapsed must purchase to upgrade, and the older versions are kept
|
||||
only to diagnose a regression). List the folder, then remove the oldest; at 8.3.0 that is 8.2.2:
|
||||
|
||||
```bash
|
||||
ls /var/www/html/ayanova.com/download/old
|
||||
```
|
||||
|
||||
```bash
|
||||
rm -r /var/www/html/ayanova.com/download/old/2024-10-23-v8.2.2
|
||||
```
|
||||
|
||||
Do **not** upload the new `latest-version.json` yet.
|
||||
|
||||
## 8. Check what was published
|
||||
|
||||
Download every installer and package back from ayanova.com and check its version: Windows
|
||||
installers by the .exe's ProductVersion, Linux zips by the `AyaNova.dll` inside. (Found
|
||||
2026-10-25: the LAN installer on the website was still 8.2.3, so 8.2.4 had never been uploaded for LAN.)
|
||||
|
||||
```powershell
|
||||
$ProgressPreference = 'SilentlyContinue'; $d = "$env:TEMP\ayanova-published"; Remove-Item $d -Recurse -Force -ErrorAction SilentlyContinue; New-Item -ItemType Directory $d | Out-Null; foreach ($f in 'ayanova-windows-x64-single-setup.exe','ayanova-windows-x64-lan-setup.exe','ayanova-linux-x64-server.zip','ayanova-linux-x64-desktop.zip','ayanova-subscription-linux-x64-server.zip') { Invoke-WebRequest "https://www.ayanova.com/download/$f" -OutFile "$d\$f" -UseBasicParsing; if ($f -like '*.exe') { "$f $((Get-Item "$d\$f").VersionInfo.ProductVersion)" } else { Expand-Archive "$d\$f" "$d\$($f -replace '\.zip$','')" ; "$f $((Get-Item "$d\$($f -replace '\.zip$','')\AyaNova.dll").VersionInfo.ProductVersion)" } }
|
||||
```
|
||||
|
||||
Every line must show the new version. If one does not, fix the download folder (step 7) and check
|
||||
again; no subscription droplet is upgraded and nothing is announced until every line is right.
|
||||
|
||||
## 9. Upgrade the subscription droplets
|
||||
|
||||
On the normal Thursday schedule and times (or out of band for an emergency), as in the
|
||||
[weekly update](#weekly-os-update): snapshot, health check, OS update, reboot; then any
|
||||
one-time steps for this release, then the [release upgrade commands](#upgrade-commands); then the
|
||||
health check and the log entry. Mark each server as upgraded in its Sockeye subscription server record.
|
||||
|
||||
## 10. Announce
|
||||
|
||||
Only after the last subscription droplet is upgraded, from PowerShell on the workstation (overwrites
|
||||
the old one; from this moment every AyaNova installation shows that the new version is available):
|
||||
|
||||
```powershell
|
||||
scp C:\data\code\raven\dist\latest-version.json root@ayanova.com:/var/www/html/ayanova.com/download/
|
||||
```
|
||||
|
||||
## 11. Tell subscription customers
|
||||
|
||||
Subscription customers are told after their server was upgraded, not before. Perpetual licence
|
||||
customers see the new version in AyaNova itself; tell one only if they are waiting for a
|
||||
specific change. Message:
|
||||
|
||||
> Subject: Your AyaNova service was upgraded
|
||||
>
|
||||
> Your AyaNova service has been upgraded to the latest version.
|
||||
>
|
||||
> This release contains these features and improvements:
|
||||
> https://ayanova.com/docs/changelog/#ayanova-830
|
||||
>
|
||||
> Be sure to backup your data regularly:
|
||||
> https://ayanova.com/docs/sub-service-intro/
|
||||
|
||||
## 12. Start the next version
|
||||
|
||||
Version numbers follow [semantic versioning](https://semver.org/): MAJOR for incompatible API
|
||||
changes, MINOR for new backward compatible functionality, PATCH for backward compatible fixes.
|
||||
|
||||
The version bump is the **first commit** of the next release, `version bump X.Y.Z` (no case: a
|
||||
release has no case of its own), one commit per repo, made right after the release, before any
|
||||
new code. Set the new version in:
|
||||
|
||||
| Repo | File | What |
|
||||
|---|---|---|
|
||||
| raven | `server/AyaNova/AyaNova.csproj` | `<Version>` and `<FileVersion>` |
|
||||
| raven | `server/AyaNova/util/AyaNovaVersion.cs` | `VersionString` |
|
||||
| raven | `dist/install/windows/x64/lan.iss` | `#define MyAppVersion` |
|
||||
| raven | `dist/install/windows/x64/standalone.iss` | `#define MyAppVersion` |
|
||||
| raven | `dist/latest-version.json` | `latestversion` and the `changelogurl` anchor |
|
||||
| raven | `docs/8.0/ayanova/docs/changelog.md` | new version heading; under it the "Upgrading from 8.2.4 or older?" line copied from the previous version's section (case 4759; keep it until John decides enough customers are past 8.3.0), then "IN PROGRESS" |
|
||||
| raven | `docs/8.0/ayanova/docs/ay-about.md` | the example version number |
|
||||
| raven-client | `ayanova/package.json`, `ayanova/package-lock.json` | `"version"` (both places in the lock file) |
|
||||
| raven-client | `ayanova/src/api/ayanova-version.js` | `version` |
|
||||
| raven-launcher | `ayanova-version.cs` | `VersionString` |
|
||||
| raven-test | `e2e/cypress.json` | `expectedServerVersion` |
|
||||
|
||||
Searching each repo for the previous version number finds them all:
|
||||
|
||||
```powershell
|
||||
git -C C:\data\code\raven grep -n -F "8.3.0" -- server dist docs/8.0/ayanova/docs/ay-about.md; git -C C:\data\code\raven-client grep -n -F "8.3.0" -- ayanova/package.json ayanova/src/api; git -C C:\data\code\raven-launcher grep -n -F "8.3.0"
|
||||
```
|
||||
|
||||
QBI (`ravenqbi`) has its own version cycle (`install\qbi.iss`).
|
||||
|
||||
## Upgrade commands
|
||||
|
||||
Run on a subscription droplet as root. They stop AyaNova, download the new zip, replace the
|
||||
program files (keeping `config.json` and everything in `data`), and start AyaNova again. `curl -f`
|
||||
makes a failed download stop the whole chain before anything is deleted; without it a missing
|
||||
file would be saved as an error page, the program files deleted and the unzip would fail, leaving
|
||||
the server down until the snapshot is restored.
|
||||
|
||||
**Next version, for testing on devtest** (from the `next` folder):
|
||||
|
||||
```bash
|
||||
cd /var/ayanova && systemctl stop ayanova.service && curl -fO https://www.ayanova.com/download/next/ayanova-subscription-linux-x64-server.zip && rm *.dll && rm -r ./resource && rm -r ./wwwroot && unzip -o ayanova-subscription-linux-x64-server.zip -x "config.json" && systemctl start ayanova.service
|
||||
```
|
||||
|
||||
**Release version, for customer droplets:**
|
||||
|
||||
```bash
|
||||
cd /var/ayanova && systemctl stop ayanova.service && curl -fO https://www.ayanova.com/download/ayanova-subscription-linux-x64-server.zip && rm *.dll && rm -r ./resource && rm -r ./wwwroot && unzip -o ayanova-subscription-linux-x64-server.zip -x "config.json" && systemctl start ayanova.service
|
||||
```
|
||||
|
||||
Then `reboot`, and the health check: `https://<server>.onayanova.com/health`.
|
||||
|
||||
## One-time steps for 8.3.0
|
||||
|
||||
Every existing droplet, once, right before its 8.3.0 upgrade. Both are safe to run again.
|
||||
|
||||
**.NET 10 runtime** (8.3.0 needs it; on Ubuntu 24.04 and 26.04 it comes from Ubuntu's own archive):
|
||||
|
||||
```bash
|
||||
apt-get install -y aspnetcore-runtime-10.0
|
||||
```
|
||||
|
||||
**Hardening** (case 4727; what the setup script now does for new droplets): AyaNova listens only on
|
||||
127.0.0.1, the program folder becomes read only for the service account, `config.json` 640, the
|
||||
old 8.2.x Chrome folders are removed, and the systemd security settings are added as a drop-in.
|
||||
Tested 2026-10-26 on `devtestn10`. Save it on the droplet with `nano /root/harden-830.sh`, paste,
|
||||
save, then run `bash /root/harden-830.sh`; it must end with `HARDENING DONE`. It takes effect when
|
||||
the upgrade commands start AyaNova.
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# AyaNova 8.3.0: one-time hardening of an existing subscription droplet (case 4727, F36 and F38).
|
||||
# Run once per droplet as root, right before the 8.3.0 upgrade commands. Safe to run again.
|
||||
set -e
|
||||
|
||||
# safety copies, kept from the first run only
|
||||
[ -e /root/config.json.before-8.3.0 ] || cp /var/ayanova/config.json /root/config.json.before-8.3.0
|
||||
[ -e /root/ayanova.service.before-8.3.0 ] || cp /etc/systemd/system/ayanova.service /root/ayanova.service.before-8.3.0
|
||||
|
||||
# F36: nginx is the only way in: listen on this server only, not on its public address
|
||||
sed -i 's#http://[*]:7575#http://127.0.0.1:7575#' /var/ayanova/config.json
|
||||
grep -q '"http://127.0.0.1:7575"' /var/ayanova/config.json
|
||||
|
||||
# F38: program files read only for the service account; it writes to data only
|
||||
chmod -R go-w /var/ayanova
|
||||
chmod -R g+rw /var/ayanova/data
|
||||
# config.json holds the database password: readable by root and the service group only
|
||||
chown root:www-data /var/ayanova/config.json
|
||||
chmod 640 /var/ayanova/config.json
|
||||
# 8.2.x report browser folders; 8.3.0 downloads the browser into data/report-browser
|
||||
rm -rf /var/ayanova/Chrome /var/ayanova/ChromeHeadlessShell
|
||||
|
||||
# F38: systemd sandboxing, as a drop-in so the existing unit file is left as it is
|
||||
mkdir -p /etc/systemd/system/ayanova.service.d
|
||||
cat > /etc/systemd/system/ayanova.service.d/hardening.conf <<'EOF'
|
||||
[Service]
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/ayanova/data
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
|
||||
echo HARDENING DONE
|
||||
```
|
||||
|
||||
After the upgrade, check that AyaNova listens only on 127.0.0.1 (`ss -tln | grep 7575` shows
|
||||
`127.0.0.1:7575`) and that the health check passes.
|
||||
|
||||
## Weekly OS update
|
||||
|
||||
Every Thursday, Ubuntu updates on every subscription droplet. Customers have been told updates
|
||||
happen on Thursday nights after 7 pm their time (downtime under a minute; none has asked for
|
||||
another time). All droplets have phased updates turned off, so they get the same packages
|
||||
devtest was tested with (`/etc/apt/apt.conf.d/52no-phasing`; the setup script adds it to new droplets).
|
||||
|
||||
Order: devtest first (it mirrors the customer droplets), then customers in groups by time zone,
|
||||
at John's 7 am (Singapore, Australia), 4 pm (US east coast and Europe, most servers) and 7 pm
|
||||
(US west coast).
|
||||
|
||||
Per droplet:
|
||||
|
||||
1. In the DigitalOcean control panel: delete last week's snapshot, take a new one.
|
||||
2. Confirm the server is healthy: `https://<server>.onayanova.com/health`.
|
||||
3. SSH in, remove last week's leftovers, update, reboot (always reboot: skipping it caused intermittent problems in the past):
|
||||
|
||||
```bash
|
||||
apt autoremove
|
||||
```
|
||||
|
||||
```bash
|
||||
apt-get update && apt-get -V full-upgrade
|
||||
```
|
||||
|
||||
```bash
|
||||
reboot
|
||||
```
|
||||
|
||||
4. Copy the session's console output into the week's log file (kept until the next week's updates, to look back at if something comes up).
|
||||
5. Mark the date and time of the update in the server's Sockeye subscription server record.
|
||||
6. Refresh the server's AyaNova page and confirm the login page appears; open `/health` in place of `/login` and paste the result into the week's log as the last entry for that server.
|
||||
@@ -12,7 +12,7 @@ under "Deferred", not into the branch.
|
||||
| Branch | `net10-port` in `raven` (and `raven-client` and `raven-test-integration` and `raven-launcher`) |
|
||||
| Baseline tag | `net8-baseline`, on the last commit before the port |
|
||||
| Release version | 8.3.0 |
|
||||
| Release timing | Tentatively within two weeks of 2026-10-23, after John's move of Sockeye and the ayanova.com business server to a new droplet (weekend of 2026-10-24) has settled. The case database's "next release" cases (including the SuperUser delete bug) must be done first. After 8.3.0: a security review of the whole codebase and an MCP interface for AyaNova 8 |
|
||||
| Release timing | Tentatively within two weeks of 2026-10-23, after John's move of Sockeye and the ayanova.com business server to a new droplet (weekend of 2026-10-24) has settled. The case database's "next release" cases (including the SuperUser delete bug) must be done first. The security review of the whole codebase (`SECURITY-REVIEW.md`) runs before 8.3.0 and its findings gate the release (John, 2026-10-23); after 8.3.0: an MCP interface for AyaNova 8 |
|
||||
|
||||
## Where this plan comes from
|
||||
|
||||
@@ -122,7 +122,7 @@ in this branch or a follow-up release. UPDATE: John has decided these all go in
|
||||
- [x] **Report browser download location.** Fixed: downloads into `<AYANOVA_DATA_PATH>/report-browser` (derived `ServerBootConfig.REPORT_RENDER_BROWSER_DOWNLOAD_PATH`, not a new setting; empty when no data path is set, then beside the app as before) through `BrowserFetcherOptions.Path`, and launches the downloaded executable explicitly. `AYANOVA_REPORT_RENDER_BROWSER_PATH` still takes precedence. Existing installs keep an unused old download beside the app until it is removed. Confirmed: `new BrowserFetcher()` with the default path beside the app (`ReportBiz.cs:800`). Point it under the data directory in code. Beside the app it is read-only under a hardened systemd unit (`ProtectSystem=strict`), and a redeploy that replaces the app folder deletes it
|
||||
- [x] **Report failures: return a specific error.** Done: download and launch each in their own `try`; on failure `HandleBrowserFailure` logs at Error (was Debug via the job processor) and writes `rendererror.exception` ("Report rendering browser could not be downloaded to … / started from … with arguments …: (reason)"), which the client already shows as `JobFailed: …`; no client change. The Sockeye crash is n/a: rendering runs as a job, and `JobsBiz.ProcessJobAsync` catches every exception, logs it through NLog and marks the job Failed (`JobsBiz.cs:390-439`), so the process survives. What remains: a browser download or launch failure (`ReportBiz.cs:800`, `:838`, outside the render `try`) reaches the client only as a generic job failure. Catch it in `DoRenderJob` and return a render error that says what failed
|
||||
- [x] **Data-list date filters, small fixes.** Fixed: `>=` and `<` use `ZeroSeconds`; `!=` is `< ZeroSeconds OR > MaxSeconds`; the `DataListField` null check runs first and in all builds, throwing `ArgumentException` naming the column. (An unknown column sent to `/data-list` is in practice rejected earlier by `DataListSqlSelectBuilder`, with its own "DEV ERROR" message; unchanged.) Sockeye 8.1.0 still has the old code. Confirmed: `>=` (`:790-793`) and `<` (`:795-798`) use `MaxSeconds` and should use `ZeroSeconds`; date `NotEqual` (`:806-814`) uses `MaxSeconds` on both sides and should exclude the whole minute (`< ZeroSeconds OR > MaxSeconds`). `DataListField` is dereferenced at `:24` and `:27` before the DEBUG-only null check at `:33`, so **release builds** throw a NullReferenceException on an unknown column, not only DEBUG builds
|
||||
- [ ] **Relative date tokens use the server's time zone** (`*today*`, `*yesterday*` …). Confirmed: `ClientToday = clientTimeStamp.Date` (`:399-400`) has `Kind=Unspecified`, and `ToUniversalTime()` (`:1060`, `:1065`) converts it with the server's zone. Wrong for any user in a different zone from the server. The fix builds the range from `clientTimeStamp.Offset`, with care for ranges that cross a DST change. **This changes behaviour** and affects the integration tests' `TIME_ZONE_ADJUSTMENT`. Probably better as a follow-up release
|
||||
- [x] **Relative date tokens use the server's time zone** **Fixed in case 4697** (boundaries converted to UTC with `clientTimeStamp.Offset`). (`*today*`, `*yesterday*` …). Confirmed: `ClientToday = clientTimeStamp.Date` (`:399-400`) has `Kind=Unspecified`, and `ToUniversalTime()` (`:1060`, `:1065`) converts it with the server's zone. Wrong for any user in a different zone from the server. The fix builds the range from `clientTimeStamp.Offset`, with care for ranges that cross a DST change. **This changes behaviour** and affects the integration tests' `TIME_ZONE_ADJUSTMENT`. Probably better as a follow-up release
|
||||
|
||||
Not port items, listed so they are not forgotten: the three server bugs the integration
|
||||
suite is waiting on (case 4653, case 4654, the contract issue).
|
||||
@@ -134,7 +134,7 @@ suite is waiting on (case 4653, case 4654, the contract issue).
|
||||
- [x] **Reports, all advanced reports**, compared with the baseline PDFs. Done: Windows (page counts = PuppeteerSharp 17), Ubuntu 24.04 and 26.04 droplets, 22/22 each; John checked the PDFs visually; `ldd` clean on both Ubuntu versions; no orphaned browser processes after a cancelled render. Was: On Windows and on Linux. On Linux also: `ldd` on the downloaded browser binary for missing libraries (install exactly what is reported; several Ubuntu 24.04+ names carry a `t64` suffix); the sandbox under the service account; no orphaned `chrome` processes after a cancelled render
|
||||
- [x] Integration suite: same result as the baseline. Locally on every step; on both droplets with `UserWithActivityShouldNotBeDeleteable` excluded (it deletes SuperUser when SuperUser has no related records, see Deferred)
|
||||
- [x] Licence verification gate: green. 730/730 on 2026-10-23 at `181b1e44` (`dotnet test ayanova.tests`); re-run in the final gate (section 5)
|
||||
- [ ] Cypress E2E (`raven-test\e2e`) against a devtest server running the .NET 10 build (`devtestn10` is kept for this until release, John). Also covers the UI half of "exercise LINQ-based queries screen by screen" (section 1)
|
||||
- [x] Cypress E2E (`raven-test\e2e`) against a devtest server running the .NET 10 build (`devtestn10` is kept for this until release, John). First set `"expectedServerVersion"` in `raven-test\e2e\cypress.json` to 8.3.0 (it says 8.2.4). **Done 2026-10-26: 27/27** on `devtestn10` with the release candidate build (Electron, from the workstation). Getting there fixed the tests, not AyaNova (`raven-test` `e0b3641`, `ad8fbde`, `2ec1e03`): logins failed at random over the internet because the login page reloads once on a first visit (service worker taking control) and can move the focus back to the username field while it settles; 8.2.4 on devtest failed the same way. `0900` looked for its notification before the server's once-a-minute delivery; it now reloads until it arrives. Also covers the UI half of "exercise LINQ-based queries screen by screen" (section 1)
|
||||
- [x] `build-release.bat` produces all five packages; install and start each one: Windows standalone, Windows LAN, Linux server, Linux desktop, subscription Linux. All five built (2026-10-23, from `4a011f65`+). **Linux done** on droplet `devtestn10` (Ubuntu 24.04): subscription zip as an in-place upgrade of 8.2.4, perpetual server and desktop zips booted fresh (see log). **Windows standalone done** in Windows Sandbox (upgrade 8.2.4 → 8.3.0, uninstall, fresh install; see log); fresh install needed the `standalone.iss` PostgreSQL folder fix. **Windows LAN done** in Windows Sandbox (fresh install following the manual, see log). Re-check both Windows installers from the final release build (they carry the fixes made during this test)
|
||||
|
||||
## 5. Release
|
||||
@@ -145,10 +145,10 @@ suite is waiting on (case 4653, case 4654, the contract issue).
|
||||
- Self-hosted Linux customers (server and desktop zips are framework-dependent)
|
||||
- Windows LAN: `lan.iss` prompts for the ASP.NET Core Hosting Bundle. Text and link updated to .NET 10 (2026-10-23); tested in Windows Sandbox (section 4)
|
||||
- Windows standalone and the launcher are self-contained: no runtime needed on the customer's machine. **But they carry the .NET runtime of the build machine's SDK:** SDK 10.0.103 → runtime 10.0.3 (the Sandbox test logged `.Net Version - 10.0.3`; Linux hosts run 10.0.13). Before the final release build, update the workstation's .NET 10 SDK to the newest release so the standalone package ships the current runtime with its security fixes. 8.2.4 did the same with .NET 8.0.13. **Done 2026-10-23:** SDK 10.0.402 installed with winget (`winget upgrade` did not find the Visual Studio-installed 10.0.103); test publish of the standalone server and the launcher bundles runtime 10.0.13. Standing note added to `devdocs/build/BUILD.md`
|
||||
- [x] Customer manual (`docs/`): system requirements and upgrade instructions for self-hosted customers. Report browser move (section 3): manual already updated in the same commit (`ops-config-report-render-browser-path.md`, `ops-install-linux-server.md`, `ops-upgrade-linux-server.md`, `ops-upgrade-linux-desktop.md`). .NET 10 runtime install and upgrade steps (Windows LAN and IIS, Linux server and desktop), Ubuntu 24.04/26.04, backup password note and the 8.3.0 changelog draft: `111d7922` (2026-10-23). Changelog wording to be finalised by John before release
|
||||
- [x] Customer manual (`docs/`): system requirements and upgrade instructions for self-hosted customers. Report browser move (section 3): manual already updated in the same commit (`ops-config-report-render-browser-path.md`, `ops-install-linux-server.md`, `ops-upgrade-linux-server.md`, `ops-upgrade-linux-desktop.md`). .NET 10 runtime install and upgrade steps (Windows LAN and IIS, Linux server and desktop), Ubuntu 24.04/26.04, backup password note and the 8.3.0 changelog draft: `111d7922` (2026-10-23). Changelog wording to be finalised by John before release. Possible extra line: John notices the UI is much faster than 8.2.4 in local debugging (2026-10-24); add a performance line only if confirmed on `devtestn10` against 8.2.4 on devtest (same droplet type and network distance, e.g. Cypress run times)
|
||||
- [ ] Build machines: the workstation has the .NET 10 SDK (10.0.402, plus 10.0.103 from Visual Studio). The backup laptop needs it before it can build 8.3.0 (`winget install --id Microsoft.DotNet.SDK.10 -e --source winget`; the .NET 8 SDK is no longer needed); tracked with the laptop's other catch-up items in `devdocs/build/BUILD.md`. `devdocs/build/DEV-VM-SETUP.md` and `devdocs/build/BUILD.md` updated (2026-10-23). The build VM is deprecated (John, 2026-10-23): n/a
|
||||
- [ ] Final gate runs on the finished code
|
||||
- [ ] Merge, per repo:
|
||||
- [x] Final gate runs on the finished code (after the "next release" cases, on the release build from `main`). **Done 2026-10-26 at `c19a3b04`** with SDK 10.0.403 (runtime 10.0.14): licence gate 730/730; integration suite on a fresh trial database 149 tests, 143 passed, 2 failed (the two known: `ContractCrud.CRUD`, `CustomerWithWorkOrders_CannotBeDeleted`; the only two errors in the server log are theirs), 4 skipped; no EF Core warning raised as an error. Release candidate build from the same commit: all five packages 8.3.0, Windows standalone and launcher bundle runtime 10.0.14, Linux zips `http://*:7575` as shipped; installed on `devtestn10` as a subscription upgrade, hardening intact. Final build follows John's changelog edit (docs only)
|
||||
- [x] Merge, per repo. **Done 2026-10-23, without a tag** (John): port merged before the "next release" cases so the port and each case stay separate in history. `raven` `36633011`, `raven-client` `44d91611`, `raven-launcher` `d296298`, `raven-test-integration` `0384bb8`; before each merge `main` had no commits outside `net10-port`, after it `git diff net10-port main` was empty; pushed. From here: cases as their own commits on `main` with their own case numbers; the tag lines below run at release. An urgent 8.2.x fix would branch from `net8-baseline` (= 8.2.4):
|
||||
```powershell
|
||||
git switch net10-port; git status; git push; git fetch origin
|
||||
git switch main; git pull
|
||||
@@ -162,11 +162,82 @@ suite is waiting on (case 4653, case 4654, the contract issue).
|
||||
```
|
||||
`--no-ff` keeps a merge commit, so the whole port can be backed out with `git revert -m 1 <merge>`
|
||||
- [ ] Rebuild the client from `main`, publish, and test **the published artifact itself**
|
||||
- [ ] Rollout: own test droplet first, then customer droplets by time zone, as in the weekly routine
|
||||
- [ ] After uploading, download every installer and package back from ayanova.com and check its version: Windows installers by the .exe's ProductVersion, Linux zips by the `AyaNova.dll` inside. (Found 2026-10-25: the LAN installer on the website was still the 8.2.3 build, same file as downloaded on 2026-10-23, so 8.2.4 was never uploaded for LAN.)
|
||||
- [ ] Rollout: own test droplet first, then customer droplets by time zone, as in the weekly routine. **Subscription droplets are upgraded before the general release** (John, 2026-10-26, standing rule from 8.3.0 on): upload `latest-version.json`, which announces the release to every AyaNova installation, only after the last customer droplet is upgraded. Each droplet also gets the one-time hardening (`harden-830.sh`, case 4727) right before its upgrade
|
||||
- [ ] After release: delete `net10-port` locally and on the remote
|
||||
|
||||
---
|
||||
|
||||
## Release 8.3.0 cases
|
||||
|
||||
John's "next release" cases for AyaNova (exported 2026-10-24). Each is its own set of commits on
|
||||
`main` (`case NNNN - …`). Status: open unless noted. Security cases map to findings in
|
||||
`SECURITY-REVIEW.md` (finding IDs are permanent).
|
||||
|
||||
**Security review** (priority 00)
|
||||
|
||||
| Case | Name | Findings |
|
||||
|---|---|---|
|
||||
| 4710 | 2FA step requires the temp token (raven and sockeye) | F5, F41. **Done**: raven `141bdeb8` (proof script 401 on both bypasses; integration test `TwoFactor` in `8e5eec4`); sockeye `f7c1d7c` (same checks on local Sockeye with a temporary user). Sockeye to be deployed to its new droplet by John; 2FA can be turned back on once each server runs the fix |
|
||||
| 4711 | JWT secret setting removed: the server generates its signing key once into `jwt-signing.key` in the data folder (4698 folded in) | F1. **Done** (placeholder-forged token 401; key survives restart; auth tests pass). Hosted setup script's secret step now does nothing; tidy later in `sockeye-client` |
|
||||
| 4712 | Customer contact route allowlist + ownership checks | F13, F14, F15, F16, F17. In 6 steps: 1 allowlist **done**; 2 work-order ownership **done**; 3 CSR and unit ownership **done**; 4 pick lists for contacts **done**; 5 schedule staff rights **done**; 6 notification subscriptions **done**. All steps done. John clicked through the customer screens as Customer, HeadOffice and CustomerRestricted contacts (2026-10-24): no failures; contact attachments checked by API (own work order list and download work, another customer's 403) |
|
||||
| 4713 | Contact users limited to customer roles | F28. **Done** (validation refuses staff roles on contacts; contacts act with customer roles only, also on download routes; test `ContactRoles`) |
|
||||
| 4715 | Download file names checked (one helper) | F21. **Done** (`FileUtil.FullPathInFolderOrNull` in temp/backup exists checks and both log routes; test `DownloadNames`) |
|
||||
| 4716 | Pick-list search text as SQL parameters | F18, and F43 (unit pick-list variant parsed as a number, found during 4712, test `FetchUnitPickListCustomerVariant`). **Done** (`@plq`/`@pltag` parameters; test `FetchUserPickListAutoCompleteApostrophe` fails on the old code, passes now; 9/9 pick-list tests) |
|
||||
| 4717 | KPI interval allowlist | F26. **Done** (`KPIFetcher.DateTruncUnitOrNull` in the 7 KPIs reading `interval`; test `KpiInterval`) |
|
||||
| 4718 | Report and translation import need create rights | F19, F24. **Done** (contact and Service 403, BizAdmin 202; test `ImportRights`) |
|
||||
| 4719 | Report page network restricted; notification reports not run as SuperUser | F20. **Done**, plan changed with John: 1 report templates changeable only by BizAdmin (`2f616cba`, test `ReportEditRights`); 2 the page holds placeholders for the token and download key, the server adds the real ones only on requests to `ayApiUrl` (interception in every kind of install; test `ReportRenderCredentials`; 22 stock reports unchanged locally; IIS VM user and notification reports); outside addresses not blocked (customer templates may load their own content), notification reports keep their rights (customers write from reports). Visible: templates calling the API by another address need changing (manual and changelog); attachment links printed into reports carry no download key (manual only, John: such links pointed at 127.0.0.1 unless the override was set, so rarely worked) |
|
||||
| 4720 | Restricted roles filtered in report/export data | F27. **Done** (partial fetch filtered like the full one; BizObjectFactory passed UserType.Service for everyone, now the real type; test `RestrictedReportData`) |
|
||||
| 4721 | Client escaping of record text | F33. **Done** (raven-client: `escapeHtml` helper for the warranty summary; address dialog as text; search excerpt escaped with the terms highlighted as plain text; dialog messages through DOMPurify. The excerpt is not encoded on the server as well: the client escaping covers it. John checked search, warranty info with `<b>` in a vendor name, the address dialog and confirmation dialogs) |
|
||||
| 4722 | DOMPurify and Vuetify upgrades (raven-client) | F34. **Done** (raven-client `44411ec1`: DOMPurify 2.5.9, Vuetify 2.7.2, builds on Node 12.22.9; Vuetify 2.7's calendar `eventSummary()` returns an element, so the six calendar event templates render it with a new `gz-vnodes` component instead of `v-html`. John checked wiki, schedules (drag, stretch, lock icons), date pickers, lists, dialogs and the reminder and review widgets. John also checked the scheduled work order widget as a Tech user. Still to do: Cypress on `devtestn10` at the final gate) |
|
||||
| 4723 | Direct SMTP and direct notification: staff only, record rights | F3, F4. **Done**, plan revised with John: staff keep sending to any address (stock report template and a customer script use it); contacts refused in both actions, an address from a record needs read rights on it, direct notifications refused to subcontractors. Test `DirectSend` (the subcontractor case returned 204 on the old code). No changelog line: nothing that works changes |
|
||||
| 4724 | SMTP: certificate checked, password never returned | F30, F31. **Done** (certificate checked, opt-out config setting `AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE` with manual page; password masked as `********` and kept on save; test `SmtpPasswordHidden`; self-signed listener proof; changelog lines for both) |
|
||||
| 4725 | Sessions: password change ends sessions, exact-path token exemption, default SuperUser refused when licensed | F7, F8, F9. **Done** (sessions end on reset, administrator password change and login disabled; exact route exemption, inactive users refused; default-password SuperUser limited to changing it on a purchased licence, option A; test `Sessions`; John's click-through with a temporary licence override; manual and changelog) |
|
||||
| 4726 | Login throttling/lockout (raven and sockeye) | F6. **Done**; the Sockeye half (`6d6c74f`), like 4710's, takes effect when Sockeye is deployed to its droplet (10 failures per login or 50 per address in 15 minutes, then 429 for the rest of the window; same in Sockeye, whose login is public; test `LoginLockout`; per-address limit proved with a forwarded documentation address) |
|
||||
| 4727 | Low severity hardening | F11, F12, F22, F23, F25, F29, F35, F36, F38; the AyaNova half of F39 (DB ID not shown anonymously). In steps: 1 logo (F23) **done**; 2 API root page (F11, F39) **done**; 3 `server-info` (F29) **done**; 4 template export (F22) **done**, job owner check accepted; 5 `OverrideUserId` and export download (F25) **done**; 6 safe headers (F35) **done**; 7 remove `dist/docker` (F38) **done**. All steps done; `sockeye-client` setup script: bind `127.0.0.1`, the `chmod`, the systemd options (F36, F38) **done** (`9f300a6`, tested on `devtestn10` 2026-10-26; existing droplets get `harden-830.sh` at the rollout); the Linux server install and upgrade manual pages updated to match (permissions, service settings, `127.0.0.1` behind NGINX, `curl -fO`). **Case done.** F10 moved to case 4736, after 8.3.0 (John, 2026-10-26) |
|
||||
| 4681 | Sockeye: licence/trial request hardening (tagged sockeye, rockfish) | F40. **Done** (sockeye `cd3ebed`: rate limits, email check, cryptographic codes; rvf unchanged) |
|
||||
| 4732 | Trusted reverse proxies setting (from the Sockeye migration runbook, Appendix A) | F6 follow-up. **Done** (`AYANOVA_TRUSTED_PROXIES`: addresses or CIDR networks, chain followed, invalid entries warned at boot; one log warning per start when `X-Forwarded-For` comes from an untrusted proxy; manual page and changelog. Proved by hand through the LAN address: not set, logins recorded as the proxy and the warning logged; set, the forwarded client address used, a forged extra entry ignored, a two-proxy chain followed; localhost unchanged. Not needed in Sockeye: its nginx connects on loopback) |
|
||||
|
||||
**From the port and earlier triage**
|
||||
|
||||
| Case | Name | Priority |
|
||||
|---|---|---|
|
||||
| 4735 | Roles audit: every documented role right against `BizRoles.cs` (found in 4719, see Deferred) | 00. **Done** (`27b56db6`, `7959ad9c`). Audit: 60 manual role lists and 19 prose sections against 89 types (script); besides report templates (4719) 15 differences, one a false positive, decided with John 2026-10-25. **Code** (commit 1): Tech restricted reads tax codes, projects, unit models, meter readings; BizAdmin and restricted read server metrics; Accounting no longer changes meter readings (both meter reading routes in `UnitController` checked Unit rights, now meter reading rights); no Subcontractor roles on memos; integrations: data operations stay open to all full staff roles (third-party apps may sign in as any role, QBI as Accounting), switching Active and deleting are BizAdmin only. Test `RoleRights` (6; before: the 5 changed rights behaved the old way; Tech still enters meter readings). **Manual** (commit 2): travel rates role names (the page named non-existent "Travel" roles), customer service requests created by customer contacts. Work orders: Inventory was a false positive of the audit script (it read the page's part cost visibility list), the role lists are right. Subcontractors still cannot enter meter readings (never could; John: keep). Lookups readable by every role are worded in prose, no change |
|
||||
| 4692 | SuperUser can be deleted | 00. **Done** (`UserBiz.ValidateCanDelete` refuses user 1 with 2004 before any other check; the integration test `UserWithActivityShouldNotBeDeleteable`, which deleted SuperUser on a database where it had no related records and so was excluded from every run, is now `SuperUserShouldNotBeDeleteable` and runs with the rest: full suite = baseline, SuperUser still logs in afterwards; changelog) |
|
||||
| 4693 | FormFieldReference start-up race | 00. **Done** (both lazily built lists, `FormFieldReferenceList` and `FormFieldKeys`, are built in a local and published when complete, fields volatile; three restarts each followed at once by the full suite: baseline every time, no "is not valid" in the log) |
|
||||
| 4654 | General notification delivers at most once per 12 hours (circuit breaker) | 00. **Done** (direct messages are general notifications with no object, so the 12-hour repeat check in `CoreJobNotify` (case 3917, keyed on subscription and object) dropped every message to a user after the first; for general notifications the check now applies only to the job's own delivery error messages (`DELIVERY_ERROR_NAME`, "Error"); integration test `DirectMessage_SendAndDelete` un-skipped and now sends two messages: before, only the first arrived; after, both; full suite = baseline; changelog) |
|
||||
| 4694 | Standalone launcher not shutting down PostgreSQL (raven-launcher) | 1. **Done** (launcher: `pg_ctl stop -m fast -w` when the server exits, replacing the `StandardInput.Close()` that threw, and on window close (SIGHUP/CTRL_CLOSE); `standalone.iss`: `PrepareToInstall` and uninstall stop a running embedded PostgreSQL with the installed `pg_ctl`. Sandbox: 7 `postgres` processes left running as by 8.2.4, then the upgrade showed no "files in use" page and stopped them; Shut down server and closing the window each left no `postgres`. Note: closing the window or Ctrl+C already ended PostgreSQL in 8.2.4 (same console); only the in-app shutdown left it) |
|
||||
| 4695 | Shipping the Visual C++ runtime DLLs with the standalone install | 1. **Done** (`msvcp140.dll`, `vcruntime140.dll`, `vcruntime140_1.dll` 14.44.35211.0, Microsoft-signed, added to `dist/win-x64/postgres-standalone/bin`, app-local; BUILD.md notes they must be replaced by hand. Sandbox: fresh Windows with no runtime, install started PostgreSQL, trial, report 1.6 s) |
|
||||
| 4696 | JWT secret missing from the standalone config.json | 1. **Done, resolved by 4711, no change** (sandbox 2026-10-25: 8.2.4 warned at every boot that AYANOVA_JWT_SECRET is missing; after the upgrade to 8.3.0 the first boot generated `C:\ProgramDatayanova\jwt-signing.key`, the next boot reused it with no warning, and a fresh 8.3.0 install did the same) |
|
||||
| 4697 | Relative date filters using the server's time zone | 1. **Done** (`BuildSQLBetweenTwoTokenDerivedDates` converts the user's local boundaries to UTC with `clientTimeStamp.Offset`, was `ToUniversalTime()` with the server's zone; the dashboard KPIs use the same builder, so their ranges are fixed too. Test `WorkOrderList_TodayIsTheUsersToday`: a work order at 00:30 today for a +10:00 user, before missing from *today*, after included; full suite = baseline. The manual already promised the user's time zone (`ay-start-data-types.md`, `ay-start-form-data-tables.md`), no manual change; changelog) |
|
||||
| 4699 | Linux desktop data-path note in the manual | 1. **Done** (`ops-install-linux-desktop.md` step 4: no `sudo mkdir ~/.ayanova-data`; AyaNova creates the folder, `~` is root's home under sudo, full path for another location) |
|
||||
| 4700 | Restore note for customers with a dedicated database role | 1. **Done** (`ops-restore.md`: `--no-owner --no-privileges --role=<user>`) |
|
||||
| 4703 | Report JS console error logging | 1. **Done** (error messages were already logged; what was lost: object arguments shown as `JSHandle@object`, no stacks. Now each `ERROR:` line is logged at once as before, then each object or Error argument is fetched from the page in the background and added as a `detail for "<message start>":` line (object JSON cut at 1000 characters; error stack without Puppeteer frames, paths shortened to file names); a failing render waits up to 2 s for pending details; page log appends locked (console thread, relay). Better than Sockeye's version, which loses the whole line when fetching details fails. Test `ConsoleErrorDetailsAreLogged`; 22 stock reports unchanged; manual (report designer, Script errors) and changelog) |
|
||||
| 4705 | License.DoFetchAsync never tries the fifth licence server | 1. **Done** (rethrow after the last server, `x == 4`; the trial request (`rvr`, rockfish only) now uses the same fallback loop, John's decision. Sandbox with rockfish, io, europa and callisto pointed at 127.0.0.1: the old build failed the trial request with a connection error, the new build reached sockeye.ayanova.com. No changelog: only noticeable if the licence server names fail) |
|
||||
| 4706 | Remove the unused RunProgram.Run | 1. **Done** (`Run` and its `RunWindows`/`RunLinuxShell`/`RunOSX` removed, no callers; `RunNoShell` stays. Backup through the API wrote the database dump and attachments zip; full suite = baseline) |
|
||||
| 4707 | Stray cypress.json in raven-client | 1. **Done** (raven-client `f2f10b81`: removed; from 2020, pointed at `tests/e2e`, which moved to `raven-test` in 2022 (`e0be8a7c`); Cypress is not a client dependency) |
|
||||
| 4687 | Generator polls every second (raven and sockeye) | 1. **Closed, no change** (John, 2026-10-26). Measured on an idle local server over 60 s: 4.5 database transactions per second (about 315 rows read per second, mostly the ready-jobs checks), AyaNova CPU 0.14 s per minute (0.2 % of one core). Wake-on-demand (5 s sleep, woken by `JobsBiz.AddJobAsync`) was considered and rejected: small gain, timing change in the loop that runs every background job |
|
||||
| 4682 | Time-only picker keeps the old date's DST offset | 1. **Done** (John: the backup time is the server's clock time, the schedule's first hour the user's. Backup: the time is stored as the UTC moment on the day it was set and was run at that UTC hour daily, so it moved an hour on the server's clock at each DST change (John saw backups start at an unexpected time); `ServerGlobalOpsSettingsCache.SetNextBackup` now recovers the clock time with the stored date's offset and runs it on the server's local days (`BackupTimeOnLocalDay`; a time skipped when clocks go forward runs an hour later). Proof (temporary log line, removed): 11:30 PM set in January (07:30Z) gave 00:30 PDT before, 23:30 PDT after. Schedule: `home-schedule.vue` already keeps `firstTime` as local "HH:MM", no change. No migration. Manual (backup time) and changelog. Sockeye has the same code: same fix in sockeye, takes effect when Sockeye is next deployed (matters for the droplet cron job that copies backups if its tzdata still has DST for America/Vancouver). Note: this workstation's Windows zone is "Pacific Time (US & Canada)", which still falls back on 2026-11-01 although BC abolished DST; AyaNova follows the OS zone) |
|
||||
| 4661 | Docs: password guidance | 1. **Done** (`home-password.md`, `ay-start-login.md`: no lyrics/quotes advice; password manager or six dice-chosen EFF words; no length or character rules) |
|
||||
| 4636 | Docs: turn off 2FA before v7 to v8 migration | 1. **Done** (`ops-import-v7.md`: 2FA off on the v8 SuperUser for the migration; also, from 4725, change the default SuperUser password first on a purchased license) |
|
||||
| 4634 | Docs: custom checkbox shows nothing for older records | 1. **Done** (`ay-customize.md`, "Checkbox custom fields on existing records") |
|
||||
| 4633 | Docs: pg_dump path quoting on Windows (likely fixed by the port; confirm) | 1. **Done, confirmed fixed by the port** (backup with `AYANOVA_BACKUP_PG_DUMP_PATH` in a folder path with spaces: dump written, no errors; no manual workaround existed; changelog backup line extended) |
|
||||
| 4632 | AYANOVA_REPORT_RENDER_API_URL_OVERRIDE breaks customer notification reports (do before or with 4719) | 1. **Done.** Cause, reproduced on a Windows Server 2025 Hyper-V VM (IIS, https-only site with a host name, as for internet access): AyaNova runs inside IIS (in-process), so nothing listens on `AYANOVA_USE_URLS`; user reports called `http://127.0.0.1:<port of the request>` (443 over https: connection reset), notification reports `127.0.0.1:7575` (refused); the override only fixed user reports, and only where the server reaches itself by its public name. Fix: under IIS the report page's requests under the API URL are intercepted (PuppeteerSharp request interception) and made by the server (`util/ReportApiRelay.cs`) to the local address and port a user's request arrived on, with its host name and scheme (IIS binding and certificate match, no DNS/router hairpin); notification reports use the latest signed-in request, else the override; the page is opened at the API origin so its calls are same-origin; outside IIS nothing changes. The notification token check no longer requires host `127.0.0.1`, only a request from this machine. Proof: VM before (both fail) and after (user report 1.6 s and notification email with PDF, https-only with and without the override); locally 22 stock reports identical with the relay forced on (temporary switch, removed); integration suite = baseline. Manual: IIS install page and override page; changelog |
|
||||
| 4628 | Deleting a work order tied to a CSR gives a raw server error | 1. **Done** (the request's `workorderitemid` foreign key blocked deleting the item, a 500 from the database; John chose: delete, unlink the request and set it back to Open, no notification. `WorkOrderBiz.ItemDeleteAsync` does that in the delete's transaction and logs a Modified event on the request; covers deleting the item alone too. Test `WorkOrderFromServiceRequest`: before 500, after 204 and the request Open and unlinked; full suite = baseline. Manual (service requests) and changelog) |
|
||||
| 4623 | Docs: migration, what is erased and what is kept | 1. **Done** (`ops-import-v7.md`: Erased and Kept lists from `DbUtil.EmptyBizDataFromDatabaseForSeedingOrImportingAsync`; tax codes erased by the migration) |
|
||||
| 4602 | Logo upload UI: hide Upload and Delete until usable | 1. **Done** (raven-client: logo form shows file choosers only with change rights, Upload once a file is chosen, Delete only for an existing logo; John added the attachment control's Upload, now shown once files are selected. Import, report template and translation uploads already did this. Checked by John as BizAdmin and BizAdmin restricted. No changelog) |
|
||||
| 4579 | Windows installer modify rights: only the folders AyaNova writes to, only for the account it runs as (includes F2) | 00. **Done** (both installers: no `users-modify` on `{app}` or `local-postgres`; `icacls "{app}" /remove:g *S-1-5-32-545 /T /C /Q` after install removes the Users grant on upgrades too; data folder keeps `users-modify` and `config.json` stays readable by local users, accepted by John and documented in the single-user and LAN install pages. Sandbox: single-user fresh and upgrade over 8.2.4, LAN upgrade; Users modify entries under `{app}` 4632/1281 before, 0 after; trial, login, attachment, backup, report render after upgrade. Found on the way: the downloaded "8.2.4" LAN installer installs server 8.2.3, which fails its integrity check on a new PostgreSQL 18 database (E1030); gone once the 8.3.0 LAN installer is published) |
|
||||
| 4690 | .NET 10 port umbrella case | 1 |
|
||||
|
||||
Sockeye cases (exported 2026-10-24): 4710 (also in the table above) and 4681. 4681 predates the review: a trial request with a made-up address made Sockeye send its verification email, which bounced to support@ (Fastmail notice, 2026-10-16).
|
||||
|
||||
Moved out of 8.3.0 by John (2026-10-24): 4665 (health route checks the database) goes to the release after.
|
||||
|
||||
Later case to create (John, 2026-10-24): **repeatable licence fetch and server fingerprint**. AyaNova skips installing a fetched key identical to the installed one and adds to its rvf request a flag (it accepts repeat replies) plus a fingerprint: version, build (perpetual or subscription), platform, installation type (standalone, Windows LAN/IIS, Linux server, Linux desktop); Sockeye returns already-fetched keys only to flagged requests (older servers keep the one-shot reply) and stores the last fingerprint per DB ID, so John can see which versions and installation types are in use (e.g. whether standalone can be deprecated). Takes the Sockeye half of F39. Mention in the manual that the server sends this information.
|
||||
|
||||
Later, not 8.3.0: F42 (move the licence signing key out of Sockeye source) and F32 (rotate the
|
||||
FastMail password after the old Sockeye server is retired), John's cases.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Release version number (Sockeye's port was released as 8.1.0) Answer: 8.3.0 will be the version
|
||||
@@ -181,22 +252,42 @@ suite is waiting on (case 4653, case 4654, the contract issue).
|
||||
|
||||
Found during the port, out of scope. One line each.
|
||||
|
||||
- `License.DoFetchAsync` (`License.cs:629-661`) rethrows at `x == 3`, so the fifth server (Sockeye) is never tried, and it can return `null` to a caller that dereferences it
|
||||
- Stray `raven-client\ayanova\cypress.json`: the Cypress suite lives in `raven-test\e2e`
|
||||
- **Race, affects production after every restart:** `FormFieldReference.FormFieldReferenceList` (`biz/FormFieldReference.cs:57-59`) builds a static dictionary lazily with no lock, assigning it before filling it. A concurrent first request sees it half-built and throws `FormFieldReferenceList: <type> is not valid`. Seen twice on .NET 10, each time on the first form-field use after boot: `VendorCrud.CRUD`, then `FormCustom.FormCustomUpdate`. Fix: build the dictionary fully before publishing it (`Lazy<T>` or a static initialiser). Pre-existing, not a port change. John's decision: its own case, fixed with the other cases going into this release, not under the port
|
||||
- `raven-launcher` `Program.cs:98` calls `PGProcess.StandardInput.Close()` but standard input was never redirected, so it throws `InvalidOperationException` at shutdown: the "Postgres shut down" step never runs and the launcher exits with an error when the server stops. Postgres was started detached by `pg_ctl start`, so it probably stays running. Pre-existing; candidate for its own case. **Confirmed in the Sandbox test (8.2.4 and 8.3.0):** after "Shut down server" the launcher crashes with exactly this exception and 7 `postgres` processes stay running. Every standalone upgrade then stops at Setup's "files in use" page listing PostgreSQL Server, and "automatically close the applications" fails ("Setup was unable to automatically close all applications"): the customer must end the processes or restart Windows first. One-line fix (stop PostgreSQL with `pg_ctl stop` instead of writing to its standard input). **John, 2026-10-23: own case, fixed with the "next release" cases before 8.3.0**
|
||||
- **Standalone install needs the Visual C++ runtime (pre-existing, same in 8.2.4; John, 2026-10-23: own case, fixed with the "next release" cases before 8.3.0):** the bundled PostgreSQL (`local-postgres\bin\pg_ctl.exe`, `postgres.exe`) needs `VCRUNTIME140.dll`, which neither the installer nor `local-postgres` provides. A Windows without it (a new PC, Windows Sandbox) shows "pg_ctl.exe - System Error: VCRUNTIME140.dll was not found" and AyaNova never starts. Confirmed fix: copying `vcruntime140.dll`, `vcruntime140_1.dll` and `msvcp140.dll` (x64, 14.44) into `local-postgres\bin` made PostgreSQL start. Ship those three in `dist\win-x64\postgres-standalone\bin` (app-local, allowed by Microsoft's redistribution terms) rather than running `vc_redist.x64.exe` (it crashed Windows Sandbox mid-install)
|
||||
- Standalone `config.json` (launcher template) has no `AYANOVA_JWT_SECRET`, so every boot logs a warning and generates a random one (logins are lost on restart). Pre-existing; noted from the Sandbox test logs. **John, 2026-10-23: own case, fixed with the "next release" cases before 8.3.0**
|
||||
- Restore manual (`ops-restore.md`) covers only the default `postgres` setup. A customer running AyaNova under a dedicated, non-superuser role who restores as `postgres` without `--role` ends up with tables owned by `postgres` and a failed integrity check at boot (found in the section 4 role test). Add a short note: `pg_restore --no-owner --no-privileges --role=<role>`. Pre-existing; after the release
|
||||
- **For the security review after 8.3.0:** the LAN/IIS (`dist\assets\lan-install-config.json`) and Linux server (`dist\assets\linux-server\config.json`) templates ship `AYANOVA_JWT_SECRET` set to a placeholder that is printed in the manual. The manual tells users to change it (optional on Linux), but an install that keeps it signs logins with a publicly known secret. Consider generating a random secret at install time or refusing to boot with the placeholder. Pre-existing
|
||||
- **Fixed in case 4762.** `NotifyController.SendNotifySmtpDirectMessage`, User branch: the missing-address and inactive messages say "Vendor" instead of "User" (found in case 4723)
|
||||
- SMTP connection security mapping (`Mailer.cs`): "StartTls" connects with `useSsl: true`, which MailKit treats as TLS from the start (port 465), not STARTTLS (587); "None" uses STARTTLS when offered, which can be stripped. Correcting it could break customers using "StartTls" on port 465 (found in case 4724)
|
||||
- Integration test `DownloadNames` logs in again as the shared `Sales` user to get a download key, which replaces the token the other tests use for Sales while running in parallel (one session per user). Tests that need a download key should create their own user, as `TemplateExportRights` does (found in case 4727). Also fragile under parallel runs: `DataListOperations.WorkOrderList_PaginationOffsetAndLimitWork` fetches two pages of work orders and checks they do not overlap, while `WorkOrderCrud` creates and deletes work orders (failed once in case 4727)
|
||||
- From F6 (case 4726), split off as separate changes: a password strength rule (not empty is the only rule today; seeded trial users use their login as password) is a new, visible feature; `limit_req` on `/api/v8/auth` in the hosted nginx configuration belongs in the `sockeye-client` setup script and affects every hosted droplet. Both are second layers behind the login lockout
|
||||
- Race in the notification system: a record saved while a matching notification subscription is being deleted fails with 500 (`anotifyevent_notifysubscriptionid_fkey` violation), and the user's save fails with it. Seen once in the integration suite (`CustomerCrud.CRUD`, run in parallel with subscription tests) during case 4724
|
||||
- **Case 4731 (John, priority 1, after 8.3.0):** a global notification setting "allow email to any address from the API and report scripts" for `notify/direct-smtp`; off for new installs, on for upgraded databases; when off, only a record's saved address. Follows from F3 / case 4723
|
||||
- **Fixed in case 4705.** `License.DoFetchAsync` (`License.cs:629-661`) rethrows at `x == 3`, so the fifth server (Sockeye) is never tried, and it can return `null` to a caller that dereferences it
|
||||
- **Removed in case 4707.** Stray `raven-client\ayanova\cypress.json`: the Cypress suite lives in `raven-test\e2e`
|
||||
- **Race, affects production after every restart:** `FormFieldReference.FormFieldReferenceList` (`biz/FormFieldReference.cs:57-59`) builds a static dictionary lazily with no lock, assigning it before filling it. A concurrent first request sees it half-built and throws `FormFieldReferenceList: <type> is not valid`. Seen twice on .NET 10, each time on the first form-field use after boot: `VendorCrud.CRUD`, then `FormCustom.FormCustomUpdate`. Fix: build the dictionary fully before publishing it (`Lazy<T>` or a static initialiser). Pre-existing, not a port change. John's decision: its own case, fixed with the other cases going into this release, not under the port. **Fixed in case 4693 (2026-10-24)**
|
||||
- **Fixed in case 4694.** `raven-launcher` `Program.cs:98` calls `PGProcess.StandardInput.Close()` but standard input was never redirected, so it throws `InvalidOperationException` at shutdown: the "Postgres shut down" step never runs and the launcher exits with an error when the server stops. Postgres was started detached by `pg_ctl start`, so it probably stays running. Pre-existing; candidate for its own case. **Confirmed in the Sandbox test (8.2.4 and 8.3.0):** after "Shut down server" the launcher crashes with exactly this exception and 7 `postgres` processes stay running. Every standalone upgrade then stops at Setup's "files in use" page listing PostgreSQL Server, and "automatically close the applications" fails ("Setup was unable to automatically close all applications"): the customer must end the processes or restart Windows first. One-line fix (stop PostgreSQL with `pg_ctl stop` instead of writing to its standard input). **John, 2026-10-23: own case, fixed with the "next release" cases before 8.3.0**
|
||||
- **Fixed in case 4695.** **Standalone install needs the Visual C++ runtime (pre-existing, same in 8.2.4; John, 2026-10-23: own case, fixed with the "next release" cases before 8.3.0):** the bundled PostgreSQL (`local-postgres\bin\pg_ctl.exe`, `postgres.exe`) needs `VCRUNTIME140.dll`, which neither the installer nor `local-postgres` provides. A Windows without it (a new PC, Windows Sandbox) shows "pg_ctl.exe - System Error: VCRUNTIME140.dll was not found" and AyaNova never starts. Confirmed fix: copying `vcruntime140.dll`, `vcruntime140_1.dll` and `msvcp140.dll` (x64, 14.44) into `local-postgres\bin` made PostgreSQL start. Ship those three in `dist\win-x64\postgres-standalone\bin` (app-local, allowed by Microsoft's redistribution terms) rather than running `vc_redist.x64.exe` (it crashed Windows Sandbox mid-install)
|
||||
- **Resolved by case 4711 (confirmed in case 4696).** Standalone `config.json` (launcher template) has no `AYANOVA_JWT_SECRET`, so every boot logs a warning and generates a random one (logins are lost on restart). Pre-existing; noted from the Sandbox test logs. **John, 2026-10-23: own case, fixed with the "next release" cases before 8.3.0**
|
||||
- **Done in case 4700.** Restore manual (`ops-restore.md`) covers only the default `postgres` setup. A customer running AyaNova under a dedicated, non-superuser role who restores as `postgres` without `--role` ends up with tables owned by `postgres` and a failed integrity check at boot (found in the section 4 role test). Add a short note: `pg_restore --no-owner --no-privileges --role=<role>`. Pre-existing; after the release
|
||||
- **Security review F1, fixed in 8.3.0:** the LAN/IIS (`dist\assets\lan-install-config.json`) and Linux server (`dist\assets\linux-server\config.json`) templates ship `AYANOVA_JWT_SECRET` set to a placeholder that is printed in the manual. The manual tells users to change it (optional on Linux), but an install that keeps it signs logins with a publicly known secret. Consider generating a random secret at install time or refusing to boot with the placeholder. Pre-existing
|
||||
- Standalone and LAN installers share the same Inno Setup `AppId` (`{B2BB79DB-0690-497C-B92B-955F7B468429}`) although they are different products (`AyaNova` vs `AyaNova server`): installing one over the other would be treated as the same application. Pre-existing; not tested. Both also install to the same folder (`{autopf}\ayanova`) and use port 7575, so the two flavours could never sit side by side on one machine anyway; the shared `AppId` only changes what happens when one flavour is installed **over** the other (e.g. an evaluator moving from standalone to LAN on the same PC): Setup treats it as an upgrade of the same program. Changing either `AppId` now would make that flavour's next installer miss existing installs (second Add/Remove entry over the same folder). Standalone is thought to be used almost only for evaluation (John). **John, 2026-10-23: keep both `AppId`s as they are**; the Windows install pages now say only one type per computer, uninstall the other first (backup and restore to switch)
|
||||
- Sockeye 8.1.0 has the NLog 6 archive naming problem fixed here for AyaNova: archives are `log-sockeye_NN.txt`, old `-0` to `-8` archives are never deleted, and its log viewer still sorts by name (it also filters on `ayanova.txt`, not `sockeye.txt`, so the current file is listed twice). Same fix applies: `ArchiveSuffixFormat = "-{0}"` and the date sort
|
||||
- **SuperUser can be deleted (serious, pre-existing, own case; John's decision 2026-10-23: fixed with the other "next release" cases before 8.3.0 ships, not under the port):** `UserBiz.ValidateCanDelete` (`biz/UserBiz.cs:1055`) only refuses a delete when the user has related records; nothing protects user id 1, although updates to it are blocked (`UserBiz.cs:946`). On the droplet, whose sample data left SuperUser with no related records, the integration test `UserWithActivityShouldNotBeDeleteable` (`DELETE User/1`) succeeded and removed the account; `AYANOVA_SET_SUPERUSER_PW` cannot bring it back (it only resets an existing user 1). Same code in 8.2.4; likely also why `superuser` could not log in on the reused local database at the first baseline attempt. Any user with delete rights on Users can do this
|
||||
- `RunProgram.Run` (and its `RunWindows`/`RunLinuxShell`/`RunOSX` helpers) has no callers since the backup moved to `RunNoShell`. It runs through `cmd.exe /C` or `/bin/sh -c` and logs its full command line on error: delete it rather than reuse it
|
||||
- **Security review: customer contact isolation (John, 2026-10-23: urgent, fixed with the "next release" cases before 8.3.0; case numbers to come).** Findings F13 to F17 in `devdocs/modernization/SECURITY-REVIEW.md`, confirmed on the local server as a seeded customer contact: any customer's work order by id (and, by code, its customer report and attachments); other customers' service requests; the full customer list with phones and emails through pick lists; the whole service schedule; notification subscriptions created for another user. Hosted and self-hosted perpetual installations are equally important. Fix direction in the review: block customer-type users by default except the routes the customer UI uses, then add a "belongs to my customer" check to each allowed route that takes an id. F5 (2FA without password), F1 (placeholder JWT secret) and F18 (pick-list search text in SQL) are fixed in 8.3.0 too (John, 2026-10-23). **8.3.0 is not released until every security review finding is fixed or explicitly accepted by John** ("all holes must be plugged"); the review's remaining passes therefore come before the release. Front-end exception (John, 2026-10-23): DOMPurify to 2.5.x and Vuetify to the latest 2.x (2.7.2, or 2.6.16 if 2.7 does not build on Node 12) for security finding F34, each proven by a full client build and Cypress
|
||||
- **Fixed in case 4692.** **SuperUser can be deleted (serious, pre-existing, own case; John's decision 2026-10-23: fixed with the other "next release" cases before 8.3.0 ships, not under the port):** `UserBiz.ValidateCanDelete` (`biz/UserBiz.cs:1055`) only refuses a delete when the user has related records; nothing protects user id 1, although updates to it are blocked (`UserBiz.cs:946`). On the droplet, whose sample data left SuperUser with no related records, the integration test `UserWithActivityShouldNotBeDeleteable` (`DELETE User/1`) succeeded and removed the account; `AYANOVA_SET_SUPERUSER_PW` cannot bring it back (it only resets an existing user 1). Same code in 8.2.4; likely also why `superuser` could not log in on the reused local database at the first baseline attempt. Any user with delete rights on Users can do this
|
||||
- **Removed in case 4706.** `RunProgram.Run` (and its `RunWindows`/`RunLinuxShell`/`RunOSX` helpers) has no callers since the backup moved to `RunNoShell`. It runs through `cmd.exe /C` or `/bin/sh -c` and logs its full command line on error: delete it rather than reuse it
|
||||
- **Own case after the port (John): automated provisioning from Sockeye.** Goal: from a trial subscription server request, one menu item in Sockeye (after John's manual approval) provisions the whole server through the DigitalOcean API, ready for login: droplet, DNS, firewall, setup, licence. Replaces today's manual steps (header comments of the setup script), which are slow and error-prone. Not part of this port
|
||||
- Linux desktop manual (`ops-install-linux-desktop.md`), pre-existing, same in 8.2.4: `sudo mkdir ~/.ayanova-data` creates the folder in the user's home, but `sudo ./AyaNova` runs as root, so `AYANOVA_DATA_PATH` `~/.ayanova-data` resolves to `/root/.ayanova-data` and the user's folder is never used (data, logs and backups end up under `/root`). Found in the 8.3.0 desktop zip test. Fix the manual (absolute data path, or not running as root) after the release
|
||||
- **Done in case 4699.** Linux desktop manual (`ops-install-linux-desktop.md`), pre-existing, same in 8.2.4: `sudo mkdir ~/.ayanova-data` creates the folder in the user's home, but `sudo ./AyaNova` runs as root, so `AYANOVA_DATA_PATH` `~/.ayanova-data` resolves to `/root/.ayanova-data` and the user's folder is never used (data, logs and backups end up under `/root`). Found in the 8.3.0 desktop zip test. Fix the manual (absolute data path, or not running as root) after the release
|
||||
- **Own case after the port (John):** the subscription server setup script is hard-coded in `biz-subscription-server.vue`, so every change needs a new Sockeye client build. Make it editable without a rebuild
|
||||
- **Improvement, own case after the port (John to create):** bring back logging of JavaScript error details from report page console errors, as Sockeye's port did. AyaNova logs only `ERROR: {message text}`; the per-argument detail (`arg instanceof Error ? arg.message : arg`) is commented out at `biz/ReportBiz.cs:873-876` since case 4586, because `IJSHandle.ExecutionContext` was removed. Sockeye 8.1.0 (`server/biz/ReportBiz.cs:575`) evaluates on the handle instead: `args.Message.Args.Select(arg => arg.EvaluateFunctionAsync("(arg) => arg instanceof Error ? arg.message : arg"))`, which also needs the console handler to be `async` again
|
||||
- **Done in case 4703.** **Improvement, own case after the port (John to create):** bring back logging of JavaScript error details from report page console errors, as Sockeye's port did. AyaNova logs only `ERROR: {message text}`; the per-argument detail (`arg instanceof Error ? arg.message : arg`) is commented out at `biz/ReportBiz.cs:873-876` since case 4586, because `IJSHandle.ExecutionContext` was removed. Sockeye 8.1.0 (`server/biz/ReportBiz.cs:575`) evaluates on the handle instead: `args.Message.Args.Select(arg => arg.EvaluateFunctionAsync("(arg) => arg instanceof Error ? arg.message : arg"))`, which also needs the console handler to be `async` again
|
||||
- **Fixed in case 4652** (work orders, quotes and PMs now leave the missing record out; the other object types already did). Pre-existing, same in 8.2.4 (found in the 4579 sandbox test, John: Deferred): rendering a report for a record that does not exist (e.g. work order ids 1 and 2 in a trial without sample data) fails the job with a generic `ArgumentNullException` ("Value cannot be null. (Parameter 'o')") from `JObject.FromObject` in `WorkOrderBiz.GetReportData` (`WorkOrderBiz.cs:1030`), instead of skipping the missing record or saying it is not found. Other object types' `GetReportData` likely do the same
|
||||
- **Roles audit, case 4735 (John, 2026-10-25, this release; found in 4719):** check every object's rights as documented in the manual (each page's "Authorization Roles required" section and `ay-biz-admin-roles.md`) against `biz/BizRoles.cs`. Report templates were changeable by Business administration - restricted although the roles page uses exactly that case as its example of a view-only restricted role (fixed in 4719). If that slipped through there could be others; for each mismatch decide whether the code or the page is wrong
|
||||
- **Fixed in case 4739** (the fallback is now the browser's own offset as an `Etc/GMT` zone in whole hours; Chrome and Edge 156 already name the BC zone `America/Vancouver`, and Chrome 155, which does not, also uses offset -8 for it, so a BC user on such a browser is one hour off instead of three: the time zone override remains the fix for them). Found 2026-10-27 (John: later case, no BC customers): when the browser cannot name its time zone, the client uses `America/New_York` (`raven-client` `api/locale.js` `getResolvedTimeZoneName`; Sockeye's client the same), so every date and time is shown, entered and sent to reports in New York time. Windows' new "British Columbia Standard Time" zone (BC permanent UTC−7 from November 2026) does this: Chrome returns `undefined` for it, although it knows `America/Vancouver`'s new rules. Seen on John's PC as backup times three hours late on devtest and Sockeye (the servers were right). Workaround: the time zone override in user settings. Fix: fall back to the browser's current UTC offset (fixed-offset zone) instead of New York
|
||||
- Found in the 8.3.0 Cypress runs (2026-10-26; pre-existing, 8.2.4 too): on a first visit over a slow connection the login page can move the keyboard focus back to the username field while it settles (the field has `v-focus` and is re-inserted when the page redraws), so part of a password typed early lands in the username field in plain view; and the service worker reloads the page once when it takes control (`App.vue`, `controllerchange`), clearing both fields. Both within the first seconds of a first visit
|
||||
- From case 4697 (John: Deferred): (1) a relative date range that crosses a daylight saving change can be an hour off at its far end, because the client sends only its current offset with list requests; exact would need the client to send its IANA time zone name too (front-end change). (2) Dashboard widgets group by day, week or month with `date_trunc` on the stored UTC times, so for a user far from UTC an item near midnight lands in the neighbouring bar; same need for the time zone name
|
||||
- From case 4727 (John: later hardening case, 2026-10-26): AyaNova runs as `www-data` on droplets and in the Linux manual (as in Microsoft's ASP.NET Core with NGINX guide), the same account as NGINX's workers, so NGINX can read `config.json` (database password) and write `data`. A dedicated `ayanova` system account isolates them: service `User=`, ownership, the setup script and the manual, plus a migration for existing droplets and self-hosted installs
|
||||
- Found in the case 4727 droplet test (2026-10-26; John: later case, early in a future release so every other test exercises it): stopping the service sometimes logs `ObjectDisposedException` from `JobsBiz.ProcessJobsAsync`, and the server-problem notification it tries to raise fails the same way, so nothing is sent. Pre-existing: 8.2.4 on devtest logged it at 2 of its last 40 stops (2026-09-10, 2026-10-01). Cause: `GeneratorService.StopAsync` (`generator/Generate.cs`) is overridden to return at once without calling `base.StopAsync`, so the host neither signals the job loop nor waits for it, and disposes its services while a pass may still be running; `Task.Delay(1000)` also ignores the stopping token. Fix: remove the override (or call the base) and pass the token to `Task.Delay`; test stopping during a report render and a backup, since shutdown would then wait for the running pass
|
||||
- From case 4755 (tidy-up, no behaviour change): `SetNextBackup` no longer reads `NextBackup`'s previous value, so the startup line `NextBackup = FileUtil.MostRecentAutomatedBackupFileDate()` (`util/ServerGlobalOpsSettingsCache.cs:50`), the reset to `DateTime.MinValue` in `GlobalOpsBackupSettingsBiz` and `FileUtil.MostRecentAutomatedBackupFileDate` itself are dead. Same in Sockeye
|
||||
- Found in case 4755's integration run (2026-10-31): a 500 response produced by the exception handler (e.g. `/` on a debug server whose `wwwroot` has no client) has none of the three safe headers from case 4727 (F35), because the handler clears headers set before it. Integration test `ApiRoute.SafeHeadersOnEveryResponse` fails for that reason on a debug server without the client in `wwwroot`; it passes on a release build
|
||||
- **Fixed in case 4761.** Pre-existing (found in the 4632 IIS test, John: Deferred): emailed report attachments have the content type `application/pdf/pdf`, because `Mailer.cs:51` builds `new MimePart("application/pdf", "pdf")` (media type and subtype) instead of `new MimePart("application", "pdf")`. Mail programs still open the file by its `.pdf` name
|
||||
- Found in case 4653 (pre-existing): `CustomerBiz.ValidateCanDeleteAsync` refuses to delete a customer that has contacts (users), yet `DeleteAsync` then has code to delete the customer's contacts, which can never run. Its comment says contacts were meant to be deleted with the customer, and that the check is there "for now". Decide which behaviour is wanted; same in Sockeye
|
||||
- Found in case 4739: reports rendered by the server for customer notifications use the subscription's time zone, and when it is empty `resource/rpt/ay-report.js` falls back to `America/Winnipeg` (date, time and date-time helpers), an arbitrary zone like the client's old New York default. Decide on a better default (the server's own zone?)
|
||||
- Found in case 4683 (pre-existing, raven-client and sockeye-client): the **Today** and **Now** buttons of the date, date-time and time pickers (`components/date-time-control.vue`, `time-control.vue`) take the date and time from the browser's own clock (`DateTime.local()`), not from the user's time zone override. The moment set by Now is still right (the time carries the browser's offset), but for a user whose override zone is far from the browser's zone, Today near midnight can pick the neighbouring day
|
||||
|
||||
## Log
|
||||
|
||||
@@ -238,3 +329,6 @@ Found during the port, out of scope. One line each.
|
||||
| 2026-10-23 | PostgreSQL link and version for Windows network installs | The PostgreSQL install guide linked from the LAN installer prompt and the LAN/IIS manual pages (`postgresqltutorial.com/install-postgresql/`) now fails (Cloudflare error; the site moved to a database vendor, Neon). Replaced with the official `https://www.postgresql.org/download/windows/` in `lan.iss` and both pages; prompt text "Open the PostgreSQL download page now?". `AYANOVA_BACKUP_PG_DUMP_PATH` in `lan-install-config.json` and the manual examples: PostgreSQL 16 → 18, with a note to change the number to the installed version. Changelog lines added. Docs build clean; LAN installer compiled to `C:\temp\ayanova-sandbox\ayanova-windows-x64-lan-setup-pglink.exe` for the LAN test |
|
||||
| 2026-10-23 | Windows LAN installer in a fresh Windows Sandbox, following `ops-install-windows-lan.md`; installer from this branch with the PostgreSQL link fix (`ayanova-windows-x64-lan-setup-pglink.exe`) | Hosting Bundle prompt opened the .NET 10 page; .NET 10.0.13 Hosting Bundle installed (very slow in the Sandbox, normal there). PostgreSQL prompt opened `postgresql.org/download/windows/`, which links to EDB; PostgreSQL 18.6-5 installed (it installs the Visual C++ runtime itself, no Sandbox crash with vGPU off). `config.json` edited (password, own JWT secret; the Sandbox has no Notepad, edited with PowerShell). AyaNova 8.3.0 boots on .NET 10.0.13, database created at schema 20, no JWT warning, no `Chrome` folders. After licensing (John), an on-demand backup succeeded with the template's `PostgreSQL\18\bin` pg_dump path. Windows Firewall prompt on every start in the Sandbox (fresh Windows each session), expected |
|
||||
| 2026-10-23 | Checklist tidy-up: older unticked items in sections 0–5 checked against current evidence | `main` = `origin/main` = `net8-baseline` in `raven` and `raven-client`; tag on both remotes. Licence gate 730/730 at `181b1e44`. No EF Core warning raised as an error in any log (local dev server, Sandbox, `devtestn10`). `System.Text.Json` 5.0.2 gone from the graph. JwtBearer 10.0.13 done. Report and E2E baselines marked waived (John); Cypress stays open in section 4 and now also covers the UI side of the EF Core screen-by-screen item. Restore manual compared: fine for the default `postgres` setup; dedicated-role note added to Deferred. Stale "still to do" remarks removed. Still open: relative date tokens (section 3, decision needed), Cypress, and the section 5 release steps |
|
||||
| 2026-10-23 | `net10-port` merged into `main` (`--no-ff`, no tag) in `raven`, `raven-client`, `raven-launcher`, `raven-test-integration` (John) | Pre-checks: all clean and pushed; `main` = `origin/main` with no commits outside `net10-port`. Post-checks: `git diff net10-port main` empty in all four; pushed. `CLAUDE.md` "Current work" now describes the 8.3.0 release on `main`; stack table updated to .NET 10; dependency rule no longer refers to the port branch. `net10-port` branches kept until after release (section 5) |
|
||||
| 2026-10-26 | Final gate, release candidate build, Cypress | See section 5. Performance, same suite and browser, devtest (8.2.4) and `devtestn10` (8.3.0), same droplet size and region (San Francisco): generating the test data 1:43 on 8.2.4, 0:42 and 0:45 on 8.3.0 (about 2.4 times faster; mostly server work); the 21 UI specs that passed in both, 224 s and 200 s (about 10% faster; much of a UI test is Cypress itself). `build-release.bat` failed twice at the subscription zip while an Explorer or PowerShell window was open on `dist\installers` (folder in use); clean once closed |
|
||||
| 2026-10-26 | Final build, staging, devtest (RELEASE-PROCEDURE.md steps 3 to 5) | Final build from `05cf4df6` (changelog and manual date only since the release candidate): all five packages 8.3.0, runtime 10.0.14. Staged in `download/next` and `docs-next` on ayanova.com (new droplet `ayanova-2`; its host key changed with the cutover, fingerprint confirmed): SHA-256 of all five files match the build, 665 docs files. Found: the live `latest-version.json` still announces 8.2.3, so installations were never told about 8.2.4; the 8.3.0 announcement covers it. devtest (snapshot first): OS update, .NET 10.0.13, `harden-830.sh` taken from the procedure, the procedure's next-version upgrade command as written, reboot: 8.3.0 boots, schema 20, trial kept, 127.0.0.1 only, program folder read only, `config.json` 640 (was 644), nothing blocked. Cypress on devtest 27/27. Tagged `8.3.0` in `raven` (`05cf4df6`), `raven-client`, `raven-launcher`; publishing, droplet upgrades and the announcement follow on Thursday 2026-10-29 (John) |
|
||||
|
||||
957
devdocs/modernization/SECURITY-REVIEW.md
Normal file
957
devdocs/modernization/SECURITY-REVIEW.md
Normal file
@@ -0,0 +1,957 @@
|
||||
# AyaNova v8 security review
|
||||
|
||||
Whole-codebase security review requested by John (2026-10-23). Done in passes, one area per
|
||||
session; each pass ends with its findings written here and committed. **Review only: no code
|
||||
changes.** Each fix John approves becomes its own case.
|
||||
|
||||
Findings in this file describe weaknesses in shipped software. The repository is private;
|
||||
keep it that way while findings are open.
|
||||
|
||||
## Status
|
||||
|
||||
| Pass | Area | Status |
|
||||
|---|---|---|
|
||||
| 1 | Threat model and attack-surface map | Done 2026-10-23 |
|
||||
| 2 | Unauthenticated routes, login, tokens, password reset, 2FA | Done 2026-10-23 |
|
||||
| 3 | Customer contact users: what they can reach and read | Done 2026-10-23 |
|
||||
| 4 | Report rendering (templates, the server-side browser, report data and download routes) | Done 2026-10-23 |
|
||||
| 5 | File handling: attachments, uploads, exports, logs, backups, restore, translations, logo | Done 2026-10-23 |
|
||||
| 6 | Raw SQL: data lists, saved filters, search, KPI, schedule, pick lists | Done 2026-10-23 |
|
||||
| 7 | Staff roles: the authenticated routes without a role check in the action (list below) | Done 2026-10-23 |
|
||||
| 8 | Email and notifications (`direct-smtp`, templates, delivery) | Done 2026-10-23 |
|
||||
| 9 | Client (XSS, token storage, `npm audit` triage), CORS, headers, Swagger UI | Done 2026-10-23 |
|
||||
| 10 | Deployment: installers, Linux setup script, nginx, systemd, config and data permissions | Done 2026-10-23 |
|
||||
| 11 | Sockeye public routes (licence fetch, trial requests) | Done 2026-10-23 |
|
||||
|
||||
The order is by risk to internet-facing servers. Findings from any pass go in **Findings**.
|
||||
|
||||
## Scope
|
||||
|
||||
- **In:** the AyaNova server (`raven/server/AyaNova`) and client (`raven-client`); the Windows
|
||||
installers and launcher; Linux packages and the hosted subscription server setup script
|
||||
(`sockeye-client/src/views/biz-subscription-server.vue`); the configuration templates.
|
||||
- **Sockeye:** only its public routes (licence fetch and trial requests). Its admin UI is used
|
||||
by John alone (revisit if agents use it later).
|
||||
- **Out:** QBI.
|
||||
|
||||
## Threat model
|
||||
|
||||
**Deployments**
|
||||
|
||||
| Deployment | Reachable by | Notes |
|
||||
|---|---|---|
|
||||
| Hosted subscription droplet (Ubuntu, nginx + Let's Encrypt, `www-data`) | The internet | Most exposed. One company per droplet |
|
||||
| Self-hosted Windows IIS | Often the internet | The manual recommends IIS when there is internet access |
|
||||
| Self-hosted Linux server | LAN or internet | Customer's own reverse proxy, if any |
|
||||
| Windows LAN / Linux desktop | Local network | Runs as the logged-in user (Linux desktop: as root with `sudo ./AyaNova`) |
|
||||
| Windows standalone | The PC itself | Mostly evaluation (John) |
|
||||
|
||||
**Who might attack**
|
||||
|
||||
1. **Anyone on the internet** (no login): 14 anonymous routes, the static client, Swagger UI,
|
||||
health check.
|
||||
2. **A customer contact** (customer-user login, roles `Customer`/`CustomerRestricted`):
|
||||
should see only its own customer's data. The highest-value question in this review: can
|
||||
one customer's login read or change another customer's data, or staff data?
|
||||
3. **A staff user with limited roles**: should not exceed them (e.g. a restricted tech
|
||||
reaching accounting, ops or user admin).
|
||||
4. **Someone holding a leaked token**: a JWT or a download token (`?t=` in URLs, so it can
|
||||
land in browser history and proxy logs).
|
||||
5. **A local user** on a Windows or Linux machine running AyaNova.
|
||||
6. **A report template author**: report templates are JavaScript run in a browser on the
|
||||
server, with access to report data and the API.
|
||||
|
||||
**What is at stake**: customer business data (customers, work orders, invoices,
|
||||
attachments), user logins and passwords, the company's email reputation (SMTP), the licence,
|
||||
the server itself (code execution, the database, backups), and availability.
|
||||
|
||||
## Attack-surface map
|
||||
|
||||
### Authentication and authorisation design
|
||||
|
||||
- **Login**: `POST auth` returns a JWT (HS256, `jose-jwt`), signed with `AYANOVA_JWT_SECRET`
|
||||
(random per boot if not set). Validation (`Startup.cs:255-279`): signature, issuer
|
||||
`ayanova.com`, expiry, zero clock skew; audience not checked.
|
||||
- **One session per user**: the user's latest token is stored (`CurrentAuthToken`); the
|
||||
middleware (`Startup.cs:366-451`) rejects any other valid token with E2004, except on
|
||||
`/auth` and `notify/hello`, and except local report-render requests: host `127.0.0.1`,
|
||||
`Request.IsLocal()` and an `rpl` claim. Behind the hosted nginx the client address is
|
||||
forwarded (`X-Forwarded-For`, `UseForwardedHeaders`), so remote requests are not local.
|
||||
The exception skips only the latest-token check, not signature or expiry.
|
||||
- **Roles** are read from the database on every request (middleware), not from the token.
|
||||
**There is no route-level role attribute and no global "login required" policy**: each
|
||||
controller has `[Authorize]` (login only) and each action calls `Authorized.Has…Role(...)`
|
||||
itself. A missing call means any logged-in user, customer contacts included.
|
||||
- **Download routes** (`?t=`) take a per-user random download key set at login (`DlKey`,
|
||||
with expiry, `AuthController.cs:272-276`) instead of a JWT. Some check only that the key
|
||||
belongs to some active user (export, report, translation); others resolve the user and
|
||||
check rights (attachments, backups, logs). To verify in passes 4 and 5.
|
||||
- **CORS**: any origin, any method, any header (`Startup.cs:167-174`). Tokens travel in the
|
||||
`Authorization` header, not cookies, which limits the impact; checked in pass 9.
|
||||
- **Swagger UI** is public at `/api-docs` with the full API description (pass 9).
|
||||
|
||||
### Routes
|
||||
|
||||
Generated with `devdocs/modernization/security/routemap.py` (`python -I routemap.py
|
||||
<Controllers folder> <out.tsv>`): **460 actions** in 71 controllers. Regenerate it in later
|
||||
passes rather than trusting these counts if the code has changed.
|
||||
|
||||
| Access | Actions |
|
||||
|---|---|
|
||||
| Anonymous (`[AllowAnonymous]`) | 14 |
|
||||
| Download key only (no attribute; check `?t=`) | 2 (`attachment/download/{id}`, `backup/download/{fileName}`) |
|
||||
| Login required (`[Authorize]`) | 444 |
|
||||
|
||||
Controllers without a class-level `[Authorize]` (`Attachment`, `Backup`, `ServiceBank`) were
|
||||
checked: every action has its own `[Authorize]` or is a download-key route; `ServiceBank` is
|
||||
entirely commented out.
|
||||
|
||||
**Anonymous routes** (pass 2 unless noted)
|
||||
|
||||
| Route | Action |
|
||||
|---|---|
|
||||
| `GET /` (API root) | Index |
|
||||
| `POST auth` | PostCreds (login) |
|
||||
| `POST auth/tfa-authenticate` | TfaAuthenticate |
|
||||
| `POST auth/reset-password` | ResetPassword |
|
||||
| `GET export/download/{fileName}` | DownloadAsync (download key; pass 5) |
|
||||
| `GET health` | Get |
|
||||
| `POST license/lc` | lc |
|
||||
| `GET log-file/download/{logname}` | DownloadLog (download key; pass 5) |
|
||||
| `GET logo/{size}` | DownloadLogo |
|
||||
| `GET notify/hello` | GetPreLoginPing |
|
||||
| `GET report/download/{fileName}` | DownloadAsync (download key; pass 4) |
|
||||
| `GET report/export/{id}` | DownloadTemplate (download key; pass 4) |
|
||||
| `POST translation/subset/{id}` | SubSet |
|
||||
| `GET translation/download/{id}` | DownloadTranslation (download key; pass 5) |
|
||||
|
||||
**Logged-in routes with no role or user-type check in the action itself** (53, heuristic:
|
||||
the check may sit in the business layer, or the route may be harmless, such as lists,
|
||||
own settings or own notifications). Each is resolved in pass 7 unless an earlier pass covers
|
||||
it: `GET server-info`, `GET role-rights`, `GET build-mode`, `POST auth/change-password`,
|
||||
`GET auth/totp`, `POST auth/totp-validate`, `GET authorization-roles/list`,
|
||||
`GET/POST/DELETE data-list-column-view…`, `GET data-list/listkeys`, `GET data-list/listfields`,
|
||||
`GET/PUT/POST/DELETE data-list-filter…`, `GET enum-list/list/{enumkey}`,
|
||||
`GET enum-list/listkeys`, `GET form-field-reference/{key}`, `GET global-biz-setting/client`,
|
||||
`GET job-operations/status|progress|logs/{gid}`, `POST job-operations/request-cancel`,
|
||||
`POST kpi`, `POST license/permanently-erase-all-data[-keep-tax-codes]`,
|
||||
`GET notify/new-count`, `GET notify/app-notifications`, `DELETE notify/{id}`,
|
||||
`POST notify/direct-message`, `POST notify/direct-smtp`, `GET notify-subscription/list`,
|
||||
`GET pick-list/template/list`, `GET pick-list/template/listfields/{ayaType}`,
|
||||
`POST report/data`, `POST report/upload`, `POST schedule/svc`, `POST schedule/user`,
|
||||
`POST search`, `GET server-state`, `GET tag-list/list`, `GET tag-list/cloudlist`,
|
||||
`GET translation/{id}`, `GET translation/list`, `GET translation/translationkeycoverage`,
|
||||
`POST translation/subset`, `POST translation/upload`, `POST trial/seed`, `GET user/amsu`,
|
||||
`GET workorder/customer/{id}`.
|
||||
|
||||
Already resolved from that list in pass 1:
|
||||
- `license/permanently-erase-all-data[-keep-tax-codes]`: SuperUser only (user id 1) plus the
|
||||
typed confirmation `i understand` (`LicenseController.cs:248-275`). OK.
|
||||
- `trial/seed`: only with a trial licence and an empty database (only SuperUser exists then)
|
||||
(`TrialController.cs`). OK.
|
||||
- `notify/direct-smtp` and `notify/direct-message`: see F3 and F4.
|
||||
|
||||
**Highest-interest leads for later passes**: `POST search` and `POST kpi` (do they filter by
|
||||
role and customer?), `POST report/data` and the report routes (pass 4), `GET
|
||||
workorder/customer/{id}` (customer users reaching other customers' work orders?), `POST
|
||||
schedule/svc|user`, `GET user/amsu`, `POST report/upload` and `POST translation/upload` (who
|
||||
may upload; what the content can do), `job-operations/*` (another user's job logs?).
|
||||
|
||||
### Other ways in
|
||||
|
||||
- **Report templates**: user-authored JavaScript and Handlebars run in a headless Chrome on
|
||||
the server (`biz/ReportBiz.cs`), with report data, a server-issued token for API calls from
|
||||
the template (`rpl` claim), and helpers such as `ayPutToAPI` (pass 4).
|
||||
- **Raw SQL**: data-list filters and sorts (`DataList/*`; case 4676 fixed the numeric and role
|
||||
values), saved filters, search, KPI, schedules (pass 6).
|
||||
- **Files**: attachments (upload, download, move, maintenance), exports, report files and
|
||||
templates, translation upload and download, logo, log files, backups and their deletion
|
||||
(passes 4 and 5).
|
||||
- **Background jobs**: backup (`pg_dump`, fixed in this release to run without a shell),
|
||||
notifications, licence fetch (outbound to the licence servers), report rendering, seeding.
|
||||
- **Email**: SMTP settings, notification templates, `direct-smtp` (pass 8).
|
||||
- **Configuration and secrets**: `config.json` (database password, JWT secret), logs (do
|
||||
they contain tokens or passwords?), the boot log's config line (password redacted).
|
||||
- **Deployment**: Windows installers (folder permissions), the launcher, the hosted setup
|
||||
script, nginx headers, the systemd unit (no hardening options), Linux desktop running as
|
||||
root (pass 10).
|
||||
|
||||
## Findings
|
||||
|
||||
Severity: **Critical** (remote, no login, or full takeover) · **High** (any login, or one
|
||||
customer reaching another's data; or serious abuse of the company's resources) · **Medium**
|
||||
(needs a specific role, local access or an unusual setup) · **Low** (hardening, information).
|
||||
Status: *candidate* (seen in code, not yet confirmed), *confirmed*, *fixed in case NNNN*,
|
||||
*accepted*.
|
||||
|
||||
**Finding IDs are permanent:** never renumber or reuse them; John's cases refer to them. New findings
|
||||
take the next free number; a fixed or accepted finding keeps its ID and changes status.
|
||||
|
||||
| ID | Severity | Status | Title |
|
||||
|---|---|---|---|
|
||||
| F43 | **Critical** | **fixed in case 4716** | Unit pick-list variant (`customerid:<value>`) is put into SQL unparsed; any logged-in user can reach it |
|
||||
| F41 | **Critical** | **fixed in case 4710** (Sockeye; the business server gets it with 8.3.0) | F5 also in Sockeye (internet-facing admin login) and in the 8.2.x business AyaNova server |
|
||||
| F42 | High | confirmed (code) | The licence-signing RSA private keys are hardcoded in Sockeye's source and binaries |
|
||||
| F5 | **Critical** | **fixed in case 4710** | 2FA login step accepts an empty or missing temp token: the password is skipped for users with 2FA on |
|
||||
| F1 | Critical (installs that kept it) | **fixed in case 4711** | Placeholder JWT secret in the LAN/IIS and Linux server config templates |
|
||||
| F19 | Critical | **fixed in case 4718** | Any logged-in user, customer contacts included, can import report templates, whose JavaScript runs in the server's browser |
|
||||
| F21 | Critical | **fixed in case 4715** | Download file names from the URL are not checked: `report/download` and `export/download` may serve files outside the temp folder on Windows |
|
||||
| F18 | Critical | **fixed in case 4716** | Pick-list search text is put into SQL without escaping; any logged-in user can reach it |
|
||||
| F26 | Critical | **fixed in case 4717** | KPI `interval` criterion is put into SQL unchecked; reachable by staff users down to TechRestricted |
|
||||
| F13 | High | **fixed in case 4712** | Customer contacts can open any customer's work order (customer view, customer report, attachments) by id |
|
||||
| F14 | High | **fixed in case 4712** | Customer contacts can read, and by code change, delete and create, any customer's service requests |
|
||||
| F15 | High | **fixed in case 4712** | Customer contacts can list every customer (and vendors, users, parts...) with any pick-list columns, phones and emails included |
|
||||
| F16 | High | **fixed in case 4712** | `schedule/svc` and `schedule/user` have no role check: anyone logged in sees the whole service schedule |
|
||||
| F17 | Medium | **fixed in case 4712** | Notification subscriptions accept any `UserId`, and object events reach subscribers without a rights check: record names to any email address |
|
||||
| F20 | High | **fixed in case 4719** | Report templates receive the renderer's token (SuperUser for customer notification reports) and the report browser can reach any network address |
|
||||
| F27 | High | **fixed in case 4720** | Restricted roles (TechRestricted, SubContractor, SubContractorRestricted) get whole work-order items through report data, rendering and export |
|
||||
| F28 | High | **fixed in case 4713** | Users with change rights on Customer (Service, Sales, Accounting) can give customer contact logins any role, BizAdmin and OpsAdmin included |
|
||||
| F33 | High | **fixed in case 4721** | Client renders some record text as HTML without escaping (search results, work-order address dialog, unit warranty info, dialogs) |
|
||||
| F3 | High | **fixed in cases 4712 and 4723** (staff sending to any address kept, by design) | Any logged-in user, customer contacts included, can send arbitrary email through the company's SMTP server |
|
||||
| F23 | Medium | **fixed in case 4727** | Logo upload accepts any content type and the anonymous logo route serves it inline from the app's origin (stored XSS) |
|
||||
| F24 | Medium | **fixed in case 4718** | Translation import has no role check: any login can add translations |
|
||||
| F30 | Medium | **fixed in case 4724** | SMTP: the mail server's TLS certificate is never checked |
|
||||
| F31 | Medium | **fixed in case 4724** | The SMTP password is returned to read-only roles (OpsAdminRestricted, BizAdmin, BizAdminRestricted) |
|
||||
| F32 | Medium | confirmed (code) | A real-looking SMTP account and password are in the DEBUG defaults in `raven` and `sockeye` source |
|
||||
| F34 | Medium | **fixed in case 4722** | Shipped client libraries behind on security fixes: DOMPurify 2.3.8 (wiki sanitiser), Vuetify 2.6.6 |
|
||||
| F6 | Medium | **fixed in cases 4710 and 4726** | No lockout or rate limit on login or the 2FA step; no password rules |
|
||||
| F9 | Medium | **fixed in case 4725** | Default SuperUser password (by design for trials), and an anonymous route that says whether it is still set |
|
||||
| F36 | Low | **accepted** for standalone and LAN; hosted **fixed in case 4727** (`sockeye-client` setup script; existing droplets at the 8.3.0 rollout) | AyaNova listens on all interfaces on plain HTTP port 7575 in every template; hosted droplets are protected by a cloud firewall |
|
||||
| F37 | Low | accepted (John) | Standalone PostgreSQL accepts any local connection without a password (`trust`) |
|
||||
| F2 | Medium | **fixed in case 4579** (write access); read access to `config.json` and write access to the data folder accepted and documented | Windows installers give all local users modify rights on the program and data folders, `config.json` included |
|
||||
| F7 | Low | **fixed in case 4725** | Password change or reset does not end existing sessions, and makes the download key non-expiring |
|
||||
| F8 | Low | **fixed in case 4725** | Latest-token check skipped for any URL ending in `/auth` or `notify/hello` |
|
||||
| F4 | Low | **fixed in cases 4712 and 4723** | Any logged-in user can send an in-app notification to any user |
|
||||
| F10 | Low | **later case 4736** (not 8.3.0; accepted as is for 8.3.0, legal check to do) | Anyone can record acceptance of the licence agreement (`license/lc`) |
|
||||
| F11 | Low | **fixed in case 4727** | API root page shows licensee, version, licence terms (and DB ID when unlicensed) to anyone |
|
||||
| F12 | Low | **later case** (not 8.3.0) | Password hashing: PBKDF2-SHA512 with 10,000 iterations |
|
||||
| F25 | Low | **fixed in case 4727** (upload before rights check accepted) | Attachment and export gaps: upload stored before the rights check, "attached by" can be set to anyone, exports not tied to their user |
|
||||
| F29 | Low | **fixed in case 4727** | `server-info` gives every login, customer contacts included, the server DB ID and full licence details |
|
||||
| F35 | Low | **fixed in case 4727** (CSP a later case; CORS, Swagger accepted) | No security headers from the server itself (self-hosted), no CSP anywhere, CORS any origin, public Swagger UI, download keys in URLs |
|
||||
| F38 | Low | **fixed in case 4727** for hosted (`sockeye-client` setup script; existing droplets at the 8.3.0 rollout); Docker files removed; desktop running as root in Deferred | Linux hardening: no systemd sandboxing, service user can replace program files, desktop runs as root, unused Docker files with a fixed database password |
|
||||
| F39 | Low | AyaNova half **fixed in case 4727**; Sockeye half a later case | Licence fetch is one-shot by DB ID, and the DB ID is shown anonymously while unlicensed: someone else can take a new key first |
|
||||
| F40 | Low | **fixed in case 4681** (Sockeye) | Trial request routes: no rate limit, non-cryptographic verification codes, a fixed bot-check value |
|
||||
| F22 | Low | **fixed in case 4727** (job owner check and Chrome sandbox accepted) | Minor report and job route gaps: template export by any download key, job status, logs and cancel by GUID without an owner check, browser without sandbox |
|
||||
|
||||
**Release decisions.** John, 2026-10-23: **F13 to F17 are urgent and are fixed in 8.3.0**, as
|
||||
"next release" cases (case numbers to come). Hosted and self-hosted perpetual installations
|
||||
matter equally; how many use customer contact logins is unknown, so every installation is
|
||||
assumed to. **F5, F1 and F18 are fixed in 8.3.0 as well** (John, 2026-10-23). John: "I won't
|
||||
release until it's secure, all holes must be plugged": 8.3.0 waits for the findings of every
|
||||
pass to be fixed or explicitly accepted by John.
|
||||
|
||||
### F5 2FA step skips the password (pass 2, confirmed on the local server)
|
||||
|
||||
`AuthController.TfaAuthenticate` (`POST auth/tfa-authenticate`, anonymous) finds the user
|
||||
only by `TempToken == pin.TempToken` (plus active, login allowed, 2FA on), then checks the
|
||||
6-digit code. The temp token is the only proof that the password was given in step one, but:
|
||||
|
||||
- after a successful 2FA login it is set to `""` (`AuthController.cs:193`), so sending
|
||||
`"tempToken": ""` matches every 2FA user who has completed a 2FA login;
|
||||
- a user who has just turned 2FA on has it `null`; sending no temp token matches them;
|
||||
- `TempToken` is not required in `TFAPinParam`, and a wrong code does not clear it.
|
||||
|
||||
So anyone, with no login and no password, can send codes to this route and log in as
|
||||
whichever 2FA user the query returns first. TOTP accepts 3 codes at any moment
|
||||
(TwoFactorAuth.Net default discrepancy 1), the only throttle is the 3 s failure delay,
|
||||
which parallel requests get around, and there is no lockout (F6) and no nginx rate limit on
|
||||
hosted droplets. At a few hundred guesses per second that is, on average, well under an
|
||||
hour; even at 20 per second, a few hours. Users who turn 2FA on are the more privileged
|
||||
ones, so 2FA makes those accounts weaker than a password alone.
|
||||
|
||||
**Proof** (local dev server, seeded `Inventory` user, 2FA turned off again afterwards):
|
||||
2FA just turned on, no password, temp token omitted, correct code: 200 and a token for
|
||||
Inventory. After one normal 2FA login, no password, temp token `""`, correct code: 200 and a
|
||||
token. Wrong code: 401. (Script kept outside the repo; the test needs the user's code, which
|
||||
an attacker guesses.)
|
||||
|
||||
**Fix** (proposed for 8.3.0): reject a null or empty temp token before the query; clear the
|
||||
temp token (set it to null) on success and on every failed code, so each guess needs the
|
||||
password again; optionally expire the temp token after a few minutes. Small change in one
|
||||
method; client unaffected (it always sends the `tt` it received).
|
||||
|
||||
### F1 Placeholder JWT secret (from the 8.3.0 installer test; confirmed in pass 2)
|
||||
|
||||
`dist/assets/lan-install-config.json` and `dist/assets/linux-server/config.json` ship
|
||||
`AYANOVA_JWT_SECRET` set to a placeholder that is also printed in the manual. The manual
|
||||
tells users to change it (Linux: "optional but recommended"). An installation that keeps it
|
||||
signs logins with a publicly known secret.
|
||||
|
||||
**Pass 2: the latest-token check does not save it.** Tokens are deterministic: the payload is
|
||||
only `exp` (login time + 5 days, in seconds), `iss` and `id`, with no random part
|
||||
(`AuthController.cs:280-291`); two logins in the same second give identical tokens. So with
|
||||
the secret, someone with no login can rebuild a user's *current* token by trying each second
|
||||
of the last 5 days: at most 432,000 requests per user, each rejected at once (no delay). It
|
||||
works for any user who logged in in the last 5 days, SuperUser included. **Proof**: with the
|
||||
local dev secret, a forged token for `Inventory` was accepted after 6 tries and was identical
|
||||
to the real one. In addition, routes ending in `/auth` (F8) accept a forged token without
|
||||
any search, and behind a reverse proxy on the same machine that does not set
|
||||
`X-Forwarded-For`, a forged token with an `rpl` claim and `Host: 127.0.0.1` skips the check
|
||||
too (`IsLocalExtension`: remote address equals local address).
|
||||
|
||||
Hosted droplets set up with the current script are not affected (random secret since
|
||||
`64d8f62`); **droplets created before that: to check (John)**. Self-hosted IIS and Linux
|
||||
servers that are reachable from the internet and kept the placeholder are fully open.
|
||||
|
||||
Fix options: treat the placeholder (and any secret shorter than 32 characters) as unset, so
|
||||
a random secret is used per boot, and log a warning (users would log in again after each
|
||||
restart); or generate a random secret at install time (Windows installer, Linux packages);
|
||||
or refuse to boot. Adding a random value (`jti`) to the token is cheap hardening as well.
|
||||
|
||||
### F6 No lockout or rate limit on login and 2FA (pass 2)
|
||||
|
||||
`POST auth` and `POST auth/tfa-authenticate` delay 3 s on failure but count nothing: no
|
||||
per-user or per-address lockout, and parallel requests are not slowed. Hosted nginx has no
|
||||
`limit_req`. There is no password rule beyond "not empty" (seeded trial users use the login
|
||||
as password). Fix options: count failures per login and per temp token with a short lockout;
|
||||
`limit_req` on `/api/v8/auth` in the hosted nginx template.
|
||||
|
||||
**Fixed (2026-10-24).** 2FA: case 4710 (5 wrong codes clear the temporary token). Password login: case 4726, `util/LoginThrottle.cs` in AyaNova and Sockeye (Sockeye's login is public too; only SSH to its droplet is limited to the office address). Failures are counted in memory: 10 per login name or 50 per client address within 15 minutes, then attempts are refused with 429 "Too many failed login attempts" for the rest of the window, without checking the password; a correct password clears the login's count; unknown logins are counted the same way. Loopback is not counted per address (the hosted nginx and IIS pass the real client address through the forwarded headers, which are trusted from loopback only). Each lockout is logged as a warning. Accepted: an attacker can keep a known login locked by failing 10 times every 15 minutes. Proved in both servers, the per-address limit through `X-Forwarded-For` with a documentation address; test `LoginLockout`. The password rule and nginx `limit_req` are in Deferred in `NET10-PORT.md`.
|
||||
|
||||
**Follow-up, case 4732 (2026-10-25).** The per-address count relies on `UseForwardedHeaders`, which by default trusts only a loopback proxy. Behind a proxy on another computer every user appeared as the proxy's address and shared one count of 50, so one person could lock everyone out. New setting `AYANOVA_TRUSTED_PROXIES` (addresses or CIDR networks) adds trusted proxies and follows a chain of them; when `X-Forwarded-For` arrives from a proxy that is not trusted, the log warns once per server start. Default behaviour unchanged.
|
||||
|
||||
### F7 Password change does not end sessions; download key loses its expiry (pass 2)
|
||||
|
||||
`UserBiz.ChangePasswordAsync` (used by change-password and reset-password) does not replace
|
||||
`CurrentAuthToken`, so a session someone else holds keeps working up to 5 days after the
|
||||
owner changes the password. It also sets `DlKeyExpire = null`, apparently meant to be
|
||||
`PasswordResetCodeExpire`; with a null expiry `ValidateDownloadTokenAndReturnUserAsync`
|
||||
never treats the key as expired, until the next login replaces it. Setting `AllowLogin` to
|
||||
false does not end a session either (only deactivating does). Fix: on password change, set
|
||||
`CurrentAuthToken` and `DlKey` to new random values and clear `PasswordResetCodeExpire`. **Fixed in case 4725 (2026-10-24):** a password set by someone else (reset code, administrator on the user form) and unticking Allow login replace `CurrentAuthToken` and `DlKey` (key expired); changing one's own password keeps the caller's session, which is the only valid one anyway (one session per user). `PasswordResetCodeExpire` is cleared instead of `DlKeyExpire`. Test `Sessions` (fails on the old code).
|
||||
|
||||
### F8 Latest-token check bypassed by the URL suffix (pass 2, confirmed)
|
||||
|
||||
The middleware skips the `CurrentAuthToken` comparison when the path ends with `/auth` or
|
||||
`notify/hello` (`Startup.cs:409-413`, `EndsWith`), meant for the login and pre-login routes.
|
||||
Any route whose last segment is free text can be called with a replaced or revoked token
|
||||
(e.g. `tag-list/batch-add/auth`, `form-custom/auth`). Confirmed: a replaced token got E2004
|
||||
on `form-field-reference/Customer` and passed the middleware on `form-field-reference/auth`.
|
||||
The middleware also does not check `Active`. Impact is small on its own (only those routes,
|
||||
with the user's current roles), larger with F1. Fix: compare the exact route
|
||||
(`/api/v8/auth`, `/api/v8/notify/hello`), or skip the check by endpoint metadata
|
||||
(`[AllowAnonymous]`) instead of by path. **Fixed in case 4725 (2026-10-24):** the exemption compares the matched route exactly (`POST auth`, `GET notify/hello`, through `CustomerContactRoutes.RouteKey`); the middleware also refuses inactive users and users who may not log in. Test `Sessions.ReplacedTokenRefusedOnRouteEndingInAuth` (old code: passed the middleware, 404 from the action).
|
||||
|
||||
### F9 Default SuperUser password, advertised anonymously (pass 2)
|
||||
|
||||
A new database has SuperUser `superuser` / `l3tm3in` (`PrimeData.cs`). Anonymous
|
||||
`GET notify/hello` returns `sudf: true` while that is still the password, and the login form
|
||||
then fills it in. Anyone can therefore check an internet-facing server for it in one request
|
||||
and log in as SuperUser. Each call also runs a PBKDF2 hash, a cheap way to load the server.
|
||||
Whether hosted droplets set their own SuperUser password at setup: to check in pass 10. **Pass 10:** they do not. The setup script generates the database password and JWT secret but not a SuperUser password, so every new hosted droplet starts internet-facing with `l3tm3in` until someone changes it. Fix in the script: generate one, set it once with `AYANOVA_SET_SUPERUSER_PW`, and write it to `/root/ayanova-secrets.txt` with the others. **John, 2026-10-23:** the published password is deliberate while a server is in trial mode, for easy evaluation; once licensed for production it must be changed on first login. Checked: that rule is enforced by the client only (`api/initialize.js` sends the user to the password page when `knownPassword` and the licence status is ActivePurchased); the server still accepts `superuser`/`l3tm3in` on a licensed server (API logins, or a user who navigates away) and `notify/hello` keeps reporting it. Fix that keeps the trial design: on a purchased licence, have the server refuse a login with the default SuperUser credentials (or allow it only to change the password), and stop returning `sudf` outside trial mode.
|
||||
Fix options: only return `sudf` from local or LAN addresses, or force a password change on
|
||||
first SuperUser login; at least warn in the UI and the manual.
|
||||
|
||||
**Fixed in case 4725 (2026-10-24), John's option A:** with a purchased licence (active or expired), `notify/hello` no longer returns `sudf`, and a SuperUser session with the default credentials may only use the routes the change password page needs (`SuperUserDefaultPasswordRoutes`, checked in the token middleware; the default-credentials result is kept per stored hash, so no extra hashing per request). Trial and unlicensed servers unchanged, as designed. Proved locally with a temporary, uncommitted purchased-licence override: other screens 403 until the password was changed, then normal (John's click-through). Hosted setup script still does not set a SuperUser password: accepted for trial mode.
|
||||
|
||||
### F10 Anonymous licence-agreement consent (pass 2)
|
||||
|
||||
`POST license/lc` with body `"iaccepttheagreement"` records the EULA consent; the comment says
|
||||
"SuperUser only", but the route is anonymous. Legal rather than technical: the consent record
|
||||
does not prove who accepted. Fix: require SuperUser.
|
||||
**John, 2026-10-24: accepted as is** (login is refused until consent is given, so consent can't require a login; requiring credentials in the request would need a client change). To be checked from a legal standpoint.
|
||||
**John, 2026-10-26: moved out of case 4727 into case 4736, after 8.3.0.**
|
||||
|
||||
### F11 Anonymous API root page discloses licence details (pass 2)
|
||||
|
||||
`GET /api/v8/` returns an HTML page with the exact version, "Registered to", licence type,
|
||||
expiry dates and licensed features; unlicensed, the server DB ID. Helps an attacker pick
|
||||
targets by version. Fix: show it only to logged-in users, or show the version only.
|
||||
**Fixed in case 4727, step 2 (2026-10-24):** the page shows the version and the links only; the licence block is gone and the support link no longer carries the DB ID and licensee (it did, unlicensed or not). The DB ID stays available to logged-in users (licence form, About form support link). Test `ApiRoute.ServerApiRootPageHasNoDbIdOrLicence`. The manual's screenshot of this page (`api-console.md`) still shows the old licence block.
|
||||
|
||||
### F12 Password hashing strength (pass 2)
|
||||
|
||||
PBKDF2-HMAC-SHA512, 10,000 iterations, 32-byte random salt (`Hasher.cs`). Current guidance
|
||||
(OWASP) is 210,000 iterations for SHA-512. Only matters if the database or a backup leaks.
|
||||
Raising it needs re-hashing at next login (store the iteration count). Roadmap item, not 8.3.0.
|
||||
**2026-10-24: later case, not 8.3.0** (John left it to the review). It only matters if the database or a backup leaks (online guessing is limited by case 4726); 210,000 iterations make each login, change and reset about 20 times more work, to be measured on a small droplet; it needs a schema change (iteration count per user) and re-hashing at each user's next login.
|
||||
|
||||
### Pass 3 overview: customer contact isolation
|
||||
|
||||
Customer and head-office contacts are users with `UserType` Customer/HeadOffice and the
|
||||
`Customer` role (seeded contacts: `AuthorizationRoles.Customer`). Isolation to their own
|
||||
customer is enforced **only in data lists**: `DataListController` allows them two lists
|
||||
(`CustomerServiceRequestDataList`, `CustomerWorkOrderDataList`) and sets the customer
|
||||
filter itself. Everywhere else the checks are role sets designed for staff, and
|
||||
`AuthorizationRoles.All` includes `Customer` and `CustomerRestricted`: `Select` is `All` on
|
||||
almost every type (Customer, User, Vendor, Part, Unit, WorkOrder…), `ReadFullRecord` is `All`
|
||||
on Report, TaskGroup, QuoteStatus, and the `Customer` role can change any
|
||||
CustomerServiceRequest. The customer-specific rights helper
|
||||
(`UserBiz.CustomerUserEffectiveRightsAsync`) answers "is this feature on for this contact
|
||||
and these tags", never "does this record belong to the contact's customer".
|
||||
|
||||
All proofs below: local dev server, seeded contact `Customer` (customer id 1), read-only
|
||||
requests; seeded data is fake.
|
||||
|
||||
**Fix direction** (one case, possibly in steps): deny customer-type users by default, with an
|
||||
allowlist of the routes the customer UI actually calls (to be listed from the client), as a
|
||||
filter or in the token middleware, where `AY_USER_TYPE` is already set; then add the
|
||||
ownership check in each allowed route that takes an id (work order customer view, customer
|
||||
report, attachments, CSR). This also covers routes not yet reviewed.
|
||||
|
||||
### F13 Any customer's work order by id (pass 3, confirmed)
|
||||
|
||||
`GET workorder/customer/{id}` (`WorkOrderBiz.WorkOrderGetForCustomerAsync`) loads the work
|
||||
order by id and never compares its `CustomerId` with the contact's. It returns serial, service
|
||||
date, customer name, customer reference number, contact name, invoice number, and the wiki
|
||||
when tag rules allow it. **Proof**: the contact of customer 1 read work order 1 of customer 12
|
||||
(200, with that customer's name). A staff user calling it gets a 500 (the helper throws for
|
||||
non-customer users). The same tag-only rights, without ownership, also gate (code reading):
|
||||
|
||||
- the customer work-order report (`ReportBiz`, report id `-100`): takes `SelectedRowIds[0]`,
|
||||
gets the effective report from that work order's tags, then renders with the role check
|
||||
skipped (`RequestIsCustomerWorkOrderReport`), so a contact can render the configured
|
||||
customer report for any work order (pass 4 to confirm);
|
||||
- work-order attachments (`attachment/list`, `attachment/download/{id}` for customer users):
|
||||
`ThisWOCanAttachments` from tags only.
|
||||
|
||||
These need the customer work-order features turned on in global settings (common where the
|
||||
customer portal is used); the customer view route does not, it always answers.
|
||||
|
||||
### F14 Customer service requests across customers (pass 3, confirmed for reading)
|
||||
|
||||
CSR `Change` includes `Customer`, and `HasReadFullRole` counts `Change` as read.
|
||||
`CustomerServiceRequestBiz` never checks `CustomerId` against the contact (create, get, put,
|
||||
delete). **Proof**: the contact read CSR 1 of customer 16 (200, with its text). Change, delete
|
||||
and create for another customer were not tested (they write data) but follow from the same
|
||||
code. Search also returns CSRs of all customers (it filters by role only). Pass 9: `unit/customerunit` (`UnitBiz.CustomerCreateAsync`) also takes `CustomerId` from the request, so a contact can add units to any customer; same fix (force the contact's customer).
|
||||
|
||||
### F15 Pick lists expose the whole customer list (pass 3, confirmed)
|
||||
|
||||
`POST pick-list/list` checks only `Select`, which the `Customer` role has for customers, head
|
||||
offices, vendors, users, parts, units, contracts, projects, rates and more. The caller also
|
||||
sends its own `template`, which chooses the returned columns. **Proof**: the contact got all
|
||||
70 seeded customers with phone and email. `pick-list/single` works the same way. A
|
||||
customer's contact can therefore download the company's customer list with contact details.
|
||||
Fix: no `Select` for customer roles on these types (keep what the customer UI needs, likely
|
||||
units of its own customer) and ignore `template` from customer-type users.
|
||||
|
||||
### F16 Service schedule open to everyone logged in (pass 3, confirmed)
|
||||
|
||||
`POST schedule/svc` and `POST schedule/user` have no role check. **Proof**: the contact got
|
||||
1,257 schedule items, each "work order number + customer name" with times and the assigned
|
||||
technicians. Fix: require read rights on `WorkOrderItemScheduledUser` (or `WorkOrder`) and
|
||||
refuse customer-type users. (`schedule/personal` is the caller's own schedule.)
|
||||
|
||||
### F17 Notification subscription for someone else (pass 3, candidate)
|
||||
|
||||
`NotifySubscriptionBiz.CreateAsync` and `PutAsync` keep the `UserId` sent by the client, and
|
||||
validation checks the event type against *that* user's type, not the caller's. A subscription
|
||||
has its own `DeliveryAddress` and can attach a report (`LinkReportId`). So, by code, a
|
||||
customer contact could create a subscription with a staff user's id, a staff event type and
|
||||
its own email address, and receive staff notifications, possibly with reports attached. Not
|
||||
tested live because it sends real email. Fix: force `UserId` to the caller unless the caller
|
||||
has user-admin rights. Pass 8 to confirm how delivery checks rights. **Pass 8 (code):** confirmed, and it goes further: `NotifyEventHelper` matches object events (created, modified, deleted, age) to subscriptions by record type only, without checking that the subscriber may read that type, and `CoreJobNotify` sends to the subscription's `DeliveryAddress`. A user notification email carries the record type, its name and a link (no data, no report: report attachments exist only on customer notification subscriptions, which staff create). So a contact, or a staff user without rights on a type, can get a running feed of record names (new customers, work orders, ...) at any address. Re-rated Medium. Fix as above, plus a read-rights check on the subscriber when an object event is matched.
|
||||
|
||||
### F18 Pick-list search text in SQL (pass 3; code reading completed in pass 6)
|
||||
|
||||
`PickListSqlBuilder.Build` puts the search text and the tag search text into `like` fragments
|
||||
by string interpolation, and no escaping was found on the way from
|
||||
`PickListController.PostList` through `PickListBiz.GetPickListAsync`. The route is open to
|
||||
every logged-in user, customer contacts included. Data-list text filters escape quotes since
|
||||
case 4676; pick lists were not covered. Deliberately not tested live. Fix: pass the search
|
||||
texts as SQL parameters (preferred) or escape them as the data-list builder does; review the
|
||||
rest of the pick-list builder in pass 6. **Pass 6:** the search text (`autoCompleteQuery`) and the tag search text (`tagSpecificQuery`) are the only free-text inputs of the pick-list builder, and neither is escaped on any path; template fields map to column definitions, ids are `long`, variants are parsed as numbers or matched to fixed strings. Status: confirmed by code.
|
||||
|
||||
|
||||
### F19 Report template import open to every login (pass 4, confirmed)
|
||||
|
||||
`POST report/upload` (`ReportController.UploadAsync`) has `[Authorize]` only, and
|
||||
`ReportBiz.ImportAsync` checks nothing either; creating or editing a template through the
|
||||
normal routes needs `Change` on `Report` (BizAdmin, BizAdminRestricted). A template holds
|
||||
JavaScript (`JsPrerender`, `JsHelpers`) and a Handlebars template, which `ReportBiz` loads into
|
||||
the headless browser on the server at render time. **Confirmed**: the seeded contact
|
||||
`Customer` imported a copy of a stock template (202, new report 104, deleted afterwards).
|
||||
|
||||
Consequences (from code; the rendering side was deliberately not tested):
|
||||
- `report/render-job` requires read rights on `Report` (everyone) and on the template's
|
||||
record type, which the uploader chooses (a contact can read CSRs), so any login can run
|
||||
its own JavaScript in the server's browser: it can reach the local API and any address the
|
||||
server can reach (internal network, cloud metadata endpoints) and put the results into the
|
||||
PDF it downloads.
|
||||
- An imported template also appears in staff report lists; when staff or SuperUser render it,
|
||||
it runs with their token (see F20).
|
||||
|
||||
Fix: in `UploadAsync`, require `HasCreateRole(Report)` like `POST report`; F20's fixes limit
|
||||
what a template can do even for legitimate authors.
|
||||
|
||||
### F20 What a report template can reach (pass 4, candidate)
|
||||
|
||||
The page gets the renderer's own bearer token in `AYMETA.ayClientMetaData.Authorization` (for
|
||||
`ayGetFromAPI`/`ayPostToAPI`/`ayPutToAPI`), and nothing restricts the page's network access
|
||||
(no request interception in `ReportBiz`). So a template's script can send the token of
|
||||
whoever renders it to any outside address; that token is the user's current session token,
|
||||
usable from anywhere. Customer notifications with an attached report
|
||||
(`CoreJobCustomerNotify`) render with `AuthController.GenRpt`: a SuperUser (id 1) token with an
|
||||
`rpl` claim, accepted from 127.0.0.1 for the render timeout. A template author (including the
|
||||
"restricted" `BizAdminRestricted` role) can therefore act as SuperUser, or as any colleague who
|
||||
runs the report. Fix options: intercept page requests (PuppeteerSharp
|
||||
`SetRequestInterceptionAsync`) and allow only the local API URL and `data:`/`blob:`; render
|
||||
notification reports as a dedicated low-rights identity rather than SuperUser; consider a
|
||||
short-lived token limited to read routes for template API calls.
|
||||
|
||||
**Fixed in case 4719 (2026-10-25).** The report page holds only placeholders for the renderer's token and download key (`Bearer ay-report-render-token`, `ay-report-render-download-key`); request interception (`util/ReportApiRelay.cs`, from case 4632, now in every kind of install) puts the real values only on the page's requests to `ayApiUrl`, so a template that sends them elsewhere sends a placeholder (stock template "z_user logged in ... ayClientMetaData" printed the real token and download key into the PDF until now). Report templates are changeable only by BizAdmin (BizAdminRestricted had change rights, against the roles page), so only someone who can already change users' roles authors what runs with other people's rights, including SuperUser for notification reports, which keep their rights (customers write from reports). Outside addresses stay reachable (customer templates may load their own images or fonts); a template author can still send out data they can read, which is no escalation. Tests `ReportEditRights`, `ReportRenderCredentials` (before: the page saw the real token and download key); 22 stock reports unchanged locally; IIS VM user and notification reports.
|
||||
|
||||
### F21 File names from the URL in download routes (pass 4, candidate, code reading)
|
||||
|
||||
`report/download/{fileName}` and `export/download/{fileName}` accept any user's download key
|
||||
and serve `FileUtil.GetFullPathForTemporaryFile(fileName)`, which is
|
||||
`Path.Combine(TemporaryFilesFolder, fileName)` with no check. On Windows, a backslash in the
|
||||
route value is not a separator for routing but is one for `Path.Combine`, and an absolute
|
||||
name replaces the folder entirely; the result is passed to `PhysicalFile`. Any login
|
||||
(customer contacts included) could then read files outside the temp folder, such as
|
||||
`config.json` (database password, JWT secret). Linux is likely not affected (`/` stays encoded
|
||||
as `%2F` in route values); IIS request filtering may block some forms. Not tested live by
|
||||
choice. The same unchecked pattern is in `log-file/{logname}`, `log-file/download/{logname}`
|
||||
and the backup download route (ops roles; pass 5 confirmed: `LogFilesController` `GetLog` and `DownloadLog` need `LogFile` read, i.e. OpsAdmin or OpsAdminRestricted; `BackupController.DownloadAsync` needs `Backup` change, i.e. OpsAdmin; backup *delete* is safe, `FileUtil.EraseBackupFile` already strips directories with `Path.GetFileName`, the pattern the other routes should share). Fix: accept only a bare file
|
||||
name (`Path.GetFileName(name) == name`, no `..`), and check the resolved full path is inside
|
||||
the folder, in one helper used by every such route.
|
||||
|
||||
### F22 Minor report and job gaps (pass 4)
|
||||
|
||||
`report/export/{id}` gives any report template to any valid download key (templates are not
|
||||
secret, but staff-only templates go to customer contacts). `job-operations/status|progress|
|
||||
logs/{gid}` and `report/request-cancel/{gid}` check no owner; job ids are random GUIDs, so
|
||||
this needs a leaked id. The browser runs with `--no-sandbox` (Chrome's own sandbox off), so a
|
||||
browser bug reached through a template is not contained; Linux runs as `www-data`, Windows LAN
|
||||
as the logged-in user. Fix: owner check on job routes; consider enabling the sandbox where
|
||||
the platform allows.
|
||||
**John, 2026-10-24:** owner check and template export in case 4727; Chrome's sandbox **accepted as off** (it often fails on Linux servers and can't run as root). The systemd options in F38 would contain the browser as a child of the service on hosted droplets.
|
||||
**Case 4727, step 4 (2026-10-24):** `report/export/{id}` gives a template only to a download-key user whose (effective) roles include one of the template's Roles, or who may edit templates; test `TemplateExportRights` (a user with no roles got any template on the old code). Job owner check **accepted, John 2026-10-24**: jobs record no owner, so it needs a schema change and changes at the 13 places that create jobs; job ids are random GUIDs and the routes return status and log only. A possible later case.
|
||||
|
||||
### F23 Logo upload: any content type, served inline (pass 5, from code)
|
||||
|
||||
`LogoController.UploadAsync` stores the bytes and the content type the browser sent
|
||||
(`file.ContentType`), with no check of either; `DownloadLogo` (anonymous, `GET logo/{size}`)
|
||||
returns them inline with that type from the AyaNova origin. A file uploaded as HTML (or SVG
|
||||
with script) therefore runs as a page of the AyaNova site when someone opens its URL, and the
|
||||
client keeps its session in `localStorage` (`vuex-persistedstate`, key `AyaNova`), which such a
|
||||
page can read. Upload and delete check `HasReadFullRole(Global)`: BizAdmin, and
|
||||
BizAdminRestricted, whose rights on Global are meant to be read-only. Fix: require
|
||||
`HasModifyRole(Global)`; accept only PNG, JPEG, GIF, WebP (check the bytes, not the header;
|
||||
no SVG); serve with `X-Content-Type-Options: nosniff` and a restrictive
|
||||
`Content-Security-Policy` (pass 9 looks at headers generally).
|
||||
|
||||
**Fixed in case 4727, step 1 (2026-10-24):** upload and delete need `HasModifyRole(Global)` (BizAdmin); the type is taken from the bytes (PNG, JPEG, GIF, WebP, SVG; anything else refused, the claimed and stored types are ignored); downloads send `nosniff` and `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`. SVG is kept (the client accepts any image, so existing customers may have one): the sandbox stops its script when it is opened by URL, `<img>` use is unaffected. Test `LogoSafety`; an SVG with a script was uploaded, served sandboxed, and the original logo restored.
|
||||
|
||||
### F24 Translation import without a role check (pass 5, from code)
|
||||
|
||||
`TranslationController.UploadAsync` (`POST translation/upload`) and `TranslationBiz.ImportAsync`
|
||||
check no role, the same shape as F19. Any login, customer contacts included, can add
|
||||
translations (up to 15 MiB each; imported as non-stock under a unique name). They affect
|
||||
only users who select them, unless an administrator makes one the default; whether
|
||||
translated text is ever rendered as HTML in the client is a pass 9 question. Fix: require
|
||||
`HasCreateRole(Translation)`, like the duplicate route.
|
||||
|
||||
### F25 Attachment and export gaps (pass 5, from code)
|
||||
|
||||
- `AttachmentController.UploadAsync` streams the whole upload to disk (limit 10 GiB) before
|
||||
it checks rights on the target record, then deletes it on 403: any login can make the
|
||||
server write large files repeatedly. Check rights before reading the body, or lower the
|
||||
limit for users without rights.
|
||||
- The form field `OverrideUserId` is accepted from anyone and recorded as "attached by":
|
||||
limit it to the import path that needs it, or to SuperUser.
|
||||
- `export/download/{fileName}` accepts any user's download key; the name is
|
||||
`{type}-{yyyyMMddHHmmssfff}.json` and files are deleted after about 15 minutes, so guessing
|
||||
another user's export is impractical, but the route should still check that the caller
|
||||
made it.
|
||||
- Customer contacts can attach files to any customer's service request (part of F14; fixed
|
||||
by the same ownership check).
|
||||
|
||||
**John, 2026-10-24:** `OverrideUserId` and the export download check in case 4727; the upload written to disk before the rights check is **accepted** (since case 4712 every login that reaches it is staff; a proper fix restructures the upload, a possible later case).
|
||||
**Case 4727, step 5 (2026-10-24):** `OverrideUserId` is used only from SuperUser or a user with change rights on User (the v7 migration's need), otherwise ignored (proved: a Service user's attachment claiming SuperUser was recorded as SuperUser's on the old code, as the Service user's now). Export files are named `{type}-u{userId}-{date}.json` and `export/download` serves only a `.json` name carrying the download-key user's own id, so it no longer serves other temporary files either; test `ExportOwnFileOnly` (another user's key downloaded the file on the old code).
|
||||
|
||||
OK in this pass: attachments are served as downloads (`Content-Disposition: attachment`), stored
|
||||
under content-hash names in the attachments folder; batch move, batch delete and maintenance
|
||||
check rights; data import (`POST import`) checks create rights per type; there is no restore
|
||||
route (restore is a manual `pg_restore`, see the manual).
|
||||
|
||||
### F26 KPI `interval` in SQL (pass 6, from code)
|
||||
|
||||
Six KPIs (`WorkOrderCreatedCount`, `WorkOrderStatusCount`, `WorkOrderStatusPct`,
|
||||
`WorkOrderCompletedOnTimePct`, `WorkOrderItemLaborQuantitySummary`,
|
||||
`WorkOrderItemLaborQuantityEveryoneSummary`) read `interval` from the request
|
||||
(`options.Criteria["interval"]`, a string) and put it into the query as
|
||||
`DATE_TRUNC('{interval}', ...)` with no check. `POST kpi` has no role check of its own;
|
||||
`KPIFetcher` checks the KPI's `AllowedRoles`, which for the labour summary are Tech and
|
||||
TechRestricted, so the least privileged staff roles reach it (customer roles do not). A value
|
||||
that leaves the quotes changes the query, which can read or change anything in the database
|
||||
(password hashes, roles, licence). Fix: accept only the values the client sends (`day`, `week`,
|
||||
`month`, `quarter`, `year`; check `raven-client` for the exact list) and reject anything else,
|
||||
in one helper used by all six. The other KPI criteria are safe: `timeSpan` goes through the
|
||||
data-list date builder (fixed tokens, otherwise `DateTime.Parse`), tags through the tag
|
||||
helper (escaped), `wostatus` and `userid` are integers.
|
||||
|
||||
### Raw SQL: what was checked and found safe (pass 6)
|
||||
|
||||
- **Data-list filters** (`DataListSqlFilterCriteriaBuilder`): column keys are looked up in the
|
||||
list's field definitions (unknown keys throw); text, phone, email and URL values have quotes
|
||||
doubled; numbers, roles and ids are parsed (case 4676); bools become `true`/`false`; dates
|
||||
and relative-date tokens are parsed; durations are rebuilt from parsed integers; tags are
|
||||
normalised and quote-doubled.
|
||||
- **Data-list sort and columns**: keys looked up in the field definitions; direction becomes
|
||||
`ASC`/`DESC`.
|
||||
- **`ClientCriteria`**: every list that reads it (51) turns it into a typed filter option, so it
|
||||
goes through the filter builder above; saved filters too.
|
||||
- **Search**: the word breaker keeps only letters, digits and `%`, so terms cannot contain a
|
||||
quote. Safe by construction, but fragile: parameters would not depend on the tokenizer.
|
||||
- **Customer notification "who" query, schedule, attachments list, name fetcher, unit and
|
||||
customer queries, accounting list, server metrics, job and sweeper queries**: typed values
|
||||
or fixed SQL; schedule uses EF.
|
||||
- `DbUtil` and `AySchema` build SQL from configuration (database name), not from requests.
|
||||
|
||||
Hardening, not a finding: hand-built SQL with string escaping is spread over the builders.
|
||||
Moving free-text values to `NpgsqlParameter`s (pick lists, data-list text filters, search,
|
||||
tags) would remove the whole class of bug; at least F18 and F26 should use parameters or an
|
||||
allowlist rather than escaping.
|
||||
|
||||
### F27 Restricted roles bypass work-order item filtering (pass 7, confirmed)
|
||||
|
||||
`WorkOrderBiz.WorkOrderGetAsync` removes, for TechRestricted, SubContractor and
|
||||
SubContractorRestricted users, every item they are not scheduled on, other users' labour,
|
||||
travel and scheduling, and (by role) parts, units, expenses, loans and outside services.
|
||||
`GetReportData` (also used by `GetExportData`) calls `WorkOrderGetPartialAsync`, which applies
|
||||
that filtering only when the requested type is `WorkOrder`; for the child types (item,
|
||||
labour, expense, part, ...) it loads the parent header and the whole item, with every
|
||||
descendant when `IncludeWoItemDescendants` is set by the caller, and filters nothing. These
|
||||
roles have change (so read) rights on the child types they work with, so `report/data`,
|
||||
`report/render-job` and `export/render` accept them. **Proof**: on work order 1, seeded
|
||||
`SubContractorRestricted` got 0 items from `workorder/1` (correct, not scheduled), and from
|
||||
`report/data` with a labour id and descendants on got the item with another user's labour,
|
||||
the scheduling and 2 parts (a role that should see no parts at all). Fix: apply the same
|
||||
restricted-user filtering in `WorkOrderGetPartialAsync` (one shared method used by both
|
||||
paths), and refuse child-type report and export requests for items the user is not scheduled
|
||||
on. Check `QuoteBiz` and `PMBiz` for the same split (they also reference restricted users).
|
||||
|
||||
### F28 Contact logins with staff roles (pass 7, candidate, code reading)
|
||||
|
||||
`UserBiz` lets anyone with modify (create) rights on `Customer` create and edit customer contact
|
||||
users (`IsOutsideCustomerContactTypeUser`), and `BizRoles` gives `Customer` change rights to
|
||||
Service, Sales and Accounting. Changing a user between inside and outside types needs rights
|
||||
on both User and Customer (`UserBiz.cs:803`), but nothing limits the **roles** a contact user may
|
||||
hold: `ValidateAsync` checks only that the roles value is valid. Authorisation is by role
|
||||
everywhere, so a contact with BizAdmin or OpsAdmin roles is an administrator. A Sales, Service
|
||||
or Accounting user could therefore create a contact login with administrator roles and a
|
||||
password of their choosing. Not tested live. The F13 to F17 allowlist would limit what a
|
||||
customer-type login can call, but the roles must still be constrained. Fix: in
|
||||
`UserBiz.ValidateAsync`, allow only `Customer` and `CustomerRestricted` roles on Customer and
|
||||
HeadOffice users (and only inside roles on inside users); reject anything else on create and
|
||||
update.
|
||||
|
||||
### F29 `server-info` to every login (pass 7)
|
||||
|
||||
`GET server-info` has no role check and returns the version, schema, server time zone, the
|
||||
server DB ID and the licence as JSON, to any login including customer contacts. The about
|
||||
page needs some of it; contacts need little or none. Whether the DB ID matters to Sockeye
|
||||
(licence fetch, trial requests) is checked in pass 11. Fix: return the DB ID and licence only
|
||||
to staff, or only to ops and admin roles.
|
||||
**Fixed in case 4727, step 3 (2026-10-24):** `server-info` returns the DB ID and licence, and `global-biz-setting/client` the DB ID and licensee, only to Service and NotService users (SuperUser is NotService), the same rule the About form already used to show them; customer contacts and subcontractors get empty values. Test `ApiRoute.DbIdAndLicenceForStaffOnly` (a contact received the DB ID on the old code).
|
||||
|
||||
### Pass 7: routes without a role check that were found OK
|
||||
|
||||
`DELETE notify/{id}` (owner checked), `notify/new-count`, `notify/app-notifications`,
|
||||
`notify-subscription/list` (the caller's own), data-list column views (validation refuses
|
||||
another user's id), saved filters (own or public), `user-option/{id}` (own unless admin),
|
||||
`auth/change-password`, `auth/totp*` (own), `POST kpi` (each KPI checks its own roles; but see
|
||||
F26), `POST search` (results filtered by read rights per type), `GET user/amsu` (returns only
|
||||
whether the caller is SuperUser), enum lists, form field references, translations (read),
|
||||
tag lists, role lists, build mode, server state, `global-biz-setting/client` (display
|
||||
settings only). Already covered elsewhere: `job-operations/*` (F22), `schedule/*` (F16),
|
||||
`workorder/customer/{id}` (F13), `report/upload` (F19), `translation/upload` (F24),
|
||||
`notify/direct-smtp` and `direct-message` (F3, F4).
|
||||
|
||||
### F30 SMTP certificate not checked (pass 8, from code)
|
||||
|
||||
`Mailer.SendEmailAsync` sets `client.ServerCertificateValidationCallback = (s, c, h, e) => true`,
|
||||
so any certificate is accepted. Anyone who can intercept the connection (a hostile network,
|
||||
DNS) can pose as the mail server and collect the SMTP account and password and every message.
|
||||
Also, with security set to none, MailKit's `ConnectAsync(host, port, false)` uses STARTTLS only
|
||||
if offered, which an attacker can suppress. Fix: remove the callback (validate normally); if a
|
||||
customer needs a self-signed mail server, make that an explicit setting. **Fixed in case 4724 (2026-10-24):** the callback is set only when the new config setting `AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE` is true (boot warning when it is; shown in server info; manual page `ops-config-smtp-accept-any-certificate.md`). Revocation checking is off (`CheckCertificateRevocation = false`) so an unreachable revocation server can't stop email; trust and name are checked. Proved locally against a TLS listener with a self-signed certificate: refused ("root certificate which is not trusted") without the setting, accepted with it. The STARTTLS-stripping point for security None stays as chosen by the customer; the security option mapping is in Deferred.
|
||||
|
||||
### F31 SMTP password returned to read-only roles (pass 8, from code)
|
||||
|
||||
`GET global-ops-notification-setting` returns the whole settings object, `SmtpPassword` included,
|
||||
to every role with read rights on `OpsNotificationSettings`: OpsAdminRestricted, BizAdmin and
|
||||
BizAdminRestricted, while only OpsAdmin may change it. Those users can then use the company's
|
||||
mail account directly. Fix: never send the password to the client (return a "set" flag; keep the
|
||||
stored value when the PUT leaves it empty). **Fixed in case 4724 (2026-10-24):** the GET returns `********` (entity detached first); a PUT with `********` or an empty value keeps the stored password. Test `SmtpPasswordHidden`; the stored password's fingerprint was unchanged after the round trip. Before the fix BizAdmin received the full password.
|
||||
|
||||
### F32 SMTP credentials in source (pass 8)
|
||||
|
||||
`models/GlobalOpsNotificationSettings.cs`, in the `#if (DEBUG)` constructor, sets a FastMail SMTP
|
||||
server, account and password; the same file is in `sockeye/server/models`. Release builds use
|
||||
placeholders, so shipped software is not affected, but the credentials are in both repositories
|
||||
and their history (contrary to the "secrets never go in a repo" rule), and debug builds send real
|
||||
email through that account (which explains report 81's real bounces in testing). Fix: rotate that
|
||||
FastMail app password now (John); remove the values from both files and read debug SMTP settings
|
||||
from an environment variable or user secrets. History need not be rewritten once the password is
|
||||
rotated. **John, 2026-10-23:** rotation planned once the old Sockeye server is retired after the
|
||||
migration; its own case.
|
||||
|
||||
OK in pass 8: SMTP settings change and the test-send route are OpsAdmin only; batch direct SMTP
|
||||
(`job-operations/batch-direct-smtp`) checks modify rights on the record type and sends only to the
|
||||
records' own addresses; notification emails are plain text built from record names (no HTML
|
||||
injection); the customer notification "who" query escapes tags.
|
||||
|
||||
### F33 Unescaped record text rendered as HTML in the client (pass 9, from code)
|
||||
|
||||
Of the 13 `v-html` uses in `raven-client`, these render text that users enter, without
|
||||
escaping it first:
|
||||
- `views/home-search.vue`: the search result excerpt (`item.info`, from `Search.GetInfoAsync`,
|
||||
which does not encode it), with the search terms highlighted. Searchable text includes
|
||||
fields customer contacts can write (service requests, units).
|
||||
- `components/work-order-address.vue`: addresses built by `formatAddress` from customer, head
|
||||
office and unit address fields.
|
||||
- `components/work-order-item-units.vue`, `quote-item-units.vue`, `pm-item-units.vue`: the
|
||||
warranty summary (vendor name, receipt number, work order list).
|
||||
- `components/gzconfirm.vue`: dialog messages, which are translation strings; with F24 any login
|
||||
can import a translation.
|
||||
|
||||
The session token is kept in `localStorage` (`vuex-persistedstate`), so script running in the
|
||||
page could act as the signed-in user. Fix: escape these values (an `escapeHtml` helper, or
|
||||
build the markup with Vue templates instead of strings); encode the excerpt on the server too;
|
||||
render translation strings as text. Safe: the wiki (`marked` then DOMPurify, see F34), the
|
||||
calendar summaries (Vuetify escapes event names), and the remaining sinks (fixed strings).
|
||||
A Content-Security-Policy (F35) would be a second layer.
|
||||
|
||||
### F34 Client libraries behind on security fixes (pass 9)
|
||||
|
||||
`npm audit` (read-only, lockfile) reports 550 advisories, almost all in build tools (webpack,
|
||||
Vue CLI, Babel, ESLint) that run only on the build machine and do not ship. Of the packages
|
||||
the app ships: **DOMPurify 2.3.8**, the wiki sanitiser, has sanitiser-bypass fixes in 2.4.x and
|
||||
2.5.x; **Vuetify 2.6.6** has XSS fixes in 2.6.10 (the last 2.x is 2.7.x); `marked` 1.2.9, `luxon`
|
||||
1.28.0 and `vue` 2.6.14 have only slow-regex (browser-side) advisories. Fix: raise DOMPurify to
|
||||
the latest 2.5.x and Vuetify to the latest 2.7.x, both on the same major version. This is a
|
||||
front-end dependency change, which CLAUDE.md rules out without John's decision; check that
|
||||
both still build with Node 12.22.9. **John, 2026-10-23: both upgrades approved for 8.3.0**, provided they do not conflict with the Node 12.22.9 pin or break the client. Checked (read-only `npm view`): DOMPurify 2.5.9 is the latest 2.x, no engine limit, same API; Vuetify 2.7.2 (latest 2.x, peer `vue ^2.6.4`) is the target, with **2.6.16** (last 2.6, has the 2.6.10 fix) as the fallback if webpack 4 on Node 12 cannot build 2.7. Prove by a full client build, the lint, and a smoke test of wiki, calendar and date pickers, then Cypress on `devtestn10`; `package.json` and `package-lock.json` change in their own commit.
|
||||
|
||||
### F35 Headers, CORS, Swagger, keys in URLs (pass 9)
|
||||
|
||||
- The server sets no security headers. Hosted droplets get HSTS, `nosniff`, `X-Frame-Options`
|
||||
and `Referrer-Policy` from the nginx template in the setup script; self-hosted IIS, Linux and
|
||||
Windows installs get none. No deployment sets a Content-Security-Policy.
|
||||
- CORS allows any origin, method and header. Impact is small because the token travels in the
|
||||
`Authorization` header (not a cookie), so another site cannot use a visitor's session.
|
||||
- Swagger UI and the full API description are public at `/api-docs`.
|
||||
- Download keys (`?t=`) are in URLs, so they reach proxy access logs and browser history;
|
||||
they last 5 days (F7 makes some last longer).
|
||||
|
||||
Fix: add the headers in the app (so every deployment gets them), with a CSP that allows the
|
||||
client's own scripts; limit CORS to the server's own origin unless API users need more;
|
||||
consider Swagger UI for logged-in users only.
|
||||
|
||||
**John, 2026-10-24:** the safe headers (`nosniff`, `X-Frame-Options`, `Referrer-Policy`) in case 4727. Content-Security-Policy for the client: its own later case, it needs testing of the whole client. CORS: **accepted** as is (the token is a header, not a cookie; limiting origins could break browser-based API users). Swagger at `/api-docs`: **accepted** public (it is the API documentation).
|
||||
**Case 4727, step 6 (2026-10-24):** every response carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN` and `Referrer-Policy: strict-origin` (the hosted nginx values, so the duplicates behind nginx agree); test `ApiRoute.SafeHeadersOnEveryResponse`. Still open from F35: the client Content-Security-Policy (later case); download keys in URLs stay (accepted with F7's fix).
|
||||
|
||||
### F36 Listening on all interfaces, no firewall (pass 10, from code)
|
||||
|
||||
Every `config.json` template (`lan-install-config.json`, `linux-server`, `linux-desktop`, the
|
||||
standalone launcher's) sets `AYANOVA_USE_URLS` to `http://*:7575`. On hosted droplets nginx
|
||||
proxies HTTPS to `127.0.0.1:7575`, but Kestrel also listens on the public address, and the setup
|
||||
script configures no host firewall. Unless a DigitalOcean cloud firewall blocks it (**John, 2026-10-23: every droplet has a restrictive
|
||||
cloud firewall**, the same settings confirmed for the Sockeye migration, so hosted droplets are
|
||||
not exposed; re-rated Low), anyone can reach AyaNova directly on port 7575 over plain HTTP, without TLS and
|
||||
without nginx's headers, and logins sent there travel in clear. The standalone (single-PC)
|
||||
install also accepts connections from the whole network. Fix: hosted and Linux server
|
||||
templates bind `http://127.0.0.1:7575` when behind a proxy; the setup script enables `ufw`
|
||||
allowing only 22, 80 and 443; standalone binds to localhost; LAN stays on all interfaces (that
|
||||
is its purpose) and the manual recommends HTTPS through IIS for anything beyond the LAN.
|
||||
**John, 2026-10-24:** standalone **left** on all interfaces (some users open it from another device; binding it to localhost would break them). Hosted: bind `127.0.0.1` in the `sockeye-client` setup script (to do there). `ufw`: not needed, every droplet has the restrictive cloud firewall.
|
||||
**Fixed for hosted in case 4727 (2026-10-26):** the setup script changes `AYANOVA_USE_URLS` to `http://127.0.0.1:7575` (`sockeye-client` `9f300a6`); existing droplets get the same change from the one-time `harden-830.sh` at the 8.3.0 rollout (see F38). Tested 2026-10-26 on `devtestn10` (Ubuntu 24.04.5), both paths, with the subscription zip of 2026-10-23: existing droplet with `harden-830.sh`, then a fresh install with the updated script (droplet rebuilt). Each time: AyaNova only on `127.0.0.1:7575`, the public address refused, HTTPS 200; `www-data` cannot write the program folder, can write `data`; a report render (including the first-use browser download into `data/report-browser`), a manual backup (pg_dump) and an attachment upload, download and delete all completed with no read-only or denied-access errors in the journal or the AyaNova log. `systemd-analyze security`: 9.2 to 8.3.
|
||||
|
||||
### F37 Standalone PostgreSQL trusts local connections (pass 10, from code)
|
||||
|
||||
`dist/win-x64/postgres-empty-database/pg_hba.conf` uses `trust` for local, 127.0.0.1 and ::1, and
|
||||
`listen_addresses` is the default (localhost). Any user or program on that PC can connect as any
|
||||
database user, the `postgres` superuser included, without a password. Standalone is a
|
||||
single-PC (mostly evaluation) install, so the exposure is local only; with F2 the database
|
||||
files are writable by every local user anyway. Fix: `scram-sha-256` with a password generated
|
||||
at install and written to the launcher's `config.json`; or accept it for evaluation installs
|
||||
(John's decision). **John, 2026-10-23: accepted** for standalone unless the fix is very short and
|
||||
low-risk; the scram change needs a generated password in the installer, the launcher config and a
|
||||
prepared database, so it is not that short: accepted.
|
||||
|
||||
### F38 Linux hardening (pass 10, from code)
|
||||
|
||||
- The systemd unit written by the setup script runs as `www-data` with no sandboxing options
|
||||
(`NoNewPrivileges`, `ProtectSystem=strict` with `ReadWritePaths=/var/ayanova/data`,
|
||||
`PrivateTmp`, `ProtectHome`).
|
||||
- `chmod g+rw /var/ayanova/` gives `www-data` (which nginx also runs as) write access to the
|
||||
program folder itself, so the service account can replace program files by renaming. Only
|
||||
`data` needs write access.
|
||||
- The Linux desktop install runs as root (`sudo ./AyaNova`, see Deferred), so the report browser,
|
||||
already started without Chrome's sandbox, runs as root too.
|
||||
- `dist/docker/linux-x64/docker-compose.yml` publishes PostgreSQL on port 5432 with a fixed
|
||||
superuser password. It is not built (`build-release.bat` lines are commented out) or
|
||||
documented; delete it or fix it before anyone uses it.
|
||||
|
||||
**John, 2026-10-24:** the `chmod` and the systemd sandboxing options go into the `sockeye-client` setup script (to do there; the systemd options need testing on a droplet so nothing that writes, such as logs, backups or the report browser download, is blocked). `dist/docker`: early development option, never offered: removed in case 4727, step 7 (2026-10-24; with its commented-out lines in `build-release.bat`; git history keeps it).
|
||||
**Fixed for hosted in case 4727 (2026-10-26):** the setup script (`sockeye-client` `9f300a6`) no longer gives the service group write access to the program folder (`chmod -R go-w /var/ayanova`, then group write on `data` only) and adds `NoNewPrivileges`, `ProtectSystem=strict` with `ReadWritePaths=/var/ayanova/data`, `PrivateTmp` and `ProtectHome` to the unit. Existing droplets get the same from a one-time `harden-830.sh`, run right before each droplet's 8.3.0 upgrade (in `devdocs/build/RELEASE-PROCEDURE.md`; the sandbox options as a drop-in, `ayanova.service.d/hardening.conf`; also `config.json` 640 and the old `Chrome` folders removed). Tested 2026-10-26 on `devtestn10` (Ubuntu 24.04.5), both paths, with the subscription zip of 2026-10-23: existing droplet with `harden-830.sh`, then a fresh install with the updated script (droplet rebuilt). Each time: AyaNova only on `127.0.0.1:7575`, the public address refused, HTTPS 200; `www-data` cannot write the program folder, can write `data`; a report render (including the first-use browser download into `data/report-browser`), a manual backup (pg_dump) and an attachment upload, download and delete all completed with no read-only or denied-access errors in the journal or the AyaNova log. `systemd-analyze security`: 9.2 to 8.3. Self-hosted Linux servers (case 4727, 2026-10-26): the manual's install steps now set the same permissions and service settings and, once NGINX works, bind `127.0.0.1` (`ops-install-linux-server.md`, `ops-config-use-urls.md`); the 8.3.0 upgrade step no longer re-adds group write on the program folder and offers the settings as a drop-in for existing installs; its upgrade commands use `curl -fO`, so a failed download stops before the program files are deleted (`ops-upgrade-linux-server.md`). Further systemd restrictions (kernel, address families, system calls) would each need the same test with the report browser and pg_dump: a later case if wanted. The desktop install running as root stays in Deferred.
|
||||
|
||||
OK in pass 10: the setup script generates a unique database password and JWT secret per
|
||||
droplet, stores them in `/root/ayanova-secrets.txt` (600), makes `config.json` 640 `root:www-data`,
|
||||
runs AyaNova as `www-data`, installs unattended upgrades, uses PGDG PostgreSQL on its default
|
||||
localhost listener, and gets a Let's Encrypt certificate with HSTS and the headers in F35.
|
||||
|
||||
### F41 The 2FA flaw (F5) in Sockeye and the business server (pass 11, from code)
|
||||
|
||||
Sockeye is a near-copy of this codebase: `sockeye/server/Controllers/AuthController.cs:168` matches
|
||||
the 2FA step on `TempToken` alone and sets it to `string.Empty` after success, exactly as F5.
|
||||
Sockeye's login is on the internet (sockeye.ayanova.com) and holds the customer list, licences
|
||||
and subscription servers; the business AyaNova server (8.2.x) has the same code. If an account on
|
||||
either has 2FA turned on, it can be reached with no password and a guessed code. **Immediate
|
||||
mitigation (no code change):** turn 2FA off on those accounts until the fix is deployed; with 2FA
|
||||
off the account is not matched by this query and the password protects it as usual. Then apply
|
||||
the F5 fix to Sockeye too. **John, 2026-10-23: 2FA turned off** on the affected accounts; the fix goes
|
||||
in the same case as F5 (one commit per repo). Other shared-code findings apply to Sockeye as well (F7, F8, F12, F19,
|
||||
F21, F30; F1 if its config kept a placeholder); its admin UI is used by John alone, so only the
|
||||
anonymous ones (F5, F41) are urgent there.
|
||||
|
||||
### F42 Licence-signing private keys in Sockeye source (pass 11, from code)
|
||||
|
||||
`sockeye/server/biz/RavenKeyFactory.cs` holds two RSA private keys as string literals (lines 158
|
||||
and 222; not reproduced here) and signs licence keys with them. They are therefore in the Sockeye
|
||||
repository and its history (self-hosted Gitea), in every build of Sockeye, and on the Sockeye
|
||||
droplet in the deployed DLL. Anyone who obtains any of those can create licence keys that every
|
||||
AyaNova server accepts. AyaNova checks licences against the matching public key
|
||||
(`License.cs`, "our real 2016 public key"). Fix: load the private key at run time from a file or
|
||||
environment variable on the Sockeye server only (root-readable, outside the repo and the
|
||||
published files) and remove it from the source. Rotation (new key pair, new public key in
|
||||
AyaNova, every licence reissued) is a business decision for John; without rotation the keys stay
|
||||
in history, so access to the repository and its backups must be treated as access to the keys.
|
||||
**John, 2026-10-23:** no rotation now; moving the key out of source becomes its own case for later
|
||||
(not an 8.3.0 blocker).
|
||||
|
||||
### F39 One-shot licence fetch by DB ID (pass 11, from code)
|
||||
|
||||
`RvfController` (`POST rvf`, anonymous) returns the newest active licence for a DB ID, once:
|
||||
`FetchedOn` is set on the first fetch and later fetches get 404. DB IDs are random (32 bytes) but
|
||||
not secret: the anonymous API root page shows it while a server is unlicensed (F11) and
|
||||
`server-info` shows it to every login (F29). Someone who knows it can take a newly issued key
|
||||
first; the real server then never receives it (a support case; the key only works for that DB
|
||||
ID, but it names the licensee and features). Fix: let the same DB ID fetch its key again (the key
|
||||
is bound to the DB ID, so one-shot adds nothing), and stop showing the DB ID anonymously.
|
||||
**AyaNova half fixed in case 4727, steps 2 and 3 (2026-10-24):** the DB ID is no longer shown anonymously (API root page) nor to customer contacts and subcontractors (`server-info`, client settings). The Sockeye half (repeatable fetch) is the later two-sided case with the server fingerprint.
|
||||
|
||||
### F40 Trial request routes (pass 11, from code)
|
||||
|
||||
`POST rvr` (AyaNova trial request) and `POST subscription-server-trial-request` are anonymous with no
|
||||
rate limit. Each `rvr` request with a new DB ID stores a request and sends Sockeye's fixed
|
||||
verification email to the address given; the email confirm code (`StringUtil.GenFetchCode`) is
|
||||
10 letters from `System.Random`, not a cryptographic generator. The subscription-server request's
|
||||
only bot check is a fixed value (`RValue == "totallylegit"`). Approval is manual, so none of this
|
||||
issues a licence; the exposure is email to arbitrary addresses and a cluttered request list.
|
||||
Fix: nginx `limit_req` on these routes; `RandomNumberGenerator` for codes; optionally a CAPTCHA
|
||||
on the website forms.
|
||||
|
||||
OK in pass 11: no licence is issued without John's approval; a DB ID with a purchased licence
|
||||
cannot request a trial; licence keys are bound to the DB ID and signed (AyaNova verifies the
|
||||
signature).
|
||||
|
||||
### F43 Unit pick-list variant in SQL (found 2026-10-24 during case 4712, from code)
|
||||
|
||||
`UnitPickList.GetVariantCriteria` takes a `customerid:<value>` variant from the request and returns
|
||||
`aunit.customerid = {v[1]}`, the value unparsed, which `PickListSqlBuilder` adds to the query. The
|
||||
route (`pick-list/list`, `pick-list/single`) is open to every login, customer contacts included. Same
|
||||
class as F18 and F26. **Correction to pass 6:** "Raw SQL: what was checked and found safe" said the
|
||||
pick-list variants were parsed as numbers or matched to fixed strings; that holds for the other six
|
||||
lists with variants but not this one (the pass 6 search looked for `{variant}` and `{crit[...]}`
|
||||
interpolations and missed `{v[1]}`). Fix: parse the value as a `long` and ignore the variant otherwise,
|
||||
as the other lists do.
|
||||
|
||||
### F2 Windows folder permissions (from the 8.3.0 installer test)
|
||||
|
||||
`lan.iss` and `standalone.iss` set `Permissions: users-modify` on `{app}` (the program
|
||||
folder) and on `{commonappdata}\ayanova` (data: database for standalone, backups,
|
||||
attachments, logs). Any local Windows user can therefore replace program files that run as
|
||||
the user who starts AyaNova or as the IIS application pool, and can read the database files
|
||||
and backups. Matters on shared or terminal-server machines. The program-folder permission
|
||||
originally let the report browser download beside the app; since 8.3.0 it downloads under
|
||||
the data folder, so `{app}` may no longer need it. To confirm in pass 10. **Pass 10:** confirmed from `lan.iss` and `standalone.iss`. `config.json`, with the database password and the JWT secret, is installed in `{app}`, so any local user can read those secrets and change the configuration or the program files; standalone also grants `users-modify` on `{app}\local-postgres` (the PostgreSQL binaries) and the database folder. Fix: drop `users-modify` from `{app}` and `local-postgres`; keep write access only where the running account needs it (logs, backups, temp, attachments, report browser), granted to that account or to Administrators rather than all Users; give `config.json` an ACL for Administrators and the service account. **Fixed in case 4579 (2026-10-25):** both installers no longer grant `users-modify` on `{app}` or `{app}\local-postgres`, and after copying files run `icacls "{app}" /remove:g *S-1-5-32-545 /T /C /Q` (Users group by SID), which also removes the old grant on upgrades; inherited read rights from Program Files stay. Nothing writes to `{app}` since 8.3.0. Proved in Windows Sandbox: single-user fresh install and upgrade over 8.2.4, LAN upgrade over the downloaded LAN installer: 4632 (single) and 1281 (LAN) Users modify entries before, 0 after; login, trial, attachment, backup and report render work after the upgrade. **Accepted (John, 2026-10-25), documented in the single-user and LAN install pages:** the data folder keeps `users-modify` and `config.json` (database password) stays readable by local users, because AyaNova runs as whichever user starts it (or the IIS app pool) and that account is not known at install time.
|
||||
|
||||
### F3 `notify/direct-smtp` open to every logged-in user
|
||||
|
||||
`NotifyController.SendNotifySmtpDirectMessage`: no role or user-type check; the code comment
|
||||
says this is intentional (Customer form, and API users emailing rendered reports, case 4310).
|
||||
Any logged-in user, customer contacts included, can send an email with any recipient
|
||||
(`ToAddress`), subject and HTML body through the company's configured SMTP server, from its
|
||||
address. On a hosted or internet-facing server one customer-contact login is enough to send
|
||||
phishing or spam under the company's name. To confirm in pass 8: which callers the client
|
||||
actually has, and the narrowest check that keeps them working (staff roles only; or
|
||||
recipient restricted to the object's own address). **Pass 8 (code):** confirmed. When `ToAddress` is given it is used as is (only when it is empty is the address taken from the Customer, HeadOffice, Vendor or User record); subject, text and HTML body come from the request; one recipient per call (`MailboxAddress.Parse`), no rate limit. The event log records each send. Fix: drop `ToAddress` from the route and always take the address from the record (the Customer form's use), check read rights on that record, and refuse customer-type users; API users who email rendered reports keep working through a record address.
|
||||
|
||||
### F4 `notify/direct-message` open to every logged-in user
|
||||
|
||||
Any logged-in user can add a general notification, with the sender's name and any text, for
|
||||
any user id. Low on its own (in-app only); relevant with F3 for social engineering of staff
|
||||
by a customer contact. Pass 8: confirmed from code (`NotifyController.SendNotifyDirectMessage`, no role check, any `ToUserId`). Fix: staff only, or only to users the caller may see.
|
||||
|
||||
**Fixed (F3, F4), 2026-10-24.** Customer contacts were already refused by the case 4712 route allowlist; case 4723 also refuses them in both actions. `direct-smtp`: an address taken from a Customer, HeadOffice, Vendor or User record needs read rights on that type (a contact's user record: User or Customer rights, as on the Contact form); the record checks run before the check that email delivery is on. Staff may still give any `ToAddress`, which John kept on purpose: the stock report template "z_API send SMTP from work order report" (`objectId 0`, `toAddress` from the work order) and at least one customer's report script rely on it. Every send is in the event log. Accepted remaining risk: a staff login can send email with any content to any address through the company's mail server. `direct-message`: refused to subcontractors too, as the client already hides it from them. Test `DirectSend` (refusals only, so no email is sent).
|
||||
|
||||
## Cases
|
||||
|
||||
John's cases for the findings (created 2026-10-24; full list in `NET10-PORT.md`, "Release 8.3.0
|
||||
cases"): 4710 (F5, F41), 4711 (F1), 4712 (F13 to F17), 4715 (F21), 4716 (F18), 4717 (F26),
|
||||
4718 (F19, F24), 4719 (F20), 4720 (F27), 4721 (F33), 4722 (F34), 4723 (F3, F4), 4724 (F30, F31),
|
||||
4725 (F7, F8, F9), 4726 (F6), 4579 (F2), 4713 (F28), 4727 (F11, F12, F22, F23, F25, F29, F35, F36, F38, and
|
||||
the AyaNova half of F39), Sockeye 4681 (F40). The Sockeye half of F39 (repeatable licence fetch) moved to a later case: AyaNova reinstalls and logs any key it fetches without comparing it with the installed one (trial servers with data would log an E1020 error on every fetch), and deployed older servers cannot be changed, so it needs an AyaNova-side change plus a request flag that Sockeye honours. Not in 8.3.0: F10 (case 4736), F32, F42 (John's later cases); F37 accepted. When a
|
||||
case is done, its findings' status becomes *fixed in case NNNN*.
|
||||
|
||||
## Customer contact route allowlist (implemented in case 4712)
|
||||
|
||||
The API routes the client can call for a customer contact (`UserType` Customer or HeadOffice),
|
||||
read from `raven-client` on 2026-10-23: the customer menu in `api/initialize.js` (user
|
||||
settings, notification subscriptions, work orders, service requests), the screens behind it
|
||||
(`customer-workorders`, `customer-workorder`, `customer-csr`, `customer-csr-edit`,
|
||||
`home-user-settings`, `home-password`, `home-security`, `home-notify-subscription(s)`,
|
||||
`home-notifications`, `ay-about`, `ay-report-view`, `login`, `home-reset`), and the components
|
||||
they use. Staff-only parts of shared components are left out: data-table extensions (export,
|
||||
batch delete, tags, direct SMTP) are not on customer screens, attachments and wiki are
|
||||
read-only there, and CSR accept/reject are staff menu items. Method used:
|
||||
`customer_routes.py` (kept outside the repo) follows each screen's imports and `gz-` components
|
||||
and lists every `$gz.api` call; the result was then checked by hand.
|
||||
|
||||
Everything not on this list would answer 403 to a customer-type user. **Ownership** says what
|
||||
each id route must also check.
|
||||
|
||||
| Area | Routes | Ownership check needed |
|
||||
|---|---|---|
|
||||
| Login, session (already anonymous or exempt) | `auth`, `auth/tfa-authenticate`, `auth/reset-password`, `notify/hello`, `translation/subset[/{id}]`, `logo/{size}`, `license/lc` (F10) | — |
|
||||
| Own account | `auth/change-password`, `auth/totp`, `auth/totp-validate`, `auth/totp-disable/{id}`, `user-option/{id}` GET and PUT | `{id}` = the caller (`totp-disable` already; check `user-option`) |
|
||||
| App shell | `global-biz-setting/client`, `enum-list/list/{key}`, `form-custom/{formkey}`, `translation/list`, `translation/subset` (logged-in version, used to load translations), `server-info`, `notify/new-count`, `notify/app-notifications`, `DELETE notify/{id}` | `notify/{id}`: the caller's own notification (pass 7) |
|
||||
| Data lists (work orders, CSRs) | `data-list` (server already forces the customer filter), `data-list/listfields`, `data-list-column-view…`, `data-list-filter…`, `tag-list/list`, `pick-list/list` from the filter editor | saved filters: already scoped to the user |
|
||||
| Work order (customer view) | `workorder/customer/{id}`, `attachment/list` (work order only), `attachment/download/{id}` (download key), `report/render-job` with report `-100` only, `job-operations/status|logs/{gid}`, `report/request-cancel/{gid}`, `report/download/{file}` | work order belongs to the contact's customer (or a customer of its head office): **F13**; job and report file belong to the caller (pass 4) |
|
||||
| Service requests | `csr` POST/PUT, `csr/{id}` GET/DELETE (not `accept`/`reject`), `unit/customerunit` POST, `pick-list/list` and `pick-list/single` for Customer (head-office variant), Unit (own customer) and UnitModel only, with the server's own template | CSR and unit belong to the contact's customer; `CustomerId` forced on create: **F14**, **F15** |
|
||||
| Notification subscriptions | `notify-subscription/list`, `notify-subscription/{id}` GET/DELETE, `notify-subscription` POST/PUT, `work-order-status/list`, `quote-status/list` (names only, used by the editor) | `UserId` forced to the caller: **F17** |
|
||||
|
||||
Confirmed while fixing (case 4712): for customer users the client's open-object handler only navigates
|
||||
to the customer screens, so its staff routes (`attachment/parent`, `form-custom/form-key`,
|
||||
`user/inside-type`, `search/ancestor`) are not needed; `server-info` stays open (the About page calls it
|
||||
for everyone; F29 trims what it returns). The list lives in
|
||||
`server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs`; the token middleware answers 403 to a
|
||||
contact for anything else (also closes F8's suffix exemption for contacts).
|
||||
|
||||
**Shape of the fix.** One check in the token middleware, which already loads `UserType` for
|
||||
every request: for Customer and HeadOffice users, compare the matched endpoint's route
|
||||
template (`context.GetEndpoint()`, available because `UseRouting` runs first) with this
|
||||
allowlist and answer 403 otherwise. New routes then stay closed to contacts unless added
|
||||
on purpose. The ownership checks stay in the routes themselves (F13, F14, F15, F17). F16
|
||||
(schedule) is closed by the allowlist alone; its staff role check is still needed for staff
|
||||
roles that should not see the schedule (pass 7).
|
||||
|
||||
## Pass log
|
||||
|
||||
| Date | Pass | Result |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | 1 Threat model and map | Scope agreed with John. Middleware, JWT validation, role model, download keys, CORS and Swagger read. Route map generated (460 actions; script kept in `security/routemap.py`). No route lacks authentication by accident. 53 logged-in routes have no role check in the action: 3 resolved (erase data, trial seed: OK; direct SMTP: F3), the rest assigned to later passes. Local-request exception not reachable through the hosted nginx. Findings F1 to F4 recorded as candidates |
|
||||
| 2026-10-23 | 2 Login, tokens, reset, 2FA, anonymous routes | Read `AuthController`, the token middleware, `Hasher`, download-key check, password reset, TOTP setup, and every anonymous route (`notify/hello`, `license/lc`, `translation/subset`, `logo`, API root, health). Tested on the local dev server with the seeded `Inventory` user: **F5 critical, confirmed** (2FA step without password); **F1 confirmed** (current token rebuilt from the secret in 6 tries); F8 confirmed. New: F6, F7, F9 to F12. OK: reset codes (32 random characters, 48 h, cleared on use, same reply for unknown codes), login replies do not reveal whether a login exists, roles read from the database on every request, deactivation ends the session, TOTP secret 160 bits. Leads for pass 4: `AuthController.GenRpt` issues SuperUser (id 1) tokens with an `rpl` claim for report rendering; pass 5: the logo is served with the content type given at upload |
|
||||
| 2026-10-23 | 3 Customer contacts | Mapped the `Customer` role in `BizRoles.cs` and every customer-reachable route. Tested on the local dev server as seeded contact `Customer` (customer 1), read-only: F13, F14 (read), F15, F16 confirmed. F17 and F18 from code (F17 not tested because it sends email; F18 not tested by choice). OK: data lists (server sets the customer filter), KPIs (no customer roles), saved filters and notification subscription reads (scoped to the user), memos (no customer access) |
|
||||
| 2026-10-23 | 4 Report rendering | Read `ReportController`, `ReportBiz` (render path, data, browser launch), `ay-report.js` helpers, `CoreJobCustomerNotify` and `AuthController.GenRpt`. Tested on the local dev server: **F19 confirmed** (contact imported a stock template copy, deleted afterwards). F20, F21 and F22 from code; rendering of a crafted template and the download path were deliberately not tested. OK: `report/data` and `render-job` check read rights on the record type; template API calls use the renderer's own token (except notification reports, F20); report output files have random names. Pass 5 picks up F21's other routes |
|
||||
| 2026-10-23 | 5 File handling | Read `ApiUploadProcessor`, `AttachmentController` (upload, list, download, move, delete, maintenance), `LogoController`, `ExportController`, `TranslationController` upload, `LogFilesController`, `BackupController`, `ImportController`, and the path helpers in `FileUtil`. No live tests (the role gaps have the same shape as F19, already confirmed). New: F23, F24, F25; F21 extended to the log and backup download routes (ops roles), backup delete found safe |
|
||||
| 2026-10-23 | 6 Raw SQL | Code reading only. Listed every hand-built query (`CommandText`, `*SqlRaw`, interpolated SQL) and traced each request value into it: data-list filter, sort and select builders, all 51 `ClientCriteria` users, saved filters, search tokenizer, every KPI's criteria, pick-list builder and variants, customer notification query, controllers and biz classes with raw SQL. New: **F26** (KPI `interval`). F18 completed by code. Everything else safe (see "Raw SQL: what was checked") |
|
||||
| 2026-10-23 | 7 Staff roles | Went through the remaining logged-in routes without a role check, the restricted-role filtering in `WorkOrderBiz`, and user and contact administration in `UserBiz`. Tested on the local dev server, read-only: **F27 confirmed** (`SubContractorRestricted` through `report/data`). F28 from code (live test not done). New: F29. The rest of the list found OK (see "Pass 7: routes ... found OK") |
|
||||
| 2026-10-23 | 8 Email and notifications | Code reading only, nothing sent. Read `NotifyController` (direct SMTP and message), `Mailer`, `GlobalOpsNotificationSettings` controller, biz and model, `NotifyEventHelper`, `CoreJobNotify`, `CoreJobCustomerNotify`, `NotifySubscriptionBiz`. F3 and F4 confirmed by code; F17 confirmed and re-rated Medium (names and links, no reports). New: F30, F31, F32. **Action for John: rotate the FastMail app password in F32** |
|
||||
| 2026-10-23 | 9 Client, headers, CORS, Swagger | Code reading of every `v-html` use in `raven-client` and its data source; `npm audit` read-only against the lockfile (no install, no change), triaged to shipped packages; server and nginx headers, CORS, Swagger. New: F33, F34 (needs John's decision on two front-end packages), F35; F14 extended (`unit/customerunit`) |
|
||||
| 2026-10-23 | 10 Deployment | Code reading: `lan.iss`, `standalone.iss`, the four `config.json` templates, the standalone PostgreSQL configuration, the hosted setup script in `sockeye-client` (secrets, permissions, systemd unit, nginx, certbot), `dist/docker`. F2 confirmed and extended (`config.json` readable by all users); F9 extended (hosted droplets start with the default SuperUser password). New: F36, F37, F38. John then confirmed hosted droplets have a restrictive cloud firewall (F36 re-rated Low), accepted F37, and explained the trial default password (F9 note added) |
|
||||
| 2026-10-23 | 11 Sockeye public routes | Code reading of `RvfController` (licence fetch), `RvrController` and `SubscriptionServerTrialRequestController` (trial requests), `TrialLicenseRequestBiz`, the code generator, Sockeye's `AuthController` and token middleware, and where licences are signed. New: **F41** (F5 in Sockeye and the business server; turn 2FA off until fixed), **F42** (signing private keys in source), F39, F40. **All 11 passes done** |
|
||||
51
devdocs/modernization/security/routemap.py
Normal file
51
devdocs/modernization/security/routemap.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Map every controller action: HTTP verb + route, anonymous or not, and which checks its body makes.
|
||||
# Usage: python -I routemap.py <Controllers folder> <out.tsv>
|
||||
import os, re, sys
|
||||
|
||||
src, out = sys.argv[1], sys.argv[2]
|
||||
verb_re = re.compile(r'\[Http(Get|Post|Put|Delete|Patch)(?:\("([^"]*)"\))?\]')
|
||||
method_re = re.compile(r'public\s+(?:async\s+)?[\w<>\[\], ]+\s+(\w+)\s*\(')
|
||||
checks = {
|
||||
'role': re.compile(r'Authorized\.Has\w+Role|HasAnyRole|HasReadFullRole|HasModifyRole|HasCreateRole|HasDeleteRole|HasSelectRole'),
|
||||
'usertype': re.compile(r'UserTypeFromContext|UserType\.Customer|UserType\.HeadOffice|IsCustomerUser|CustomerUser'),
|
||||
'biz': re.compile(r'Biz\.GetBiz|new \w+Biz\(|\w+Biz\.'),
|
||||
'dltoken': re.compile(r'ValidateDownloadToken'),
|
||||
}
|
||||
|
||||
rows = []
|
||||
for fn in sorted(os.listdir(src)):
|
||||
if not fn.endswith('.cs'):
|
||||
continue
|
||||
text = open(os.path.join(src, fn), encoding='utf-8-sig').read()
|
||||
# drop // comments so commented-out actions are ignored
|
||||
text = '\n'.join(l for l in text.splitlines() if not l.lstrip().startswith('//'))
|
||||
m = re.search(r'\[Route\("([^"]*)"\)\]', text)
|
||||
base = m.group(1).replace('api/v{version:apiVersion}/', '') if m else ''
|
||||
cls_auth = bool(re.search(r'\[Authorize\][^{]*class\s+\w+Controller', text, re.S))
|
||||
for vm in verb_re.finditer(text):
|
||||
after = text[vm.end():]
|
||||
mm = method_re.search(after)
|
||||
if not mm:
|
||||
continue
|
||||
attrs = text[max(0, vm.start() - 300):vm.end()] + after[:mm.start()]
|
||||
anon = '[AllowAnonymous]' in attrs
|
||||
# body: from the method's opening brace to the matching closing brace
|
||||
start = vm.end() + mm.end()
|
||||
brace = text.find('{', start)
|
||||
depth, i = 0, brace
|
||||
while i < len(text):
|
||||
if text[i] == '{': depth += 1
|
||||
elif text[i] == '}':
|
||||
depth -= 1
|
||||
if depth == 0: break
|
||||
i += 1
|
||||
body = text[brace:i]
|
||||
found = [k for k, r in checks.items() if r.search(body)]
|
||||
rows.append((fn.replace('Controller.cs', ''), vm.group(1).upper(), (base + '/' + (vm.group(2) or '')).rstrip('/'),
|
||||
mm.group(1), 'ANON' if anon else ('auth' if cls_auth or '[Authorize]' in attrs else 'NO-ATTR'), ','.join(found) or '-'))
|
||||
|
||||
with open(out, 'w', encoding='utf-8') as f:
|
||||
f.write('controller\tverb\troute\taction\taccess\tchecks\n')
|
||||
for r in rows:
|
||||
f.write('\t'.join(r) + '\n')
|
||||
print(len(rows), 'actions')
|
||||
7
dist/assets/LICENSE
vendored
7
dist/assets/LICENSE
vendored
@@ -51,7 +51,7 @@ Ground Zero will comply with all applicable laws pertaining to personal data inc
|
||||
|
||||
A: Data obtained
|
||||
|
||||
Information about you (such as your email address, name, company information) is provided to us when you email us (such as when you email us directly, email us via our online form, email us via our online temporary Key request form) or when you register on our AyaNova Support Forum.
|
||||
Information about you (such as your email address, name, company information) is provided to us when you email us (such as when you email us directly, email us via our online form, email us via our online temporary Key request form).
|
||||
|
||||
Information you provided in your order (such as your company name, company contact information, contact name, along with the AyaNova products/services/subscriptions purchased) is sent to us from our payment processing partner with whom you placed your order.
|
||||
|
||||
@@ -61,7 +61,7 @@ Ground Zero will not share your information for marketing purposes with companie
|
||||
|
||||
C: Use and Sharing of Data
|
||||
|
||||
Personal data you provided when you contact us (such as when you email us directly, email us via our online form, email us via our online temporary Key request form or register on our AyaNova Support Forum) will be used to provide technical support, to respond to user inquiries, to provide temporary Key(s), to maintain case and bug issues reported by you, and to send you relevant AyaNova information.
|
||||
Personal data you provided when you contact us (such as when you email us directly, email us via our online form, or email us via our online temporary Key request form) will be used to provide technical support, to respond to user inquiries, to provide temporary Key(s), to maintain case and bug issues reported by you, and to send you relevant AyaNova information.
|
||||
|
||||
Ground Zero will also receive your company information you provided to our payment processing partner, so that we may process your product/service/subscription order.
|
||||
|
||||
@@ -83,8 +83,7 @@ E: Deleting of Data
|
||||
(i) Temporary key requests without subsequent purchase will be deleted within one year.
|
||||
(ii) Email correspondence without subsequent purchase will be deleted within two years except where required for maintaining case and support history.
|
||||
(iii) Email correspondence including temporary key requests with subsequent subscription purchase will be retained for the life of your subscription. Once a subscription has been allowed to lapse (not renewed), correspondence will be deleted within two years except where required for maintaining case and support history.
|
||||
(iv) Customer information obtained through AyaNova Support Forum registration will be deleted within three years of forum login inactivity.
|
||||
(v) Purchase information along with company information as provided by our payment processing partner will be retained for a minimum of six years after the end of the taxation year to which they relate as per the Canada Revenue Agency and no longer than 10 years after which will be deleted within one year.
|
||||
(iv) Purchase information along with company information as provided by our payment processing partner will be retained for a minimum of six years after the end of the taxation year to which they relate as per the Canada Revenue Agency and no longer than 10 years after which will be deleted within one year.
|
||||
|
||||
|
||||
I HAVE READ AND UNDERSTOOD ALL OF THE ABOVE TERMS AND CONDITIONS AND ACKNOWLEDGE THAT THESE TERMS AND CONDITIONS FORM PART OF THIS AGREEMENT. I AGREE THAT A PRINT-OUT OF THIS AGREEMENT WILL CONSTITUTE A "WRITING" UNDER ANY APPLICABLE LAWS.
|
||||
|
||||
3
dist/assets/lan-install-config.json
vendored
3
dist/assets/lan-install-config.json
vendored
@@ -4,8 +4,7 @@
|
||||
"AYANOVA_DEFAULT_TRANSLATION":"en",
|
||||
"AYANOVA_DATA_PATH":"%ProgramData%\\ayanova",
|
||||
"AYANOVA_BACKUP_PG_DUMP_PATH": "C:\\Program Files\\PostgreSQL\\18\\bin",
|
||||
"AYANOVA_LOG_LEVEL": "Info",
|
||||
"AYANOVA_JWT_SECRET": "11111YourRandom32CharacterSecret"
|
||||
"AYANOVA_LOG_LEVEL": "Info"
|
||||
}
|
||||
|
||||
|
||||
|
||||
7
dist/assets/license.rtf
vendored
7
dist/assets/license.rtf
vendored
@@ -54,7 +54,7 @@ Ground Zero will comply with all applicable laws pertaining to personal data inc
|
||||
\f0\fs20\par
|
||||
A: Data obtained\f1\fs24\par
|
||||
\f0\fs20\par
|
||||
Information about you (such as your email address, name, company information) is provided to us when you email us (such as when you email us directly, email us via our online form, email us via our online temporary Key request form) or when you register on our AyaNova Support Forum.\f1\fs24\par
|
||||
Information about you (such as your email address, name, company information) is provided to us when you email us (such as when you email us directly, email us via our online form, email us via our online temporary Key request form).\f1\fs24\par
|
||||
\f0\fs20\par
|
||||
Information you provided in your order (such as your company name, company contact information, contact name, along with the AyaNova products/services/subscriptions purchased) is sent to us from our payment processing partner with whom you placed your order.\f1\fs24\par
|
||||
\f0\fs20\par
|
||||
@@ -64,7 +64,7 @@ Ground Zero will not share your information for marketing purposes with companie
|
||||
\f0\fs20\par
|
||||
C: Use and Sharing of Data\f1\fs24\par
|
||||
\f0\fs20\par
|
||||
Personal data you provided when you contact us (such as when you email us directly, email us via our online form, email us via our online temporary Key request form or register on our AyaNova Support Forum) will be used to provide technical support, to respond to user inquiries, to provide temporary Key(s), to maintain case and bug issues reported by you, and to send you relevant AyaNova information.\f1\fs24\par
|
||||
Personal data you provided when you contact us (such as when you email us directly, email us via our online form, or email us via our online temporary Key request form) will be used to provide technical support, to respond to user inquiries, to provide temporary Key(s), to maintain case and bug issues reported by you, and to send you relevant AyaNova information.\f1\fs24\par
|
||||
\f0\fs20\par
|
||||
Ground Zero will also receive your company information you provided to our payment processing partner, so that we may process your product/service/subscription order.\f1\fs24\par
|
||||
\f0\fs20\par
|
||||
@@ -86,8 +86,7 @@ E: Deleting of Data \f1\fs24\par
|
||||
(i) Temporary key requests without subsequent purchase will be deleted within one year.\f1\fs24\par
|
||||
\f0\fs20 (ii) Email correspondence without subsequent purchase will be deleted within two years except where required for maintaining case and support history.\f1\fs24\par
|
||||
\f0\fs20 (iii) Email correspondence including temporary key requests with subsequent subscription purchase will be retained for the life of your subscription. Once a subscription has been allowed to lapse (not renewed), correspondence will be deleted within two years except where required for maintaining case and support history. \f1\fs24\par
|
||||
\f0\fs20 (iv) Customer information obtained through AyaNova Support Forum registration will be deleted within three years of forum login inactivity. \f1\fs24\par
|
||||
\f0\fs20 (v) Purchase information along with company information as provided by our payment processing partner will be retained for a minimum of six years after the end of the taxation year to which they relate as per the Canada Revenue Agency and no longer than 10 years after which will be deleted within one year. \f1\fs24\par
|
||||
\f0\fs20 (iv) Purchase information along with company information as provided by our payment processing partner will be retained for a minimum of six years after the end of the taxation year to which they relate as per the Canada Revenue Agency and no longer than 10 years after which will be deleted within one year. \f1\fs24\par
|
||||
\f0\fs20\par
|
||||
\par
|
||||
I HAVE READ AND UNDERSTOOD ALL OF THE ABOVE TERMS AND CONDITIONS AND ACKNOWLEDGE THAT THESE TERMS AND CONDITIONS FORM PART OF THIS AGREEMENT. I AGREE THAT A PRINT-OUT OF THIS AGREEMENT WILL CONSTITUTE A "WRITING" UNDER ANY APPLICABLE LAWS.\f1\fs24\par
|
||||
|
||||
3
dist/assets/linux-server/config.json
vendored
3
dist/assets/linux-server/config.json
vendored
@@ -3,6 +3,5 @@
|
||||
"AYANOVA_DB_CONNECTION":"Server=localhost;Username=postgres;Password=YOUR_PASSWORD_HERE;Database=AyaNova;CommandTimeout=300;",
|
||||
"AYANOVA_DEFAULT_TRANSLATION":"en",
|
||||
"AYANOVA_DATA_PATH":"/var/ayanova/data/",
|
||||
"AYANOVA_LOG_LEVEL": "Info",
|
||||
"AYANOVA_JWT_SECRET": "12345YourRandom32CharacterSecret"
|
||||
"AYANOVA_LOG_LEVEL": "Info"
|
||||
}
|
||||
15
dist/docker/linux-x64/ayanovadocker/dockerfile
vendored
15
dist/docker/linux-x64/ayanovadocker/dockerfile
vendored
@@ -1,15 +0,0 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine
|
||||
WORKDIR /app
|
||||
COPY ./files .
|
||||
RUN apk add libgdiplus --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/
|
||||
RUN apk add postgresql-client
|
||||
RUN apk add --no-cache \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
freetype-dev \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-freefont
|
||||
|
||||
ENTRYPOINT ["dotnet", "AyaNova.dll"]
|
||||
44
dist/docker/linux-x64/docker-compose.yml
vendored
44
dist/docker/linux-x64/docker-compose.yml
vendored
@@ -1,44 +0,0 @@
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
|
||||
|
||||
postgresserver:
|
||||
image: postgres:alpine
|
||||
restart: always
|
||||
ports:
|
||||
- 5432:5432
|
||||
environment:
|
||||
POSTGRES_PASSWORD: letmein
|
||||
volumes:
|
||||
- /var/lib/ayanova/db:/var/lib/postgresql/data
|
||||
|
||||
ayanova:
|
||||
image: gztw/ayanova:v8.0.0
|
||||
init: true
|
||||
restart: always
|
||||
ports:
|
||||
- 7575:7575
|
||||
volumes:
|
||||
- /var/lib:/var/lib
|
||||
environment:
|
||||
AYANOVA_USE_URLS: http://*:7575
|
||||
AYANOVA_DB_CONNECTION: User ID=postgres;Password=letmein;Host=postgresserver;Port=5432;Database=AyaNova;Pooling=true;CommandTimeout=90;
|
||||
AYANOVA_ATTACHMENT_FILES_PATH: /var/lib/ayanova/files/user
|
||||
AYANOVA_BACKUP_FILES_PATH: /var/lib/ayanova/files/backup
|
||||
AYANOVA_TEMP_FILES_PATH: /var/lib/ayanova/files/temp
|
||||
AYANOVA_LOG_PATH: /var/lib/ayanova
|
||||
AYANOVA_LOG_LEVEL: Info
|
||||
AYANOVA_REPORT_RENDER_BROWSER_PATH: /usr/bin/chromium-browser
|
||||
build:
|
||||
context: ./ayanovadocker
|
||||
dockerfile: Dockerfile
|
||||
links:
|
||||
- postgresserver
|
||||
depends_on:
|
||||
- "postgresserver"
|
||||
|
||||
networks:
|
||||
default:
|
||||
external:
|
||||
name: docker-network
|
||||
9
dist/install/windows/x64/lan.iss
vendored
9
dist/install/windows/x64/lan.iss
vendored
@@ -1,7 +1,7 @@
|
||||
; LAN install for internal network use only
|
||||
|
||||
#define MyAppName "AyaNova server"
|
||||
#define MyAppVersion "8.3.0"
|
||||
#define MyAppVersion "8.3.1"
|
||||
#define MyAppPublisher "Ground Zero Tech-Works, Inc."
|
||||
#define MyAppURL "https://ayanova.com/"
|
||||
#define MyAppLauncherExeName "ayanova-launcher.exe"
|
||||
@@ -43,7 +43,6 @@ Source: "C:\data\code\raven\dist\assets\open-ayanova-app.bat"; DestDir: "{app}";
|
||||
|
||||
|
||||
[Dirs]
|
||||
Name: "{app}";Permissions: users-modify;
|
||||
Name: "{commonappdata}\ayanova";Permissions: users-modify;
|
||||
Name: "{commonappdata}\ayanova\logs";Permissions: users-modify;
|
||||
|
||||
@@ -52,6 +51,12 @@ Name: "{commonappdata}\ayanova\logs";Permissions: users-modify;
|
||||
Type: filesandordirs; Name: "{app}\Chrome"
|
||||
Type: filesandordirs; Name: "{app}\ChromeHeadlessShell"
|
||||
|
||||
[Run]
|
||||
; case 4579: the program folder is no longer writable by all local users (nothing writes there, data is under
|
||||
; {commonappdata}\ayanova). Upgrades over 8.2.4 or older still carry the old grant, so remove the Users group's own
|
||||
; rights (by SID, the group name is translated) from the program folder and everything in it; inherited read rights stay
|
||||
Filename: "{sys}\icacls.exe"; Parameters: """{app}"" /remove:g *S-1-5-32-545 /T /C /Q"; Flags: runhidden waituntilterminated
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\AyaNova server\{#MyAppName}"; Filename: "{app}\AyaNova.exe";IconFilename: "{app}\AyaNova.exe";
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\AyaNova.exe";IconFilename: "{app}\AyaNova.exe";
|
||||
|
||||
29
dist/install/windows/x64/standalone.iss
vendored
29
dist/install/windows/x64/standalone.iss
vendored
@@ -3,7 +3,7 @@
|
||||
; external to lan requires different config
|
||||
|
||||
#define MyAppName "AyaNova"
|
||||
#define MyAppVersion "8.3.0"
|
||||
#define MyAppVersion "8.3.1"
|
||||
#define MyAppPublisher "Ground Zero Tech-Works, Inc."
|
||||
#define MyAppURL "https://ayanova.com/"
|
||||
#define MyAppLauncherExeName "ayanova-launcher.exe"
|
||||
@@ -45,12 +45,11 @@ Source: "C:\data\code\raven\dist\win-x64\ayanova\standalone\AyaNova.exe"; DestDi
|
||||
Source: "C:\data\code\raven\dist\win-x64\ayanova\standalone\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "C:\data\code\raven\dist\win-x64\launcher\config.json"; DestDir: "{app}"; Flags: ignoreversion confirmoverwrite
|
||||
Source: "C:\data\code\raven\dist\win-x64\launcher\*"; DestDir: "{app}\launcher"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "C:\data\code\raven\dist\win-x64\postgres-standalone\*"; DestDir: "{app}\local-postgres"; Permissions: users-modify;Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "C:\data\code\raven\dist\win-x64\postgres-standalone\*"; DestDir: "{app}\local-postgres"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "C:\data\code\raven\dist\win-x64\postgres-empty-database\*"; DestDir: "{commonappdata}\ayanova\database"; Permissions: users-modify; Flags: ignoreversion recursesubdirs createallsubdirs onlyifdoesntexist uninsneveruninstall
|
||||
|
||||
[Dirs]
|
||||
;Name: "{app}\.local-chromium";Permissions: users-modify;Flags: uninsalwaysuninstall
|
||||
Name: "{app}";Permissions: users-modify;
|
||||
Name: "{commonappdata}\ayanova";Permissions: users-modify;Flags: uninsneveruninstall
|
||||
Name: "{commonappdata}\ayanova\logs";Permissions: users-modify;Flags: uninsneveruninstall
|
||||
; Empty folders PostgreSQL 14 needs in a new database. Git does not keep empty folders, so dist\win-x64\postgres-empty-database
|
||||
@@ -81,11 +80,35 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\launcher\ayanova-launcher.e
|
||||
;removed this from above line to force desktop icon Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
; case 4579: the program folder is no longer writable by all local users (nothing writes there, data is under
|
||||
; {commonappdata}\ayanova). Upgrades over 8.2.4 or older still carry the old grant, so remove the Users group's own
|
||||
; rights (by SID, the group name is translated) from the program folder and everything in it; inherited read rights stay
|
||||
Filename: "{sys}\icacls.exe"; Parameters: """{app}"" /remove:g *S-1-5-32-545 /T /C /Q"; Flags: runhidden waituntilterminated
|
||||
Filename: "{app}\launcher\ayanova-launcher.exe"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[Code]
|
||||
// case 4694: the launcher of 8.2.4 and older left the local PostgreSQL server running when AyaNova was shut down, so
|
||||
// its files are in use; stop it (if running) before installing over it or uninstalling
|
||||
procedure StopLocalPostgres();
|
||||
var
|
||||
PgCtl: String;
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
PgCtl := ExpandConstant('{app}\local-postgres\bin\pg_ctl.exe');
|
||||
if FileExists(PgCtl) and DirExists(ExpandConstant('{commonappdata}\ayanova\database')) then
|
||||
Exec(PgCtl, 'stop -D "' + ExpandConstant('{commonappdata}\ayanova\database') + '" -m fast -w', ExtractFileDir(PgCtl), SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
|
||||
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||
begin
|
||||
StopLocalPostgres();
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
begin
|
||||
if CurUninstallStep = usUninstall then
|
||||
StopLocalPostgres();
|
||||
if CurUninstallStep = usPostUninstall then
|
||||
begin
|
||||
if MsgBox('Do you want to delete all AyaNova database, attachment and log files?', mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then
|
||||
|
||||
4
dist/latest-version.json
vendored
4
dist/latest-version.json
vendored
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"latestversion":"8.3.0",
|
||||
"changelogurl":"https://ayanova.com/docs/changelog/#ayanova-830"
|
||||
"latestversion":"8.3.1",
|
||||
"changelogurl":"https://ayanova.com/docs/changelog/#ayanova-831"
|
||||
}
|
||||
BIN
dist/win-x64/postgres-standalone/bin/msvcp140.dll
vendored
Normal file
BIN
dist/win-x64/postgres-standalone/bin/msvcp140.dll
vendored
Normal file
Binary file not shown.
BIN
dist/win-x64/postgres-standalone/bin/vcruntime140.dll
vendored
Normal file
BIN
dist/win-x64/postgres-standalone/bin/vcruntime140.dll
vendored
Normal file
Binary file not shown.
BIN
dist/win-x64/postgres-standalone/bin/vcruntime140_1.dll
vendored
Normal file
BIN
dist/win-x64/postgres-standalone/bin/vcruntime140_1.dll
vendored
Normal file
Binary file not shown.
@@ -17,13 +17,13 @@ Full access
|
||||
|
||||
- Accounting
|
||||
- Business administration
|
||||
- Travel manager
|
||||
- Service manager
|
||||
|
||||
Read only access
|
||||
|
||||
- Business administration - restricted
|
||||
- Travel manager - restricted
|
||||
- Travel technician
|
||||
- Service manager - restricted
|
||||
- Service technician
|
||||
- Sales
|
||||
|
||||
## Import / Export
|
||||
|
||||
@@ -29,6 +29,10 @@ Logos are stored in the AyaNova database and appear in reports and on the login
|
||||
If you do not want to customize the login form and only want a logo for reporting then you would only need to provide the Small sized logo.
|
||||
If you provide a Medium or Large sized logo they will automatically be used by the Login form in place of the standard AyaNova logo.
|
||||
|
||||
### Logo file types
|
||||
|
||||
Logos must be PNG, JPEG, GIF, WebP or SVG images. Other files are refused when uploaded.
|
||||
|
||||
### Maximum logo file size
|
||||
|
||||
Maximum file size allowed for any logo is 512KiB (512000 bytes) however you should ensure your logo is as small a file size as possible while still maintaining visual quality. Under 100kb is a good rule of thumb.
|
||||
|
||||
@@ -22,11 +22,9 @@ Full access
|
||||
|
||||
- Business administration
|
||||
|
||||
- Business administration - restricted
|
||||
|
||||
Read only access
|
||||
|
||||
- none
|
||||
- Business administration - restricted
|
||||
|
||||
## How to access report templates
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ One side of this feature is the [Integrated applications User interface](adm-int
|
||||
|
||||
Using standard AyaNova API routes, developers can leverage this feature to provide configuration and mapping data persistance, logging for troubleshooting and a master on/off "switch" to control the active status of the integrating application.
|
||||
|
||||
An integrating application can sign in as a User with any of these full (not restricted) [roles](ay-biz-admin-roles.md) to create its integration record, read and update its data and write to its log: Business administration, Service manager, Inventory, Accounting, Service technician, Sales or System operations. Changing the Active status of an integration and deleting an integration are only available to the Business administration role, as in the [Integrated applications User interface](adm-integrations.md); the application should read the Active status and not change it.
|
||||
|
||||
### API Routes
|
||||
|
||||
As with all API routes in AyaNova, these API routes can be viewed in the [API explorer console](api-console.md).
|
||||
|
||||
@@ -20,7 +20,7 @@ This section provides information regarding the AyaNova web app client running i
|
||||
|
||||
#### Version
|
||||
|
||||
This is the version number of the AyaNova web application. When a new version of AyaNova is released this number will go up. AyaNova uses the industry standard [semantic versioning system](https://semver.org/) which is 3 numbers separated by periods for example `8.3.0`.
|
||||
This is the version number of the AyaNova web application. When a new version of AyaNova is released this number will go up. AyaNova uses the industry standard [semantic versioning system](https://semver.org/) which is 3 numbers separated by periods for example `8.3.1`.
|
||||
|
||||
The leftmost number, for example **8**.1.2 is the major release version indicating major changes to the program that are not backwards compatible to the prior major release version.
|
||||
|
||||
|
||||
@@ -86,6 +86,12 @@ We have a [Guided tour for custom fields](tour-bizadmin-form-custom-fields.md) a
|
||||
|
||||
If you change the data type of a custom field after data has been entered AyaNova will do it's best to work with that change but it's not recommended. It's better to simply hide the former custom control and show a new one with the new data type.
|
||||
|
||||
#### Checkbox custom fields on existing records
|
||||
|
||||
When a `True / False` checkbox custom field is enabled, records that already existed have no value for it at all, neither true nor false. In a [data table](ay-start-form-data-tables.md) column for that field those records show nothing (an "indeterminate" value).
|
||||
|
||||
Once the checkbox is set or cleared on a record and the record is saved, the data table shows true or false for it like any other record.
|
||||
|
||||
### Changing the title of fields
|
||||
|
||||
To change the label text displayed to the User above any field (not just custom fields), edit any [translations](adm-translations.md) in use for that field's translation key. The internal Translation Key for each field is displayed in the Customize form beneath the current translation of that field's title to simplify finding which translation item needs to be changed.
|
||||
|
||||
@@ -60,7 +60,7 @@ The default login for the AyaNova SuperUser account is user name: `superuser` pa
|
||||
|
||||
Before AyaNova is licensed and used in production or before you import / migrate or enter any real world data into an evaluation AyaNova you **MUST** change the sample User passwords or anyone could potentially login and view your data.
|
||||
|
||||
AyaNova will prompt you to change it if it discovers a login using the default superuser password with a non evaluation license.
|
||||
With a non evaluation license, AyaNova requires it to be changed: a login using the default superuser password can only be used to change the password, until it is changed.
|
||||
|
||||
## Generating sample data
|
||||
|
||||
|
||||
@@ -176,6 +176,20 @@ Note that the SAMPLE DATA section of the user interface in the report designer s
|
||||
|
||||
A common use for the ayPrepareData function would be to insert or re-shape data based on calculations or data fetched from external API's or the AyaNova API itself (see the section regarding API usage below for details). It is defined as an asynchronous function so that the User can all AyaNova or other external API's if necessary to fetch data to insert into the report. For example getting part images from a manufacturers API site to insert into a report document.
|
||||
|
||||
#### Script errors
|
||||
|
||||
When a report fails to render, the error shown includes everything the report's scripts wrote with `console.error` (and `console.log` / `console.warn`). For `console.error`, objects passed to it are also shown as their contents and errors with their stack, for example:
|
||||
|
||||
```javascript
|
||||
try {
|
||||
// ...
|
||||
} catch (e) {
|
||||
console.error("Getting part images failed", e, { partId: item.PartId });
|
||||
}
|
||||
```
|
||||
|
||||
In a stack line such as `at ayPrepareData (<anonymous>:12:30)` the numbers are the line and column within your Prepare or Helpers script.
|
||||
|
||||
### Helpers
|
||||
|
||||

|
||||
@@ -304,8 +318,8 @@ ayClientMetaData:
|
||||
{
|
||||
"UserName": "AyaNova username",
|
||||
"UserId": "23",
|
||||
"Authorization": "Bearer eJhbGciO...token...data...hb9JyjcWl3Tib",
|
||||
"DownloadToken": "IslXKU....token....data...here....Bg2Vx0yT2rQ",
|
||||
"Authorization": "Bearer ay-report-render-token",
|
||||
"DownloadToken": "ay-report-render-download-key",
|
||||
"TimeZoneName": "America/Los_Angeles",
|
||||
"LanguageName": "en-US",
|
||||
"Hour12": true,
|
||||
@@ -351,7 +365,7 @@ ayServerMetaData:
|
||||
}
|
||||
```
|
||||
|
||||
- ayApiUrl - this is the URL of the server relative to the report generator and is useful for calling AyaNova API methods in your prepare data function to bring in sources of information from other areas of AyaNova that might not be directly available to your report
|
||||
- ayApiUrl - this is the URL of the server relative to the report generator and is useful for calling AyaNova API methods in your prepare data function to bring in sources of information from other areas of AyaNova that might not be directly available to your report. Always call the AyaNova API through this URL (or the API convenience functions below, which use it): the User's access is only added to requests made to this URL (see [API Usage](#api-usage))
|
||||
- HasXXLogo - these keys are set to true if a Logo has been uploaded for each size to AyaNova and are useful for controlling whether or not to show a logo on a report template as can be seen in many of our sample reports
|
||||
- Company XXX fields - these fields represent your company information as entered in Global settings to provide a centralized source of information for all reports requiring company contact or address data
|
||||
|
||||
@@ -559,9 +573,16 @@ The `ayGroupByKey` function does no sorting so the data will be returned in the
|
||||
|
||||
The full power of the [AyaNova developer's API](api-intro.md) is available to reports to incorporate data from anywhere in AyaNova as required.
|
||||
|
||||
The currently logged in User's API Bearer access token is provided for accessing API routes via the `ayClientMetaData.Authorization` value (see above).
|
||||
API calls from a report run with the access of the User running the report, so a report can only do what that User could also do in the AyaNova application. Reports attached to [customer notifications](customer-notify-subscriptions.md) run with full administrative access.
|
||||
|
||||
You should not store a static copy of an API Bearer access token or hard code it into your templates because it changes regularly and will expire the moment the User logs in to AyaNova.
|
||||
The report itself never holds the User's actual credentials. The `ayClientMetaData.Authorization` and `ayClientMetaData.DownloadToken` values are stand-ins, and the AyaNova server puts the User's actual access in their place only on requests made to the `ayApiUrl` address. So API calls and attachment images work as usual through `ayApiUrl`, but:
|
||||
|
||||
- an API call to any other address, including your AyaNova server's own public address typed into a template, is made without the User's access and will be refused; use `ayApiUrl` instead
|
||||
- a link to an attachment printed in the finished report (for example from a wiki) does not include a download key and will not open the attachment
|
||||
|
||||
Because a report template's script runs with the access of whoever runs the report, only Users with the full "Business administration" role can create or edit report templates.
|
||||
|
||||
You should not store a static copy of an API Bearer access token or hard code it into your templates.
|
||||
|
||||
While it is possible to login via a script using alternate credentials and access the API with alternative access to the current logged in user, this opens a security hole as you would need to hard code credentials into the report script and for this reason is **absolutely NOT recommended**.
|
||||
|
||||
|
||||
@@ -22,11 +22,7 @@ The password field has an option to show your password to make it easier to ensu
|
||||
|
||||
Click on the eye symbol to the right of the password field to show or hide the password entry.
|
||||
|
||||
AyaNova passwords and user names are case sensitive and there are no particular rules regarding passwords but longer passwords that are easier to remember without writing down are far more secure than shorter complicated ones with a mix of symbols that are hard to remember.
|
||||
|
||||
Using a mix of characters, numbers and symbols is unnecessary and outdated advice; modern security best practices emphasize simple text passwords that are easy to remember without having to write them down and as long as can be comfortably entered on your device.
|
||||
|
||||
For example this password `to see a world in a grain of sand` is easier to remember and many orders of magnitude more secure than this password `xJ#9a$a` due to it's length.
|
||||
AyaNova passwords and user names are case sensitive. AyaNova has no rules about password length or which characters a password must contain; see [What is a secure password?](home-password.md#what-is-a-secure-password) for how to choose a good one.
|
||||
|
||||
At the bottom of the login form is a link to the AyaNova website displayed as the version and copyright information for the AyaNova app.
|
||||
|
||||
@@ -36,7 +32,7 @@ The default login for the AyaNova SuperUser account is user name: `superuser` pa
|
||||
|
||||
Before AyaNova is licensed and used in production you **MUST** change this or anyone could login with this default.
|
||||
|
||||
AyaNova will prompt you to change it if it discovers a login using the default superuser password with a non evaluation license.
|
||||
With a non evaluation license, AyaNova requires it to be changed: a login using the default superuser password can only be used to change the password, until it is changed.
|
||||
|
||||
The built-in SuperUser User account in AyaNova is not a normal type of User and does not consume a license; it is used for specific special operations in AyaNova that could lead to loss of data or affect the operations of the server over all.
|
||||
|
||||
@@ -66,9 +62,7 @@ A User's authentication token can be [revoked at any time](adm-users.md#revoking
|
||||
|
||||
## Loss of authentication on server restart
|
||||
|
||||
If the Users are forced to re-login any time the AyaNova server is restarted the [JWT Secret setting](ops-config-jwt-secret.md) was probably not set as recommended during installation and should be verified.
|
||||
|
||||
If this setting is configured properly Users will be able to continue working after the server is restarted without the need to log back in again.
|
||||
Users stay logged in when the AyaNova server restarts, because the server keeps its [sign-in key](ops-config-jwt-secret.md) in its data folder. If Users are forced to log in again after every restart, check the server log at startup: AyaNova notes there if it could not read or create the sign-in key file, which usually means the AyaNova server cannot write to its data folder.
|
||||
|
||||
## Logout
|
||||
|
||||
|
||||
@@ -8,28 +8,74 @@ See the [upgrade instructions](ops-upgrade.md) section of this manual for detail
|
||||
|
||||
## 2026
|
||||
|
||||
### AyaNova 8.3.0
|
||||
### AyaNova 8.3.1
|
||||
|
||||
IN PROGRESS
|
||||
Released 2026-11-05
|
||||
|
||||
**WARNING:** this release contains changes that will require a **pre upgrade step** on all Windows and Linux network platforms from the normal upgrade process you may be accustomed to. Be sure to follow our [upgrade instructions](ops-upgrade.md) and the **pre upgrade step** for your platform for this particular upgrade from version 8.2.4 (or older) to version 8.3.0 (or newer).
|
||||
**Upgrading from 8.2.4 or older?** AyaNova 8.3.0 and newer need a one-time [pre-upgrade step](ops-upgrade.md#upgrading-from-824-or-older) on Windows network and Linux installations.
|
||||
|
||||
**Changed**
|
||||
|
||||
- Linux: the [upgrade](ops-upgrade.md) scripts now first check that the .NET 10 runtime is installed, and stop without changing anything if it is not.
|
||||
|
||||
- Server: in some cases the automatic backup could be skipped for one day after the AyaNova server was restarted or a manual backup was made; it now always runs at the next chosen [backup time](ops-form-backup.md#backup-time).
|
||||
- Server: in some cases opening a record that has since been deleted (for example from an old link) gave a server error, and a report printed for records of which some had since been deleted failed; it now shows a "not found" message, and the report prints the remaining records.
|
||||
- Server: in some cases deleting a record that is still in use elsewhere (for example a customer, unit, part or rate used on a work order, quote or PM) gave a server error; it now always says which kind of record is using it.
|
||||
- Server: the server log now records the next automatic backup time (or that automatic backup is off) each time the server starts.
|
||||
- Backup: the [number of backups to keep](ops-form-backup.md#number-of-backups-to-keep) can no longer be set below 1; a setting of 0 already kept the most recent backup, the same as 1.
|
||||
- User interface: in some cases, when the web browser could not identify its time zone, dates and times were shown and entered in New York time; the browser's own offset from UTC is now used instead. A [time zone override](ay-start-localization.md#time-zone-override) in user options always takes precedence.
|
||||
- Email: report PDF files attached to customer notification emails now have the standard PDF content type; mail programs already opened them by their .pdf name.
|
||||
- Email: sending an email directly to a user who has no email address, or who is inactive, now names them as a user instead of a vendor in the message.
|
||||
|
||||
### AyaNova 8.3.0
|
||||
|
||||
Released 2026-10-29
|
||||
|
||||
**WARNING:** this release contains changes that will require a **pre upgrade step** on all Windows and Linux network platforms from the normal upgrade process you may be accustomed to. Be sure to follow our [upgrade instructions](ops-upgrade.md) and the **pre upgrade step** for your platform for this particular upgrade from version 8.2.4 (or older) to version 8.3.0 (or newer).
|
||||
|
||||
**IMPORTANT:** this release includes important security improvements along with the move to .NET 10. We recommend upgrading to 8.3.0 promptly.
|
||||
|
||||
**Changed**
|
||||
|
||||
- Server: significant performance improvements.
|
||||
- Server: after several incorrect two-factor authentication codes, the password must be entered again before another code can be tried.
|
||||
- Server: the `AYANOVA_JWT_SECRET` setting is no longer used. AyaNova now generates its own [sign-in key](ops-config-jwt-secret.md) once and keeps it in its data folder, so users stay logged in when the server restarts. The setting can be removed from `config.json` if desired.
|
||||
- Server: updated to use .NET 10.x LTS from previously used .NET 8.x LTS. .NET 8.x reaches end of support in November 2026. With this update to .NET 10.x come performance and security gains.
|
||||
- Server: all 3rd party components used by the AyaNova server have been upgraded to their most recent release versions to use with .NET 10.x. This improves performance, stability and security for each component used.
|
||||
- Server: the report rendering system now uses the smaller and faster "Chrome headless shell" browser instead of the full Chrome browser. It is downloaded into a `report-browser` folder under the AyaNova data folder instead of the program folder, so it is no longer removed by an upgrade. There will be a longer than normal delay the _first time_ a report is rendered after upgrading while AyaNova automatically downloads the new report rendering browser. Any old `Chrome` and `ChromeHeadlessShell` folders in the AyaNova program folder are no longer used and can be removed.
|
||||
- Server: the automatic internal backup now runs the database backup utility directly instead of through a command shell, so the database password can contain any characters, and a failed backup is now reported as an error instead of appearing to succeed. See [backup](ops-form-backup.md).
|
||||
- Server: the automatic internal backup now runs the database backup utility directly instead of through a command shell, so the database password can contain any characters and the [backup utility path](ops-config-backup-pgdump-path.md) can contain spaces, and a failed backup is now reported as an error instead of appearing to succeed in some cases. See [backup](ops-form-backup.md).
|
||||
- Server: log files are now named with an increasing number when they are archived; the [server log](ops-log.md) form lists them newest first.
|
||||
- Server: removed an unused internal performance profiling component.
|
||||
- Server: AyaNova now checks the email (SMTP) server's security certificate when sending email. If your mail server uses a self-signed or internal certificate and email notifications stop after upgrading, see [SMTP certificate check](ops-config-smtp-accept-any-certificate.md).
|
||||
- Server: after repeated failed login attempts, further attempts for that login, or from that address, are refused for a few minutes.
|
||||
- Server: new [trusted reverse proxies](ops-config-trusted-proxies.md) setting for a reverse proxy that runs on a different computer from the AyaNova server, so AyaNova sees each user's own address.
|
||||
- Server: while a report renders, its template no longer holds the User's actual access token or download key; the AyaNova server adds the User's access only to the report's requests to the `ayApiUrl` address. Templates that call the AyaNova API through any other address need to be changed. See [API usage in reports](ay-report-edit.md#api-usage).
|
||||
- Server: users with the "Business administration - restricted" role can view [report templates](adm-report-templates.md) but no longer create, edit or import them, as described for that [role](ay-biz-admin-roles.md). The full "Business administration" role is required for users who need to edit report templates.
|
||||
- Server: relative date filters such as "today" or "last month" (in lists and dashboard widgets) now start and end the period at midnight in the user's own [time zone](ay-start-form-data-tables.md#filtering-dates-and-time-zones). Previously, for a user in a different time zone from the server, records close to the start or end of the period could be included or left out in some cases.
|
||||
- Server: when a report fails to render, objects and errors a report script passes to `console.error` are now shown with their contents and stack (line numbers within the script) in the error message, to help with [writing report scripts](ay-report-edit.md#script-errors).
|
||||
- Server: on servers in a time zone with daylight saving time, the automatic backup could run an hour earlier or later than the chosen [backup time](ops-form-backup.md#backup-time) for part of the year; it now always runs at the chosen time on the server's clock.
|
||||
- Server: a work order created from a [customer service request](svc-csr.md#accept-to-new-work-order) can now be deleted; the request goes back to Open. Previously the delete failed with a server error.
|
||||
- Server: the SuperUser account can no longer be deleted. Previously it could be deleted on a new installation before it had any activity.
|
||||
- Server: role rights corrected to match the documentation: "Service technician - restricted" users can view tax codes, projects, unit models and meter readings; "Business administration" users can view server metrics; Accounting users can no longer enter unit meter readings; only "Business administration" users can switch an [integrated application](api-integrated-applications.md) on or off or delete it.
|
||||
- Server: fixed direct notification messages to a user: in some cases only the first one within 12 hours was delivered, now every message is.
|
||||
- Server: the server information page at `/api/v8/` no longer shows license details.
|
||||
- Server: business logo uploads must be PNG, JPEG, GIF, WebP or SVG images, and require full rights to global settings. See [business logos](adm-global-logo.md).
|
||||
- Server: when a user's password is reset or set by an administrator, or their login is disabled, they are signed out and must sign in again.
|
||||
- Server: on a server with a purchased license, the SuperUser account can only be used to change its password if it still has the default password, and the login form no longer offers the default SuperUser login. See [SuperUser login](ay-start-login.md).
|
||||
- Server: the SMTP server password is no longer shown in the [notification settings](ops-notification-system.md) form; it shows `********` when a password is set. Type a new password to change it.
|
||||
- Linux: Ubuntu 24.04 and 26.04 are now tested and supported. On Ubuntu 24.04 and newer the .NET runtime is installed from Ubuntu's own package archive.
|
||||
- Docs: updated the [Linux server](ops-install-linux-server.md), [Linux desktop](ops-install-linux-desktop.md), [Windows local network](ops-install-windows-lan.md) and [Windows IIS](ops-install-windows-iis.md) installation and upgrade instructions for .NET 10 and Ubuntu 24.04 / 26.04.
|
||||
- Docs: updated the database password guidance in the installation and [backup](ops-form-backup.md) documentation.
|
||||
- Windows IIS: reports, including reports attached to customer notifications, now render under IIS with any site bindings, HTTPS included, without the [report rendering API URL override](ops-config-report-rendering-api-url-override.md) setting. See [report rendering under IIS](ops-install-windows-iis.md#report-rendering-under-iis).
|
||||
- Windows single user: the database server now shuts down when AyaNova is shut down, so installing an upgrade no longer stops at a "files in use" page. The installer also shuts down a database server left running by an earlier version.
|
||||
- Windows single user: the Microsoft Visual C++ runtime files the built-in database server needs are now included, so it no longer fails to start on computers without them.
|
||||
- Windows: the installers no longer give all local users write access to the AyaNova program folder; only administrators can change files there. Upgrading removes the earlier write access.
|
||||
- Windows: the local network installer and the [local network](ops-install-windows-lan.md) and [IIS](ops-install-windows-iis.md) installation instructions now link to the official PostgreSQL download page (the third-party guide linked before is no longer available), and the configuration examples assume PostgreSQL 18.
|
||||
- Docs: the Windows installation instructions now state that only one type of AyaNova 8 installation (single user, local network or IIS) can be on a computer at a time.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- Server: fixed occasional errors on the first requests after the server starts.
|
||||
- Server: searching selection lists for text that contains an apostrophe (for example a name like O'Brien) now works.
|
||||
- Server: hardened data table filter handling of number and role filter values.
|
||||
- Server: data table date filters for "greater than or equal", "less than" and "not equal" could include or exclude records on the boundary of the selected date.
|
||||
- Server: the database connection used for administrative operations is now built correctly when the database name also appears elsewhere in the connection setting.
|
||||
@@ -37,7 +83,6 @@ IN PROGRESS
|
||||
- Server: if the report rendering browser cannot be downloaded or started, a specific error is now shown and logged instead of a generic job failure.
|
||||
- Docs: fixed several links in the manual that opened the wrong section or page.
|
||||
|
||||
|
||||
## 2025
|
||||
|
||||
### AyaNova 8.2.4
|
||||
|
||||
@@ -18,21 +18,14 @@ You can click on the eye icon to conceal your entry from onlookers.
|
||||
|
||||
### What is a secure password?
|
||||
|
||||
#### Long, not complicated
|
||||
#### Randomly chosen
|
||||
|
||||
Modern security industry best practices around passwords focus entirely on length and not 'complexity' and AyaNova fully supports this by allowing very lengthy passwords and not requiring any special characters.
|
||||
What makes a password hard to guess is how randomly it was chosen, not only its length. Password guessing tools try well known phrases first, such as song lyrics, movie quotes and lines from books and poems, along with common substitutions such as `0` for `o` or `1` for `i`. A long password made from a phrase you picked because it is memorable can be guessed far faster than its length suggests.
|
||||
|
||||
For an adequate level of security ensure your password is **at minimum** 15 characters long or as long as you can comfortably remember without writing it down.
|
||||
AyaNova accepts passwords of any length and has no rules about which characters they must contain, so you can use either of these:
|
||||
|
||||
#### Memorable
|
||||
|
||||
We recommend using a lengthy but easily remembered phrase as a password, for example a song lyric, movie quote or a line from a poem that is **at least** 15 characters or more of words is currently _extremely_ difficult to hack by brute force methods.
|
||||
|
||||
There is no security advantage in modern practice to using mixed case, unusual symbols or numbers in your password as brute force hacking techniques try all enterable characters anyway and it just makes the password harder to remember.
|
||||
|
||||
It is more secure to have a lengthy password of plain alphabet text than it is to have a shorter one with a mix of numbers and characters.
|
||||
|
||||
For example: this password `somewhereovertherainbowskiesareblue` is far more secure than this password `Ab3#@%5` and easier to remember without writing it down.
|
||||
- **A password manager** (recommended): let it generate a long random password and remember it for you.
|
||||
- **A passphrase of at least six random words**: for example roll dice to pick words from the [EFF long word list](https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt). Six words chosen this way are easy to type and remember and very hard to guess, as long as the dice choose the words, not you.
|
||||
|
||||
#### Impersonal
|
||||
|
||||
@@ -46,7 +39,7 @@ In the current climate of hacking and malware we recommend you do _not_ enter yo
|
||||
|
||||
#### Backed up by Two factor authentication
|
||||
|
||||
AyaNova has [built in support](home-tfa.md) for the current most secure form of Two factor authentication and we encourage you to use it for all users.
|
||||
AyaNova has [built in support](home-tfa.md) for Two factor authentication and we encourage you to use it for all users.
|
||||
|
||||
In particular any User account that has the Business administration role (such as the built in SuperUser account) should be backed up with with two factor authentication codes as these roles are extremely powerful and can be used to easily circumvent security by assigning roles to other Users or creating entirely new Users.
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@ You may be viewing an older local version of this manual that was installed with
|
||||
|
||||
The most recent manual can always be viewed on our website at [https://www.ayanova.com/docs/](https://www.ayanova.com/docs/)
|
||||
|
||||
You can tell which version of the manual you are viewing by look at the revision stamp at the bottom of the page which shows the year month and day that copy of the manual was last updated:
|
||||
|
||||
`REV-YYYY-MM-DD `
|
||||
You can tell which version of the manual you are viewing by looking at the revision stamp at the bottom of the page: `REV-` followed by the year, month and day this copy of the manual was last changed.
|
||||
|
||||
### Beyond this manual
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Ground Zero will comply with all applicable laws pertaining to personal data inc
|
||||
|
||||
A: Data obtained
|
||||
|
||||
Information about you (such as your email address, name, company information) is provided to us when you email us (such as when you email us directly, email us via our online form, email us via our online temporary Key request form) or when you register on our AyaNova Support Forum.
|
||||
Information about you (such as your email address, name, company information) is provided to us when you email us (such as when you email us directly, email us via our online form, email us via our online temporary Key request form).
|
||||
|
||||
Information you provided in your order (such as your company name, company contact information, contact name, along with the AyaNova products/services/subscriptions purchased) is sent to us from our payment processing partner with whom you placed your order.
|
||||
|
||||
@@ -61,7 +61,7 @@ Ground Zero will not share your information for marketing purposes with companie
|
||||
|
||||
C: Use and Sharing of Data
|
||||
|
||||
Personal data you provided when you contact us (such as when you email us directly, email us via our online form, email us via our online temporary Key request form or register on our AyaNova Support Forum) will be used to provide technical support, to respond to user inquiries, to provide temporary Key(s), to maintain case and bug issues reported by you, and to send you relevant AyaNova information.
|
||||
Personal data you provided when you contact us (such as when you email us directly, email us via our online form, or email us via our online temporary Key request form) will be used to provide technical support, to respond to user inquiries, to provide temporary Key(s), to maintain case and bug issues reported by you, and to send you relevant AyaNova information.
|
||||
|
||||
Ground Zero will also receive your company information you provided to our payment processing partner, so that we may process your product/service/subscription order.
|
||||
|
||||
@@ -83,8 +83,7 @@ E: Deleting of Data
|
||||
(i) Temporary key requests without subsequent purchase will be deleted within one year.
|
||||
(ii) Email correspondence without subsequent purchase will be deleted within two years except where required for maintaining case and support history.
|
||||
(iii) Email correspondence including temporary key requests with subsequent subscription purchase will be retained for the life of your subscription. Once a subscription has been allowed to lapse (not renewed), correspondence will be deleted within two years except where required for maintaining case and support history.
|
||||
(iv) Customer information obtained through AyaNova Support Forum registration will be deleted within three years of forum login inactivity.
|
||||
(v) Purchase information along with company information as provided by our payment processing partner will be retained for a minimum of six years after the end of the taxation year to which they relate as per the Canada Revenue Agency and no longer than 10 years after which will be deleted within one year.
|
||||
(iv) Purchase information along with company information as provided by our payment processing partner will be retained for a minimum of six years after the end of the taxation year to which they relate as per the Canada Revenue Agency and no longer than 10 years after which will be deleted within one year.
|
||||
|
||||
|
||||
I HAVE READ AND UNDERSTOOD ALL OF THE ABOVE TERMS AND CONDITIONS AND ACKNOWLEDGE THAT THESE TERMS AND CONDITIONS FORM PART OF THIS AGREEMENT. I AGREE THAT A PRINT-OUT OF THIS AGREEMENT WILL CONSTITUTE A "WRITING" UNDER ANY APPLICABLE LAWS.
|
||||
|
||||
@@ -86,7 +86,7 @@ Command line parameters are appended to the command to start the ayanova server,
|
||||
|
||||
### API
|
||||
|
||||
- [AYANOVA_JWT_SECRET](ops-config-jwt-secret.md)
|
||||
- [AYANOVA_JWT_SECRET](ops-config-jwt-secret.md) (no longer used as of version 8.3.0)
|
||||
- [AYANOVA_USE_URLS](ops-config-use-urls.md)
|
||||
|
||||
### REPORTING
|
||||
@@ -95,6 +95,11 @@ Command line parameters are appended to the command to start the ayanova server,
|
||||
- [AYANOVA_REPORT_RENDERING_TIMEOUT](ops-config-report-rendering-timeout.md)
|
||||
- [AYANOVA_REPORT_RENDER_API_URL_OVERRIDE](ops-config-report-rendering-api-url-override.md)
|
||||
|
||||
### EMAIL
|
||||
|
||||
- [AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE](ops-config-smtp-accept-any-certificate.md)
|
||||
|
||||
### SECURITY
|
||||
|
||||
- [AYANOVA_SET_SUPERUSER_PW](ops-config-set-superuser-pw.md)
|
||||
- [AYANOVA_TRUSTED_PROXIES](ops-config-trusted-proxies.md)
|
||||
|
||||
@@ -1,56 +1,23 @@
|
||||
# JWT secret setting
|
||||
# Security and JWT web tokens
|
||||
|
||||
AyaNova uses JSON Web Tokens (JWT) for authentication.
|
||||
|
||||
These time limited tokens are signed by the server using a JWT secret key and issued to users when they log in to the AyaNova server.
|
||||
When a User logs in, the AyaNova server issues them a time limited token signed with the server's own sign-in key. The token is sent with every request the User makes and is verified by the server each time.
|
||||
|
||||
Every time the user makes a request to the server the JWT is sent along as well and verified to be valid.
|
||||
Tokens expire 5 days after they are issued, and each User can only have one current token: logging in again replaces the previous one.
|
||||
|
||||
Tokens have a built in expiry mechanism of 5 days from issue.
|
||||
(Individual Users can be prevented from logging in or accessing the server by [setting them inactive](adm-users.md#revoking-access-and-locking-out-users), which takes effect immediately regardless of how many days are left on their current token.)
|
||||
|
||||
All active tokens previously issued can be invalidated by changing this JWT Secret setting and restarting the server (or restarting the server and allowing it to choose a new secret value randomly if none is specified).
|
||||
## The sign-in key
|
||||
|
||||
(Individual Users can be prevented from logging in or accessing the server by setting them inactive which takes effect immediately regardless of how many days are left on their current access token.)
|
||||
The AyaNova server generates its sign-in key automatically the first time it starts and keeps it in a file named `jwt-signing.key` in the AyaNova [data folder](ops-config-data-path.md). It is used from then on, so Users stay logged in when the server restarts. There is nothing to configure.
|
||||
|
||||
## Default
|
||||
Keep the data folder protected like the rest of AyaNova's data: anyone who can read the sign-in key file could create valid tokens.
|
||||
|
||||
If no secret key is specified the server will generate a new, random one each time it starts and this means that remote users who previously authenticated will need to login freshly if the server is restarted.
|
||||
## Signing everyone out
|
||||
|
||||
If you would like to ensure that a server reboot does not affect remote users credentials then you can specify a value for the secret key so that the same key will always be used by the server even if it reboots.
|
||||
|
||||
## Overriding
|
||||
|
||||
AyaNova expects the JWT secret to be provided by a config.json property, environment variable or command line parameter named
|
||||
|
||||
`AYANOVA_JWT_SECRET`
|
||||
|
||||
The value specified should be a string of up to 32 characters, for example:
|
||||
`02847This_is_my_secret_key456576`
|
||||
If fewer than 32 characters are provided they secret will be padded out to 32 characters. If more than 32 characters are specified it will only use the first 32.
|
||||
|
||||
You should use the same precautions as for choosing any other password and ensure the secret is not well known or easily looked up in a dictionary. 32 random characters would be sufficient.
|
||||
|
||||
Example config.json entry
|
||||
|
||||
```json
|
||||
{
|
||||
...other properties...
|
||||
"AYANOVA_JWT_SECRET": "My32CharacterSecretKey0123456789"
|
||||
}
|
||||
```
|
||||
|
||||
Example command line parameter
|
||||
|
||||
`ayanova.exe --AYANOVA_JWT_SECRET="02847This_is_my_secret_key456576"`
|
||||
|
||||
Example environment variable
|
||||
|
||||
Windows
|
||||
|
||||
`set "AYANOVA_JWT_SECRET=02847This_is_my_secret_key456576"`
|
||||
|
||||
Linux
|
||||
|
||||
`export AYANOVA_JWT_SECRET="02847This_is_my_secret_key456576"`
|
||||
To invalidate every token that has been issued (for example if you suspect a token or the key file was exposed): stop the AyaNova server, delete the `jwt-signing.key` file from the data folder, and start the server again. A new key is generated and every User will need to log in again.
|
||||
|
||||
## AYANOVA_JWT_SECRET (no longer used)
|
||||
|
||||
Before version 8.3.0 the sign-in key could be set with an `AYANOVA_JWT_SECRET` configuration setting. That setting is no longer used: if it is still present in config.json, an environment variable or the command line it is ignored, a note is written to the server log, and it can safely be removed.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
The report rendering api override setting is used in situations where report rendering code is unable to contact the local AyaNova api server via it's localhost address.
|
||||
|
||||
**IIS:** as of AyaNova 8.3.0 this setting is no longer needed when AyaNova runs inside IIS. AyaNova sends the report's requests to the AyaNova server itself, to the same address and site name users connect to (see [report rendering under IIS](ops-install-windows-iis.md#report-rendering-under-iis)). A value set for an earlier version does no harm and can be removed.
|
||||
|
||||
The symptom of this issue is reports failing to render with an error displayed containing text similar to "JobFailed: PuppeteerSharp -> Evaluation failed: TypeError: Failed to fetch at ayPostToAPI"
|
||||
|
||||
Reports are rendered by a "headless" web browser running at the AyaNova server which executes the javascript required to render the report. Most report scripts will need to contact the AyaNova API to fetch data like translations or logos. In some scenarios such as some specific configurations when using IIS as the front end for AyaNova the local headless browser is prevented from contacting the AyaNova server due to accessing it via the `localhost` address. In these situations it's necessary to tell the script to use an alternative address.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# SMTP certificate check setting
|
||||
|
||||
When AyaNova connects to the SMTP server set in the [notification settings](ops-notification-system.md) to send email, it checks the mail server's security certificate. The certificate must be issued by a trusted certificate authority and must match the SMTP server address entered in the notification settings. This prevents someone on the network from posing as your mail server to collect the SMTP account password and the messages being sent.
|
||||
|
||||
Most mail servers and email providers have a certificate that passes this check and nothing needs to be done.
|
||||
|
||||
## When the check fails
|
||||
|
||||
If the mail server's certificate can not be validated, email notifications are not sent. The Send test message option in the notification settings shows an error such as "The server's SSL certificate could not be validated", and failed deliveries are reported in the [notification system](ops-notification-system.md) as usual.
|
||||
|
||||
This can happen when:
|
||||
|
||||
- the mail server uses a self-signed certificate or one issued by your organization's own internal certificate authority
|
||||
- the SMTP server address is entered as an IP address or a name that the certificate does not cover
|
||||
|
||||
The best fix is to correct the cause: enter the SMTP server address exactly as named in its certificate, or install your internal certificate authority as trusted on the computer running the AyaNova server. If neither is possible, the check can be switched off with this setting.
|
||||
|
||||
## Default
|
||||
|
||||
If this setting is not specified the certificate is checked.
|
||||
|
||||
## Overriding
|
||||
|
||||
AyaNova expects this value to be provided by a config.json property, environment variable or command line parameter named
|
||||
|
||||
`AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE`
|
||||
|
||||
Set it to `true` to accept any certificate from the mail server. Only do this for a mail server on a network you trust: without the check, the connection to the mail server is encrypted but AyaNova can not tell if it is talking to your real mail server.
|
||||
|
||||
When it is set, the AyaNova server log records a warning each time the server starts.
|
||||
|
||||
Example config.json entry
|
||||
|
||||
```json
|
||||
{
|
||||
...other properties...
|
||||
"AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE": "true"
|
||||
}
|
||||
```
|
||||
|
||||
Example command line parameter
|
||||
|
||||
`ayanova.exe --AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE="true"`
|
||||
|
||||
Example environment variable
|
||||
|
||||
Windows
|
||||
|
||||
`set "AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE=true"`
|
||||
|
||||
Linux
|
||||
|
||||
`export AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE="true"`
|
||||
56
docs/8.0/ayanova/docs/ops-config-trusted-proxies.md
Normal file
56
docs/8.0/ayanova/docs/ops-config-trusted-proxies.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Trusted reverse proxies setting
|
||||
|
||||
When AyaNova is reached through a reverse proxy server (for example NGINX, Apache, IIS, a load balancer or a cloud service in front of AyaNova), every connection AyaNova receives comes from the proxy, not from the user's computer. The proxy passes along the user's real address in an `X-Forwarded-For` header.
|
||||
|
||||
AyaNova uses the user's address to limit repeated failed login attempts and to record where logins come from in the [server log](ops-log.md). Because anyone can add an `X-Forwarded-For` header to a request, AyaNova only accepts it from a proxy it trusts.
|
||||
|
||||
A reverse proxy running on the **same computer** as the AyaNova server and connecting to it through `localhost` or `127.0.0.1` is always trusted. This is how our [Linux server](ops-install-linux-server.md) and [Windows IIS](ops-install-windows-iis.md) installation instructions set it up, and nothing needs to be done.
|
||||
|
||||
## When this setting is needed
|
||||
|
||||
If the reverse proxy runs on a **different computer** from the AyaNova server, AyaNova does not trust it by default. All users then appear to come from the proxy's address, and they share a single limit on failed login attempts: repeated failed logins by one person can then refuse logins for everyone connecting through the proxy for a few minutes.
|
||||
|
||||
When this happens, the AyaNova server log records a warning once after each server start, naming the address of the proxy:
|
||||
|
||||
`A request came through a reverse proxy at 10.0.0.5 that is not trusted...`
|
||||
|
||||
If that address is your reverse proxy, add it to this setting.
|
||||
|
||||
## Default
|
||||
|
||||
If this setting is not specified only a reverse proxy on the same computer is trusted.
|
||||
|
||||
## Overriding
|
||||
|
||||
AyaNova expects this value to be provided by a config.json property, environment variable or command line parameter named
|
||||
|
||||
`AYANOVA_TRUSTED_PROXIES`
|
||||
|
||||
The value is a list of the IP addresses of your reverse proxies, separated by commas. A whole network can be given in CIDR notation, for example `10.0.0.0/24` for the addresses `10.0.0.0` to `10.0.0.255`. If there is more than one proxy in a chain (for example a load balancer in front of a reverse proxy), list each of them.
|
||||
|
||||
Only list addresses of your own proxy servers. Any computer listed here can tell AyaNova a user's address, so never list an address range that ordinary users connect from.
|
||||
|
||||
An entry that is not a valid IP address or network is ignored and the AyaNova server log records a warning when the server starts.
|
||||
|
||||
Example config.json entry
|
||||
|
||||
```json
|
||||
{
|
||||
...other properties...
|
||||
"AYANOVA_TRUSTED_PROXIES": "10.0.0.5, 10.0.1.0/24"
|
||||
}
|
||||
```
|
||||
|
||||
Example command line parameter
|
||||
|
||||
`ayanova.exe --AYANOVA_TRUSTED_PROXIES="10.0.0.5, 10.0.1.0/24"`
|
||||
|
||||
Example environment variable
|
||||
|
||||
Windows
|
||||
|
||||
`set "AYANOVA_TRUSTED_PROXIES=10.0.0.5, 10.0.1.0/24"`
|
||||
|
||||
Linux
|
||||
|
||||
`export AYANOVA_TRUSTED_PROXIES="10.0.0.5, 10.0.1.0/24"`
|
||||
@@ -8,7 +8,11 @@ If no override is specified AyaNova will use the following default value:
|
||||
|
||||
`"http://*:7575"`
|
||||
|
||||
This means AyaNova will listen on port 7575
|
||||
This means AyaNova will listen on port 7575 on all of the computer's network addresses.
|
||||
|
||||
## Behind a reverse proxy
|
||||
|
||||
When all users connect through a reverse proxy (such as NGINX) on the same computer, use `127.0.0.1` in place of `*`, for example `"http://127.0.0.1:7575"`. AyaNova then accepts connections only from that computer, so it can not be reached directly over unencrypted HTTP, bypassing the reverse proxy. See the [Linux server installation](ops-install-linux-server.md#f-accept-connections-only-through-nginx) instructions.
|
||||
|
||||
## Overriding
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ These defaults should be examined carefully and set to values that are appropria
|
||||
|
||||
Choose a time for the automatic backup when the server will not be in use by Users.
|
||||
|
||||
The backup runs at this time on the AyaNova server's own clock, every day, including across daylight saving time changes, so make sure the server's operating system is set to the correct time zone. The time is entered and shown in your own time zone, so if you are in a different time zone from the server, the time you see is the matching time in your time zone.
|
||||
|
||||
**Windows server IIS Warning** if you are hosting AyaNova via IIS on Windows be aware that there is a setting in IIS that will regularly restart AyaNova automatically and it must be adjusted to not conflict with the backup time and general usage.
|
||||
|
||||
If you're not sure then double check the IIS installation instructions [Application Pool settings](ops-install-windows-iis.md#74-edit-application-pool).
|
||||
@@ -156,7 +158,7 @@ Checking this box will cause AyaNova to also back up the attached files in addit
|
||||
|
||||
_AyaNova Subscription service keeps one backup by default and this option is not available_
|
||||
|
||||
The backup system will automatically remove old backups depending upon this setting. If it is set to 3 then the most recent three backups are kept.
|
||||
The backup system will automatically remove old backups depending upon this setting. If it is set to 3 then the most recent three backups are kept. The minimum is 1: the most recent backup is always kept.
|
||||
|
||||
If you are following the disaster recovery best practice policy of 10 backup rotation as [suggested above](#10-backup-rotation) then you would only need to keep 1 current backup set.
|
||||
|
||||
|
||||
@@ -84,13 +84,26 @@ Be sure to have all staff carefully test all business processes they require in
|
||||
|
||||
### AyaNova 8 database will be erased
|
||||
|
||||
The V8Migrate plugin will erase **all** user entered data including attached files in the AyaNova 8 database as the first step with the following exceptions:
|
||||
As its first step the V8Migrate plugin erases the AyaNova 8 database so it can be filled with your AyaNova 7 data.
|
||||
|
||||
**Erased** (replaced by the data migrated from AyaNova 7):
|
||||
|
||||
- All business data: Customers, Head offices, Customer contacts, Vendors, Units and Unit models, Parts and inventory, Purchase orders, Work orders, Quotes, Preventive maintenance, Projects, Contracts, Customer service requests, Loaners, Memos, Reminders, Reviews
|
||||
- Business settings entered as records: Service rates, Travel rates, Tax codes, Work order and Quote statuses, Work order item statuses and priorities, Task groups
|
||||
- All Users except the SuperUser account
|
||||
- Attached files, Tags, custom fields set up with [Customize form](ay-customize.md), saved data table filters and column views, autocomplete templates
|
||||
- Notification subscriptions and notifications, history, dashboard layouts, custom (non stock) translations and data stored by integrated applications such as QBI
|
||||
|
||||
**Kept**:
|
||||
|
||||
- The SuperUser account and its password
|
||||
- Global settings
|
||||
- Operations backup settings
|
||||
- Operations notification settings
|
||||
- Operations notification settings (including the email server settings)
|
||||
- Business logos
|
||||
- Reports (any missing stock report templates will be re-installed)
|
||||
- The stock translations
|
||||
- The license
|
||||
|
||||
There is no ability to synchronize or partially migrate only recent changes from v7; the migrate process entirely replaces the V8 data each time it's run.
|
||||
|
||||
@@ -216,7 +229,8 @@ Do not login to AyaNova 8 until migration has completed or stopped due to error
|
||||
- Select the "V8 Migrate" plugin in AyaNova 7 from the main window plugin menu and the login and server URL dialog will open
|
||||
- Enter the URL of your AyaNova 8 server. The easiest way to get this is to simply copy the entire URL displayed in the browser from the AyaNova 8 web application web page, for example the login page.
|
||||
- There is a "Test URL" button to confirm if you have entered the correct address or not.
|
||||
- Enter your AyaNova 8 SuperUser account login credentials. No other account is permitted to perform this operation. The default SuperUser account login name is "superuser" and password is "l3tm3in" if it has not been changed.
|
||||
- Enter your AyaNova 8 SuperUser account login credentials. No other account is permitted to perform this operation. The default SuperUser account login name is "superuser" and password is "l3tm3in" if it has not been changed. If the AyaNova 8 server has a purchased license the SuperUser password must be changed from the default first (see [Default SuperUser login](ay-start-login.md#default-superuser-login)).
|
||||
- If [two factor authentication](home-tfa.md) is turned on for the AyaNova 8 SuperUser account, turn it off before running the migration and turn it back on afterwards; the V8 Migrate plugin can not log in with two factor authentication and the migration will stop with an error.
|
||||
- A **Migration log** form will popup and show the progress of the Migration operation including any errors or specific action required for your particular data.
|
||||
- Do **NOT** login to the AyaNova 8 server while migration is taking place, it will crash the migration completely.
|
||||
- There is no easy way to estimate how long migration will take which is why several test migrations should be done before the final migration. Time of migration depends on the network latency and speed between the V8 Migrate plugin and the AyaNova 8 server as well as the number and complexity of objects entered into AyaNova and the quantity and size of files that may need to be uploaded.
|
||||
|
||||
@@ -80,17 +80,11 @@ If successful psql will display "ALTER ROLE" in response and you can close the p
|
||||
|
||||
### 4\. Create directorys for AyaNova
|
||||
|
||||
AyaNova requires two directorys to be created, one to hold the AyaNova binary and other AyaNova server and web application files and one to hold the AyaNova data. AyaNova will need to write to both these locations so be sure you choose locations with appropriate permissions.
|
||||
AyaNova uses two directories, one to hold the AyaNova binary and other AyaNova server and web application files and one to hold the AyaNova data.
|
||||
|
||||
From the terminal:
|
||||
The data directory does not need to be created: AyaNova creates it the first time it starts. The default setting in AyaNova's configuration is `~/.ayanova-data`. Because AyaNova is started with `sudo` (see below) it runs as the root user, so `~` is root's home directory and the data is kept in `/root/.ayanova-data`. To keep the data somewhere else, set [AYANOVA_DATA_PATH](ops-config-data-path.md) in step 6 to a full path, for example `/opt/ayanova-data`.
|
||||
|
||||
Create the data directory, for desktop linux we recommend your user home directory and AyaNova defaults to '~/.ayanova-data' in it's configuration, however you can use any location that you have write access to.
|
||||
|
||||
```bash
|
||||
sudo mkdir ~/.ayanova-data
|
||||
```
|
||||
|
||||
Create the AyaNova binary files directory. For desktop linux we recommend `/opt/ayanova`.
|
||||
From the terminal, create the AyaNova binary files directory. For desktop linux we recommend `/opt/ayanova`.
|
||||
|
||||
```bash
|
||||
sudo mkdir /opt/ayanova
|
||||
|
||||
@@ -197,16 +197,6 @@ The [AYANOVA_DB_CONNECTION](ops-config-db.md) property must be edited and where
|
||||
|
||||
The [AYANOVA_USE_URLS](ops-config-use-urls.md) property specifies which TCP / IP Port that AyaNova server should listen on for connections from web browsers. The default value is port 7575 which should be free in most cases, however if you need to use an alternate port due to conflict it must be set here. We recommend using the default 7575 to avoid current or future confusion or conflict with other servers that may be installed or present elsewhere on the network.
|
||||
|
||||
Optional but recommended: Add a 32 character [AYANOVA_JWT_SECRET](ops-config-jwt-secret.md) property to the configuration if you want to ensure that logged in User's session can survive a restart of the AyaNova server. If this is not set AyaNova will invalidate all login tokens any time the server restarts by generating a new random JWT secret on boot.
|
||||
|
||||
```json
|
||||
{
|
||||
...existing properties...
|
||||
"AYANOVA_LOG_LEVEL": "Info",
|
||||
"AYANOVA_JWT_SECRET": "1111111MyRandom32CharacterSecret"
|
||||
}
|
||||
```
|
||||
|
||||
Edit the config.json file in your favorite editor, here we'll use Nano:
|
||||
|
||||
```bash
|
||||
@@ -225,16 +215,22 @@ Give www-data ownership of the ayanova directory:
|
||||
sudo chown -vR :www-data /var/ayanova
|
||||
```
|
||||
|
||||
Set permissions to read / write for the AyaNova data directory:
|
||||
Make the AyaNova program files read only for everyone but root, so the account AyaNova runs as can not change them:
|
||||
|
||||
```bash
|
||||
sudo chmod -R go-w /var/ayanova
|
||||
```
|
||||
|
||||
Set permissions to read / write for the AyaNova data directory, the only place AyaNova needs to write to:
|
||||
|
||||
```bash
|
||||
sudo chmod -R g+rw /var/ayanova/data
|
||||
```
|
||||
|
||||
Set permissions for the AyaNova folder
|
||||
The config.json file holds the database password, make it readable only by root and AyaNova:
|
||||
|
||||
```bash
|
||||
chmod g+rw /var/ayanova/
|
||||
sudo chmod 640 /var/ayanova/config.json
|
||||
```
|
||||
|
||||
### 10\. Create and install a service to run AyaNova automatically
|
||||
@@ -264,6 +260,12 @@ SyslogIdentifier=ayanova-server
|
||||
User=www-data
|
||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||
# Security: AyaNova can write only to its data directory
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/ayanova/data
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -271,6 +273,8 @@ WantedBy=multi-user.target
|
||||
|
||||
Make the changes and save with `ctr-x` and type `y` to answer "Save modified buffer?" prompt.
|
||||
|
||||
The lines under `# Security` limit what the AyaNova service can do on the server: it can not gain extra privileges, sees the rest of the system as read only and can write only to its data directory. AyaNova keeps all of its files (logs, attachments, backups, temporary files and the report rendering browser) in the data directory by default so nothing else is needed. If you have set a [log](ops-log.md#log-path), [attachments](ops-config-folder-user-files.md), [backup](ops-config-folder-backup-files.md) or [temporary files](ops-config-folder-temporary-files.md) path outside of the data directory, add each of those folders to the `ReadWritePaths` line separated by spaces, otherwise AyaNova will not be able to write to them.
|
||||
|
||||
Enable the service:
|
||||
|
||||
```bash
|
||||
@@ -333,6 +337,8 @@ These instructions will assume use of a free SSL certificate from [LetsEncrypt](
|
||||
|
||||
We will provide instructions for using the free and open source [NGINX](https://nginx.org/) as a reverse proxy server, if you already have another server capable of being a reverse proxy (such as Apache) you will need to adapt these instructions to match your situation.
|
||||
|
||||
These instructions run the reverse proxy on the same server as AyaNova. If your reverse proxy runs on a different computer, see [trusted reverse proxies](ops-config-trusted-proxies.md).
|
||||
|
||||
#### A\. Install NGINX
|
||||
|
||||
Install NGINX to be the reverse proxy server:
|
||||
@@ -622,7 +628,33 @@ Certbot will also install a systemd timer event which will auto-renew the SSL ce
|
||||
|
||||
Confirm that you can now navigate to AyaNova web app login page through encrypted HTTPS via the domain name configured earlier. i.e. `https://ayanova.yourdomain.com` or `https://yourdomain.com` if not using a subdomain.
|
||||
|
||||
You should now see the login (or license agreement) page through an encrypted SSL connection and AyaNova is ready for use.
|
||||
You should now see the login (or license agreement) page through an encrypted SSL connection.
|
||||
|
||||
#### F\. Accept connections only through NGINX
|
||||
|
||||
AyaNova still listens on port 7575 on all of the server's network addresses, where it can be reached directly over unencrypted HTTP, bypassing NGINX and its encryption. Now that NGINX is working, set AyaNova to accept connections only from this server itself so all users connect through NGINX.
|
||||
|
||||
Edit the [AYANOVA_USE_URLS](ops-config-use-urls.md) setting in config.json:
|
||||
|
||||
```bash
|
||||
sudo nano /var/ayanova/config.json
|
||||
```
|
||||
|
||||
and change `http://*:7575` to `http://127.0.0.1:7575` so the line reads:
|
||||
|
||||
```json
|
||||
"AYANOVA_USE_URLS": "http://127.0.0.1:7575",
|
||||
```
|
||||
|
||||
Save, then restart AyaNova:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart ayanova.service
|
||||
```
|
||||
|
||||
Confirm again that the login page opens through `https://` as in the previous step. AyaNova is now ready for use.
|
||||
|
||||
Only make this change if all users connect through NGINX: users connecting directly to port 7575 on the local network will no longer be able to connect and must use the `https://` address instead.
|
||||
|
||||
## Getting started and using AyaNova
|
||||
|
||||
|
||||
@@ -232,20 +232,6 @@ The [AYANOVA_BACKUP_PG_DUMP_PATH](ops-config-backup-pgdump-path.md) property spe
|
||||
A. if AyaNova server will be doing the automatic daily backups using AyaNova's Backup feature, confirm this path stated matches the path to YOUR PostgreSQL database backup utility pg_dump. The `18` in the path is the PostgreSQL version number: if you installed a different version, change it to match (for example `C:\\Program Files\\PostgreSQL\\17\\bin`)
|
||||
B. otherwise, If you already have a backup solution in place for your PostgreSQL databases AND WILL NOT BE USING AYANOVA'S Backup feature, remove this
|
||||
|
||||
OPTIONAL EDIT:
|
||||
|
||||
Modify the 32 character [AYANOVA_JWT_SECRET](ops-config-jwt-secret.md) property of the configuration which ensures that the logged in User's session can survive a restart of the AyaNova server. If this is not set AyaNova will invalidate all login tokens any time the server restarts by generating a new random JWT secret on boot which under IIS happens frequently.
|
||||
|
||||
Change this from the default of "11111YourRandom32CharacterSecret" to be unique to your site (must be 32 characters).
|
||||
|
||||
```json
|
||||
{
|
||||
...existing properties...
|
||||
"AYANOVA_LOG_LEVEL": "Info",
|
||||
"AYANOVA_JWT_SECRET": "11111YourRandom32CharacterSecret"
|
||||
}
|
||||
```
|
||||
|
||||
After editing, saving and exiting out of NotePad, open the config.json file AGAIN to confirm that the changes were saved correctly and that the file is still specifically labeled with the name and extension config.json
|
||||
|
||||
### 6\. Boot and test the server
|
||||
@@ -404,15 +390,13 @@ Let's Encrypt provides free SSL certificates and lists [several Windows clients]
|
||||
|
||||
The very first time a report is rendered in AyaNova there will be a delay while the Chromium browser report rendering components are installed.
|
||||
|
||||
#### Report rendering error
|
||||
#### Report rendering under IIS
|
||||
|
||||
In some cases there may be a futher setting required for report rendering.
|
||||
Reports, including reports attached to customer notifications, render under IIS with any site bindings, HTTPS included, and no further setting is needed. When AyaNova runs inside IIS, the requests a report makes to the AyaNova server are sent by AyaNova itself to the same address and site name that users connect to.
|
||||
|
||||
If you render a report and see this error:
|
||||
Earlier versions of AyaNova needed the [AYANOVA_REPORT_RENDER_API_URL_OVERRIDE](ops-config-report-rendering-api-url-override.md) setting under some IIS configurations. If it is set from an earlier version it does no harm and can be removed.
|
||||
|
||||
`JobFailed: PuppeteerSharp -> Evaluation failed: TypeError: Failed to fetch at ayPostToAPI`
|
||||
|
||||
You will need to set the [AYANOVA_REPORT_RENDER_API_URL_OVERRIDE](ops-config-report-rendering-api-url-override.md) setting in the [AyaNova server configuration](#5-ayanova-server-configuration) to your AyaNova server's external URL address to enable report rendering for your configuration.
|
||||
If a report fails with an error that mentions "AyaNova API request ... failed", the error text says why. For example, the site's SSL certificate must be valid for the site name users connect to.
|
||||
|
||||
#### Getting started and using AyaNova
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ An up to date modern web browser (not Internet Explorer) with Javascript enabled
|
||||
|
||||
- Program files : By default (and recommended) AyaNova server program files will be installed to `C:\Program Files\ayanova` folder
|
||||
- Data files: All data aside from attachment files is stored in the PostgreSQL database. By default AyaNova will store attachment files, logs and backup files in the %ProgramData% folder under `ayanova` which on most computers will be located at `C:\ProgramData\ayanova`. Each folder location can be changed via [configuration](ops-config-environment-variables.md).
|
||||
- Folder permissions: only administrators can change files in the program folder, so `config.json` must be edited with Notepad run as administrator (see below). Every user of the computer can change files in the data folder, because the AyaNova server runs as whichever user starts it. If the computer is shared with people who should not have access to AyaNova's data, limit the data folder to the Windows account that runs the AyaNova server. The `config.json` file contains the database password and can be read by every user of the computer; on a shared computer limit it to Administrators and the Windows account that runs the AyaNova server.
|
||||
|
||||
## Installation procedure
|
||||
|
||||
@@ -164,20 +165,6 @@ The [AYANOVA_BACKUP_PG_DUMP_PATH](ops-config-backup-pgdump-path.md) property spe
|
||||
A. if AyaNova server will be doing the automatic daily backups using AyaNova's Backup feature, confirm this path stated matches the path to YOUR PostgreSQL database backup utility pg_dump. The `18` in the path is the PostgreSQL version number: if you installed a different version, change it to match (for example `C:\\Program Files\\PostgreSQL\\17\\bin`)
|
||||
B. otherwise, If you already have a backup solution in place for your PostgreSQL databases AND WILL NOT BE USING AYANOVA'S Backup feature, remove this
|
||||
|
||||
OPTIONAL EDIT:
|
||||
|
||||
Modify the 32 character [AYANOVA_JWT_SECRET](ops-config-jwt-secret.md) property of the configuration which ensures that the logged in User's session can survive a restart of the AyaNova server. If this is not set AyaNova will invalidate all login tokens any time the server restarts by generating a new random JWT secret on boot.
|
||||
|
||||
Change this from the default of "11111YourRandom32CharacterSecret" to be unique to your site (must be 32 characters).
|
||||
|
||||
```json
|
||||
{
|
||||
...existing properties...
|
||||
"AYANOVA_LOG_LEVEL": "Info",
|
||||
"AYANOVA_JWT_SECRET": "11111YourRandom32CharacterSecret"
|
||||
}
|
||||
```
|
||||
|
||||
After editing, saving and exiting out of NotePad, open the config.json file AGAIN to confirm that the changes were saved correctly and that the file is still specifically labeled with the name and extension config.json
|
||||
|
||||
### 5\. Boot and test the server
|
||||
|
||||
@@ -32,6 +32,7 @@ The single user installer will use TCP/IP ports 6432 for the PostgreSQL server a
|
||||
|
||||
- Program files : By default (and recommended) AyaNova server program files will be installed to `C:\Program Files\ayanova` folder
|
||||
- Data files: AyaNova will store all data in the %ProgramData% folder under `ayanova` which on most computers will be located at `C:\ProgramData\ayanova`. This includes all logs, backups and User attachment files.
|
||||
- Folder permissions: only administrators can change files in the program folder. Every user of the computer can change files in the data folder, because AyaNova and its built in database server run as whichever user starts them. If the computer is shared with people who should not have access to AyaNova's data, limit the data folder to the Windows account that runs AyaNova. The `config.json` file in the program folder contains the database password and can be read by every user of the computer; on a shared computer limit it to Administrators and the Windows account that runs AyaNova.
|
||||
|
||||
## Installation procedure
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Typically this is an email address, for example `noreply@example.com` but some m
|
||||
|
||||
#### SMTP server password
|
||||
|
||||
The password the SMTP server requires to send email using the SMTP server account address. Note that this password is visible in this form to system operators and the read only roles listed above.
|
||||
The password the SMTP server requires to send email using the SMTP server account address. Once saved, the password is never shown in this form: it shows `********` when a password is set. To change it, type the new password and save. Leaving `********` in place keeps the saved password.
|
||||
|
||||
We recommend a dedicated email account used only for sending notifications.
|
||||
|
||||
@@ -60,6 +60,8 @@ The choices are:
|
||||
- SSL \ TLS recommended and most secure. Implicit forced security.
|
||||
- StartTLS not recommended, negotiated security, may or may not be secure depending on the capabilities of the mail server.
|
||||
|
||||
AyaNova checks the mail server's security certificate when it connects. See [SMTP certificate check](ops-config-smtp-accept-any-certificate.md) if the test message reports that the certificate could not be validated.
|
||||
|
||||
#### SMTP server port
|
||||
|
||||
This is the port that must match the SMTP server's mail port. Most modern mail servers use port `587` which is the current recommended standard port for encrypted mail transmission.
|
||||
|
||||
@@ -139,6 +139,16 @@ pg_restore -U postgres -c -d AyaNova ~/downloads/db-20211212195504569.backup
|
||||
|
||||
The restore will complete normally without any message if successful.
|
||||
|
||||
#### If AyaNova connects to PostgreSQL with its own user
|
||||
|
||||
The examples above are for the default setup, where AyaNova connects to PostgreSQL as the `postgres` user (the `Username` in [AYANOVA_DB_CONNECTION](ops-config-db.md)). If you have set AyaNova up to connect as a different, dedicated PostgreSQL user, the restored tables must belong to that user: add `--no-owner --no-privileges --role=` followed by that user name to the restore command. For example, for a PostgreSQL user named `ayanova`:
|
||||
|
||||
```bash
|
||||
pg_restore -U postgres -c --no-owner --no-privileges --role=ayanova -d AyaNova ~/downloads/db-20211212195504569.backup
|
||||
```
|
||||
|
||||
Without these options the restored tables belong to `postgres` and AyaNova will refuse to start with a database integrity error.
|
||||
|
||||
### Linux server only - set ownershipt and permissions
|
||||
|
||||
Before restarting the AyaNova server on Linux it will first be necessary to reset the ownership and permissions for the files and folders or AyaNova will not be able to write it's log, access attachments or generate reports properly.
|
||||
|
||||
@@ -43,13 +43,14 @@ After this step has completed you can continue with the normal upgrade process i
|
||||
|
||||
#### upgrade script
|
||||
|
||||
The following shell script will automatically remove the old AyaNova files, download and unzip the latest AyaNova files then start the server.
|
||||
The following shell script will automatically remove the old AyaNova files, download and unzip the latest AyaNova files then start the server. It first checks that the .NET 10 runtime is installed and stops without changing anything if it is not (see the pre-upgrade step above). If the download fails, it also stops before any files are removed.
|
||||
|
||||
From a terminal window run the following commands:
|
||||
|
||||
```bash
|
||||
cd /opt/ayanova && \
|
||||
sudo curl -O https://www.ayanova.com/download/ayanova-linux-x64-desktop.zip && \
|
||||
{ dotnet --list-runtimes | grep -q '^Microsoft.AspNetCore.App 10\.' || { echo "The .NET 10 runtime is not installed: do the pre-upgrade step first. Nothing was changed."; false; }; } && \
|
||||
sudo curl -fO https://www.ayanova.com/download/ayanova-linux-x64-desktop.zip && \
|
||||
sudo rm *.dll && \
|
||||
sudo rm -r ./resource && \
|
||||
sudo rm -r ./wwwroot && \
|
||||
|
||||
@@ -18,7 +18,7 @@ If you do not have a fresh backup there will be no way to recover from potential
|
||||
|
||||
**Only if** your current AyaNova version is older than 8.3.0 there is an extra step before the normal upgrade process.
|
||||
|
||||
8.3.0 switched to .NET 10 LTS from the prior used .NET 8 LTS (8.2.0 to 8.2.4), so the .NET 10 runtime must be installed first. If you are upgrading from a version older than 8.2.0 this script also accommodates that release's changes to folder ownership and permissions.
|
||||
8.3.0 switched to .NET 10 LTS from the prior used .NET 8 LTS (8.2.0 to 8.2.4), so the .NET 10 runtime must be installed first. This script also sets the folder ownership and permissions the [installation instructions](ops-install-linux-server.md#9-set-ownership-and-permissions) now use: the program files read only for the account AyaNova runs as, which only needs to write to the data directory, and config.json, which holds the database password, readable only by root and AyaNova.
|
||||
|
||||
From a terminal window run the following commands:
|
||||
|
||||
@@ -26,29 +26,48 @@ From a terminal window run the following commands:
|
||||
sudo apt-get update && sudo apt-get upgrade && \
|
||||
sudo apt-get install -y aspnetcore-runtime-10.0 && \
|
||||
sudo chown -vR :www-data /var/ayanova && \
|
||||
sudo chmod g+rw /var/ayanova/
|
||||
sudo chmod -R go-w /var/ayanova && \
|
||||
sudo chmod -R g+rw /var/ayanova/data && \
|
||||
sudo chmod 640 /var/ayanova/config.json
|
||||
```
|
||||
|
||||
These commands assume AyaNova runs as `www-data`, as in the installation instructions. If you chose another account, use its group in the `chown` line.
|
||||
|
||||
On Ubuntu 24.04 and 26.04 the .NET 10 runtime comes from Ubuntu's own package archive. On Ubuntu 22.04 first add the Ubuntu .NET backports package repository with `sudo add-apt-repository ppa:dotnet/backports`.
|
||||
|
||||
Installing the .NET 10 runtime replaces the shared `dotnet` command, the .NET 8 runtime stays installed and can be removed later if nothing else on the server uses it.
|
||||
|
||||
If there is an issue or error break apart the commands and execute each one individually to find the error.
|
||||
|
||||
#### Recommended: security settings for the AyaNova service
|
||||
|
||||
The [installation instructions](ops-install-linux-server.md#10-create-and-install-a-service-to-run-ayanova-automatically) now add security settings to the AyaNova service so it can write only to its data directory. To add them to an existing installation without editing the service file, run:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/systemd/system/ayanova.service.d && \
|
||||
printf '[Service]\nNoNewPrivileges=true\nProtectSystem=strict\nReadWritePaths=/var/ayanova/data\nPrivateTmp=true\nProtectHome=true\n' | sudo tee /etc/systemd/system/ayanova.service.d/hardening.conf && \
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
They take effect when the AyaNova service next starts, in the upgrade step below. If you have set a log, attachments, backup or temporary files path outside of the data directory, first read the note about `ReadWritePaths` in the [installation instructions](ops-install-linux-server.md#10-create-and-install-a-service-to-run-ayanova-automatically).
|
||||
|
||||
If users connect through NGINX as in the [internet access](ops-install-linux-server.md#internet-access) instructions, also set AyaNova to [accept connections only through NGINX](ops-install-linux-server.md#f-accept-connections-only-through-nginx).
|
||||
|
||||
After this step has completed you can continue with the normal upgrade process in the next step.
|
||||
|
||||
### 2\. Upgrade
|
||||
|
||||
#### upgrade script
|
||||
|
||||
The following shell script will automatically stop the AyaNova server service, remove the old AyaNova files, download and unzip the latest AyaNova files then start the server service.
|
||||
The following shell script will automatically stop the AyaNova server service, remove the old AyaNova files, download and unzip the latest AyaNova files then start the server service. It first checks that the .NET 10 runtime is installed and stops without changing anything if it is not (see the pre-upgrade step above).
|
||||
|
||||
Run the following shell commands:
|
||||
|
||||
```bash
|
||||
cd /var/ayanova && \
|
||||
{ dotnet --list-runtimes | grep -q '^Microsoft.AspNetCore.App 10\.' || { echo "The .NET 10 runtime is not installed: do the pre-upgrade step first. Nothing was changed."; false; }; } && \
|
||||
sudo systemctl stop ayanova.service && \
|
||||
sudo curl -O https://www.ayanova.com/download/ayanova-linux-x64-server.zip && \
|
||||
sudo curl -fO https://www.ayanova.com/download/ayanova-linux-x64-server.zip && \
|
||||
sudo rm *.dll && \
|
||||
sudo rm -r ./resource && \
|
||||
sudo rm -r ./wwwroot && \
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
[View Change log / release notes](https://ayanova.com/docs/changelog).
|
||||
|
||||
## Upgrading from 8.2.4 or older
|
||||
|
||||
AyaNova 8.3.0 moved to .NET 10. If your AyaNova is version 8.2.4 or older, upgrading a Windows network (IIS or LAN) or Linux installation needs a one-time pre-upgrade step, described in the instructions for your platform below. It applies whichever newer version you upgrade to. Windows single standalone installations need no extra step.
|
||||
|
||||
## Maintenance plan
|
||||
|
||||
AyaNova updates are available with an active Maintenance plan.
|
||||
|
||||
@@ -8,17 +8,17 @@ AyaNova subscription service ensures you are always running the latest version o
|
||||
|
||||
Our network operations engineers will take care of all updates required to AyaNova or the underlying server operating system hosting AyaNova.
|
||||
|
||||
### Update advance notifications
|
||||
### Update notifications
|
||||
|
||||
When an update is required to be installed we will try to give at least 24 hours advance notice however in the case of an urgent security update we may not be able to give any advance warning due to the urgency of the situation.
|
||||
|
||||
An upgrade notification will be emailed to the email address used to purchase the AyaNova subscription service. The notification will contain information about the type of upgrade required and a link to the [AyaNova change log](changelog.md) so you can see exactly what will be fixed, changed or new features added.
|
||||
When your service has been upgraded to a new version of AyaNova, a notification is emailed to the email address used to purchase the AyaNova subscription service. It contains a link to the [AyaNova change log](changelog.md) so you can see exactly what was fixed, changed or added.
|
||||
|
||||
### Downtime during an update
|
||||
|
||||
Standard upgrades to AyaNova typically require less than one minute of down time where the AyaNova server will be unresponsive. During an update there is typically no need to log out of AyaNova, there will be a brief period of non responsiveness and then AyaNova will continue to respond normally.
|
||||
Every week the operating system of the server hosting your AyaNova is updated, and the server is then restarted: AyaNova is unavailable for as much as 2 minutes.
|
||||
|
||||
In the case of an operating system upgrade the server itself hosting your AyaNova instance needs to be rebooted so there can be as much as 2 minutes down time on average. Again there is typically no need to log out of AyaNova, it is designed to allow you to continue working once it has reloaded and is ready for connections.
|
||||
When a new version of AyaNova is installed, which is less frequent than the weekly update, the server is shut down for the upgrade, so AyaNova can be unavailable for a few minutes longer, usually 5 minutes or less.
|
||||
|
||||
In either case there is no need to log out of AyaNova: it stops responding for a short time and then continues normally once it is ready for connections again.
|
||||
|
||||
### Web application update pop-up
|
||||
|
||||
@@ -28,13 +28,14 @@ If you choose to do it later you can continue to work and then select to reload
|
||||
|
||||
### What time will my service be upgraded?
|
||||
|
||||
We have chosen standard times for each data center to install standard updates that are intended to be outside of normal business hours relative to each data center to avoid any disruption to your business.
|
||||
We have chosen standard times for each data center to install updates, outside of normal business hours relative to each data center to avoid any disruption to your business.
|
||||
|
||||
Usually, if necessary, we will perform maintenance or upgrades on Thursday (our west coast north america time zone).
|
||||
Updates are installed every Thursday (Pacific time, our time zone).
|
||||
|
||||
Your service will be updated at the following data center local time:
|
||||
|
||||
- New York 7 p.m.
|
||||
- Atlanta 7 p.m.
|
||||
- San Francisco 7 p.m.
|
||||
- Amsterdam 1 a.m.
|
||||
- Singapore 1 a.m.
|
||||
@@ -44,7 +45,7 @@ Your service will be updated at the following data center local time:
|
||||
- Bangalore 5:30 a.m.
|
||||
- Sydney 4 a.m.
|
||||
|
||||
Note that in the case of an urgent or emergency security update we may be forced to install it immediately at any time of day without advance notice.
|
||||
An urgent update, such as a security fix, may be installed on any day and at any time if necessary; we always try to keep the impact on your business as small as possible.
|
||||
|
||||
## Backups
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ Read only access
|
||||
- Service manager - restricted
|
||||
- Service technician
|
||||
|
||||
Customer contacts with the Customer role can create Customer service requests for their own Customer and view them; with the Customer - restricted role they can view them.
|
||||
|
||||
## How to access Customer service requests
|
||||
|
||||
Customer service requests are accessed in the following ways:
|
||||
@@ -115,6 +117,8 @@ This option will locally create a new work order based off this Customer service
|
||||
|
||||
If the Work order is not saved no changes are made to the Customer service request and it remains Open.
|
||||
|
||||
If that work order, or the work order item created from the request, is later deleted, the Customer service request goes back to Open so it can be accepted again. No notification is sent for this.
|
||||
|
||||
#### Reject service request
|
||||
|
||||
This option will immediately reject the CSR, save changes and close the CSR form.
|
||||
|
||||
@@ -36,7 +36,7 @@ It is **critically important** to do this before taking any further steps; parti
|
||||
|
||||
Failing to do this would mean you will have no effective security as the AyaNova superuser password is well known and could be used by anyone to cause significant damage to your data or create more Users with administrative level privileges.
|
||||
|
||||
Once AyaNova is licensed, if you log in as the superuser with the default password `l3tm3in`, you will be taken automatically to the change password form to encourage it to be changed.
|
||||
Once AyaNova is licensed, if you log in as the superuser with the default password `l3tm3in`, you will be taken automatically to the change password form, and the rest of AyaNova can not be used by the superuser until the password is changed.
|
||||
|
||||
We have [secure password guidelines](home-password.md#what-is-a-secure-password) for the latest best practices in choosing a secure password and we encourage you to read about it and consider the advice written there in order to choose an easy to memorize, secure password that does not need to be written down or stored insecurely.
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ site_name: AyaNova manual
|
||||
site_dir: "../../../server/AyaNova/wwwroot/docs"
|
||||
site_url: https://ayanova.com/docs/
|
||||
strict: true
|
||||
copyright: Copyright © 2022-2026 Ground Zero Tech-Works Inc. REV-2026-10-22
|
||||
# YEAR-AUTO and REV-AUTO are set when the manual is built (../rev_date.py)
|
||||
copyright: Copyright © 2022-YEAR-AUTO Ground Zero Tech-Works Inc. REV-AUTO
|
||||
hooks:
|
||||
- ../rev_date.py
|
||||
extra:
|
||||
generator: false
|
||||
# Extensions
|
||||
@@ -197,8 +200,10 @@ nav:
|
||||
- "Reporting render browser path": "ops-config-report-render-browser-path.md"
|
||||
- "Reporting timeout configuration": "ops-config-report-rendering-timeout.md"
|
||||
- "Environment variable reference": "ops-config-environment-variables.md"
|
||||
- "SMTP certificate check": "ops-config-smtp-accept-any-certificate.md"
|
||||
- "Security and JWT web tokens": "ops-config-jwt-secret.md"
|
||||
- "SuperUser password reset": "ops-config-set-superuser-pw.md"
|
||||
- "Trusted reverse proxies": "ops-config-trusted-proxies.md"
|
||||
- "Server error codes": "ops-error-codes.md"
|
||||
- "Migrating from AyaNova 7": "ops-import-v7.md"
|
||||
- "Metrics": "ops-metrics.md"
|
||||
|
||||
@@ -8,7 +8,10 @@ theme:
|
||||
site_name: AyaNova Customer user manual
|
||||
site_dir: '../../../server/AyaNova/wwwroot/cust'
|
||||
strict: true
|
||||
copyright: Copyright © 2022 Ground Zero Tech-Works Inc. REV-2022-07-18
|
||||
# YEAR-AUTO and REV-AUTO are set when the manual is built (../rev_date.py)
|
||||
copyright: Copyright © 2022-YEAR-AUTO Ground Zero Tech-Works Inc. REV-AUTO
|
||||
hooks:
|
||||
- ../rev_date.py
|
||||
extra:
|
||||
generator: false
|
||||
# Extensions
|
||||
|
||||
34
docs/8.0/rev_date.py
Normal file
34
docs/8.0/rev_date.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# case 4757: the revision stamp and copyright year in the footer are set when a manual is built, never by hand.
|
||||
# Used by both manuals (hooks: in mkdocs.yml). In the copyright line REV-AUTO becomes REV-<date> and YEAR-AUTO
|
||||
# becomes that date's year. The date is the last commit that changed this manual (its folder, mkdocs.yml included),
|
||||
# or today if it has uncommitted changes or git is not available (e.g. a build outside the repo).
|
||||
# Both manuals build with strict: true, so a warning here fails the build: only a missing placeholder warns.
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
log = logging.getLogger("mkdocs.hooks.rev_date")
|
||||
|
||||
|
||||
def _git(folder, *args):
|
||||
return subprocess.run(["git", *args, "--", "."], cwd=folder, capture_output=True, text=True, check=True).stdout.strip()
|
||||
|
||||
|
||||
def on_config(config):
|
||||
folder = os.path.dirname(os.path.abspath(config.config_file_path))
|
||||
today = datetime.date.today().isoformat()
|
||||
try:
|
||||
rev = today if _git(folder, "status", "--porcelain") else _git(folder, "log", "-1", "--format=%cs")
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
log.info("git not available: revision date set to today")
|
||||
rev = today
|
||||
rev = rev or today
|
||||
|
||||
copyright = config.copyright or ""
|
||||
for placeholder in ("REV-AUTO", "YEAR-AUTO"):
|
||||
if placeholder not in copyright:
|
||||
log.warning(f"{placeholder} not found in the copyright line of {config.config_file_path}")
|
||||
config.copyright = copyright.replace("REV-AUTO", "REV-" + rev).replace("YEAR-AUTO", rev[:4])
|
||||
log.info(f"revision stamp: {config.copyright}")
|
||||
return config
|
||||
@@ -4,8 +4,8 @@
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<GenerateFullPaths>true</GenerateFullPaths>
|
||||
<Version>8.3.0</Version>
|
||||
<FileVersion>8.3.0.0</FileVersion>
|
||||
<Version>8.3.1</Version>
|
||||
<FileVersion>8.3.1.0</FileVersion>
|
||||
<ApplicationIcon>ayanova.ico</ApplicationIcon>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
<noWarn>1591</noWarn>
|
||||
|
||||
107
server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs
Normal file
107
server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace AyaNova.Api.ControllerHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// case 4712: the only API routes a customer contact (UserType Customer or HeadOffice) may call:
|
||||
/// the ones the customer screens of the client use. Everything else answers 403 to a contact, so a
|
||||
/// new route stays closed to contacts unless it is added here on purpose.
|
||||
/// Ownership of the records these routes return is checked in the routes themselves.
|
||||
/// See devdocs/modernization/SECURITY-REVIEW.md, "Customer contact route allowlist".
|
||||
/// </summary>
|
||||
internal static class CustomerContactRoutes
|
||||
{
|
||||
private const string API_PREFIX = "api/v{version:apiVersion}/";
|
||||
|
||||
//"METHOD route template" with the API prefix removed, exactly as in the controllers' attributes
|
||||
private static readonly HashSet<string> Allowed = new HashSet<string>
|
||||
{
|
||||
//own account
|
||||
"POST auth/change-password",
|
||||
"GET auth/totp",
|
||||
"POST auth/totp-validate",
|
||||
"POST auth/totp-disable/{id}",
|
||||
"GET user-option/{id}",
|
||||
"PUT user-option/{id}",
|
||||
//app shell
|
||||
"GET global-biz-setting/client",
|
||||
"GET enum-list/list/{enumkey}",
|
||||
"GET form-custom/{formkey}",
|
||||
"GET translation/list",
|
||||
"POST translation/subset",
|
||||
"GET server-info",
|
||||
"GET notify/new-count",
|
||||
"GET notify/app-notifications",
|
||||
"DELETE notify/{id}",
|
||||
//data lists (the server sets the customer filter for contacts)
|
||||
"POST data-list",
|
||||
"GET data-list/listfields",
|
||||
"GET data-list-column-view/{listKey}",
|
||||
"POST data-list-column-view",
|
||||
"DELETE data-list-column-view/{listKey}",
|
||||
"POST data-list-column-view/sort",
|
||||
"GET data-list-filter/list",
|
||||
"GET data-list-filter/{id}",
|
||||
"POST data-list-filter",
|
||||
"PUT data-list-filter",
|
||||
"DELETE data-list-filter/{id}",
|
||||
"GET tag-list/list",
|
||||
"POST pick-list/list",
|
||||
"POST pick-list/single",
|
||||
//work order (customer view, customer report, attachments)
|
||||
"GET workorder/customer/{id}",
|
||||
"GET attachment/list",
|
||||
"POST report/render-job",
|
||||
"GET job-operations/status/{gid}",
|
||||
"GET job-operations/logs/{gid}",
|
||||
"POST report/request-cancel/{gid}",
|
||||
//customer service requests (not accept / reject, which are staff actions)
|
||||
"POST csr",
|
||||
"PUT csr",
|
||||
"GET csr/{id}",
|
||||
"DELETE csr/{id}",
|
||||
"POST unit/customerunit",
|
||||
//notification subscriptions
|
||||
"GET notify-subscription/list",
|
||||
"GET notify-subscription/{id}",
|
||||
"POST notify-subscription",
|
||||
"PUT notify-subscription",
|
||||
"DELETE notify-subscription/{id}",
|
||||
"GET work-order-status/list",
|
||||
"GET quote-status/list",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True if a customer contact may call the endpoint this request was routed to.
|
||||
/// Anonymous routes (login, downloads by key, logo...) are public anyway and always allowed.
|
||||
/// </summary>
|
||||
internal static bool IsAllowed(HttpContext context)
|
||||
{
|
||||
var endpoint = context.GetEndpoint();
|
||||
if (endpoint == null)
|
||||
return true;//not routed to anything: the request ends in a 404 regardless
|
||||
if (endpoint.Metadata.GetMetadata<IAllowAnonymous>() != null)
|
||||
return true;
|
||||
var key = RouteKey(context);
|
||||
return key != null && Allowed.Contains(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "METHOD route template" of the endpoint this request was routed to, API prefix removed
|
||||
/// (e.g. "GET user-option/{id}"); null when not routed to a route endpoint.
|
||||
/// Also used by the token middleware (case 4725)
|
||||
/// </summary>
|
||||
internal static string RouteKey(HttpContext context)
|
||||
{
|
||||
if (context.GetEndpoint() is not RouteEndpoint routeEndpoint)
|
||||
return null;
|
||||
var template = routeEndpoint.RoutePattern.RawText ?? string.Empty;
|
||||
if (template.StartsWith(API_PREFIX))
|
||||
template = template.Substring(API_PREFIX.Length);
|
||||
return $"{context.Request.Method.ToUpperInvariant()} {template}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace AyaNova.Api.ControllerHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// case 4725: with a purchased licence, a SuperUser still using the default password may only change it:
|
||||
/// these are the routes the change password page and the app shell need. Everything else answers 403
|
||||
/// until the password is changed.
|
||||
/// See devdocs/modernization/SECURITY-REVIEW.md, F9.
|
||||
/// </summary>
|
||||
internal static class SuperUserDefaultPasswordRoutes
|
||||
{
|
||||
//"METHOD route template" with the API prefix removed, exactly as in the controllers' attributes
|
||||
private static readonly HashSet<string> Allowed = new HashSet<string>
|
||||
{
|
||||
"POST auth/change-password",
|
||||
"GET user-option/{id}",
|
||||
"GET global-biz-setting/client",
|
||||
"GET enum-list/list/{enumkey}",
|
||||
"GET form-custom/{formkey}",
|
||||
"GET translation/list",
|
||||
"POST translation/subset",
|
||||
"GET server-info",
|
||||
"GET notify/new-count",
|
||||
"GET notify/app-notifications",
|
||||
};
|
||||
|
||||
internal static bool IsAllowed(HttpContext context)
|
||||
{
|
||||
var endpoint = context.GetEndpoint();
|
||||
if (endpoint == null)
|
||||
return true;//not routed to anything: the request ends in a 404 regardless
|
||||
if (endpoint.Metadata.GetMetadata<IAllowAnonymous>() != null)
|
||||
return true;
|
||||
var key = CustomerContactRoutes.RouteKey(context);
|
||||
return key != null && Allowed.Contains(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,9 @@ namespace AyaNova.Api.Controllers
|
||||
*/
|
||||
|
||||
|
||||
var dbId = Uri.EscapeDataString(AyaNova.Core.License.ServerDbId);
|
||||
var company = Uri.EscapeDataString(AyaNova.Core.License.ActiveKey.RegisteredTo);
|
||||
return $"https://contact.ayanova.com/contact?dbid={dbId}&company={company}";
|
||||
//case 4727 (F11, F39): this page is anonymous, so the link no longer carries the DB ID and licensee;
|
||||
//the About form's support link (logged in) still fills them in
|
||||
return "https://contact.ayanova.com/contact";
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,6 @@ namespace AyaNova.Api.Controllers
|
||||
<h2><a href=""/docs"" target=""_blank"">User and technical guide</a></h2>
|
||||
<h2><a href=""{SupportUrl()}"" target=""_blank"">Contact technical support</a></h2>
|
||||
<h2><a href=""/api-docs"" target=""_blank"">API explorer for developers</a></h2>
|
||||
<pre>{AyaNova.Core.License.LicenseInfo}</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
@@ -120,6 +119,9 @@ namespace AyaNova.Api.Controllers
|
||||
[HttpGet("server-info")]
|
||||
public ActionResult ServerInfo()
|
||||
{
|
||||
//case 4727 (F29): the DB ID and licence go to staff only (Service, NotService), as the About form shows them
|
||||
var userType = UserTypeFromContext.Type(HttpContext.Items);
|
||||
bool staff = userType == UserType.Service || userType == UserType.NotService;
|
||||
return Ok(new
|
||||
{
|
||||
data = new
|
||||
@@ -128,8 +130,8 @@ namespace AyaNova.Api.Controllers
|
||||
DBSchemaVersion = AySchema.currentSchema,
|
||||
ServerLocalTime = DateUtil.ServerDateTimeString(System.DateTime.UtcNow),
|
||||
ServerTimeZone = TimeZoneInfo.Local.Id,
|
||||
ServerDbId = AyaNova.Core.License.ServerDbId,
|
||||
License = AyaNova.Core.License.LicenseInfoAsJson,
|
||||
ServerDbId = staff ? AyaNova.Core.License.ServerDbId : string.Empty,
|
||||
License = staff ? AyaNova.Core.License.LicenseInfoAsJson : null,
|
||||
#if (SUBSCRIPTION_BUILD)
|
||||
Build = "Subscription"
|
||||
#else
|
||||
|
||||
@@ -157,11 +157,16 @@ namespace AyaNova.Api.Controllers
|
||||
|
||||
//Is this a customer user attempting to view a wo attachments??
|
||||
var userType = UserTypeFromContext.Type(HttpContext.Items);
|
||||
if (ayaType == AyaType.WorkOrder && (userType == UserType.Customer || userType == UserType.HeadOffice))
|
||||
//case 4712: customer contacts see attachments only on their own customer's work orders
|
||||
if (userType == UserType.Customer || userType == UserType.HeadOffice)
|
||||
{
|
||||
if (ayaType != AyaType.WorkOrder)
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
var wo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == ayaId).Select(x => new { x.Tags, x.CustomerId }).FirstOrDefaultAsync();
|
||||
if (wo == null || !await UserBiz.CustomerContactOwnsCustomerAsync(ct, UserIdFromContext.Id(HttpContext.Items), wo.CustomerId))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
//check if allowed
|
||||
var woTags = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == ayaId).Select(x => x.Tags).FirstOrDefaultAsync();
|
||||
var custUserRights = await UserBiz.CustomerUserEffectiveRightsAsync(UserIdFromContext.Id(HttpContext.Items), woTags);
|
||||
var custUserRights = await UserBiz.CustomerUserEffectiveRightsAsync(UserIdFromContext.Id(HttpContext.Items), wo.Tags);
|
||||
if (!custUserRights.ThisWOCanAttachments)
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
}
|
||||
@@ -264,7 +269,9 @@ namespace AyaNova.Api.Controllers
|
||||
{
|
||||
AttachToAType = uploadFormData.FormFieldData["AttachToAType"].ToString();
|
||||
//for v8 migrate purposes
|
||||
if (uploadFormData.FormFieldData.ContainsKey("OverrideUserId"))
|
||||
//case 4727 (F25): only SuperUser or a user who may manage users may set it, otherwise it is ignored
|
||||
if (uploadFormData.FormFieldData.ContainsKey("OverrideUserId") &&
|
||||
(UserIdFromContext.Id(HttpContext.Items) == 1 || Authorized.HasModifyRole(HttpContext.Items, AyaType.User)))
|
||||
OverrideUserId = long.Parse(uploadFormData.FormFieldData["OverrideUserId"].ToString());
|
||||
|
||||
AttachToObjectId = uploadFormData.FormFieldData["AttachToObjectId"].ToString();
|
||||
@@ -553,11 +560,16 @@ namespace AyaNova.Api.Controllers
|
||||
//is this allowed?
|
||||
//Is this a customer user attempting to view a wo attachments??
|
||||
|
||||
if (dbObject.AttachToAType == AyaType.WorkOrder && (DownloadUser.UserType == UserType.Customer || DownloadUser.UserType == UserType.HeadOffice))
|
||||
//case 4712: customer contacts download attachments only from their own customer's work orders
|
||||
if (DownloadUser.UserType == UserType.Customer || DownloadUser.UserType == UserType.HeadOffice)
|
||||
{
|
||||
if (dbObject.AttachToAType != AyaType.WorkOrder)
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
var wo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == dbObject.AttachToObjectId).Select(x => new { x.Tags, x.CustomerId }).FirstOrDefaultAsync();
|
||||
if (wo == null || !await UserBiz.CustomerContactOwnsCustomerAsync(ct, DownloadUser.Id, wo.CustomerId))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
//check if allowed
|
||||
var woTags = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == dbObject.AttachToObjectId).Select(x => x.Tags).FirstOrDefaultAsync();
|
||||
var custUserRights = await UserBiz.CustomerUserEffectiveRightsAsync(DownloadUser.Id, woTags);
|
||||
var custUserRights = await UserBiz.CustomerUserEffectiveRightsAsync(DownloadUser.Id, wo.Tags);
|
||||
if (!custUserRights.ThisWOCanAttachments)
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -34,6 +35,10 @@ namespace AyaNova.Api.Controllers
|
||||
private readonly ApiServerState serverState;
|
||||
private const int JWT_LIFETIME_DAYS = 5;
|
||||
|
||||
//case 4710: wrong 2fa codes allowed per password entry, counted per user (in memory, a restart only resets the count)
|
||||
private const int MAX_TFA_FAILURES = 5;
|
||||
private static readonly ConcurrentDictionary<long, int> TfaFailures = new ConcurrentDictionary<long, int>();
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
@@ -77,8 +82,14 @@ namespace AyaNova.Api.Controllers
|
||||
return StatusCode(451, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, "generalerror", "License agreement consent required"));
|
||||
}
|
||||
|
||||
//case 4726: too many failed logins for this login name or from this address: refused at once, the password is not checked
|
||||
var address = HttpContext.Connection.RemoteIpAddress;
|
||||
if (LoginThrottle.IsRefused(creds.Login, address))
|
||||
return StatusCode(429, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, null, "Too many failed login attempts, please try again later"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(creds.Login) || string.IsNullOrWhiteSpace(creds.Password))
|
||||
{
|
||||
RecordLoginFailure(creds.Login, address);
|
||||
//Make a failed pw wait
|
||||
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
||||
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
||||
@@ -94,7 +105,7 @@ namespace AyaNova.Api.Controllers
|
||||
string hashed = Hasher.hash(u.Salt, creds.Password);
|
||||
if (hashed == u.Password)
|
||||
{
|
||||
|
||||
LoginThrottle.RecordSuccess(creds.Login);
|
||||
|
||||
//TWO FACTOR ENABLED??
|
||||
//if 2fa enabled then need to validate it before sending token, so we're halfway there and need to send a 2fa prompt
|
||||
@@ -103,6 +114,7 @@ namespace AyaNova.Api.Controllers
|
||||
//Generate a temporary token to identify and verify this is the same user
|
||||
u.TempToken = Hasher.GenerateSalt().Replace("=", "").Replace("+", "");
|
||||
await ct.SaveChangesAsync();
|
||||
TfaFailures.TryRemove(u.Id, out _);
|
||||
var UOpt = await ct.UserOptions.AsNoTracking().FirstAsync(z => z.UserId == u.Id);
|
||||
|
||||
List<string> TranslationKeysToFetch = new List<string> { "AuthTwoFactor", "AuthEnterPin", "AuthVerifyCode", "Cancel", "AuthPinInvalid" };
|
||||
@@ -128,11 +140,20 @@ namespace AyaNova.Api.Controllers
|
||||
}
|
||||
|
||||
//No users matched, it's a failed login
|
||||
RecordLoginFailure(creds.Login, address);
|
||||
//Make a failed pw wait
|
||||
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
||||
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
||||
}
|
||||
|
||||
//case 4726
|
||||
private void RecordLoginFailure(string login, System.Net.IPAddress address)
|
||||
{
|
||||
var reached = LoginThrottle.RecordFailure(login, address);
|
||||
if (reached != null)
|
||||
log.LogWarning($"Too many failed login attempts for {reached}: further attempts refused for up to 15 minutes");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -167,7 +188,14 @@ namespace AyaNova.Api.Controllers
|
||||
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
||||
}
|
||||
|
||||
//Match to temp token that would have been set by initial credentialed login for 2fa User
|
||||
//case 4710: the temp token is the only proof the password step was passed, so it must be present
|
||||
if (string.IsNullOrWhiteSpace(pin.TempToken))
|
||||
{
|
||||
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
||||
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
||||
}
|
||||
|
||||
//Match to temp token that would have been set by initial credentialed login for 2fa User
|
||||
var user = await ct.User.Where(z => z.TempToken == pin.TempToken && z.Active == true && z.AllowLogin==true && z.TwoFactorEnabled == true).FirstOrDefaultAsync();
|
||||
|
||||
|
||||
@@ -184,14 +212,22 @@ namespace AyaNova.Api.Controllers
|
||||
var tfa = new TwoFactorAuth("AyaNova");
|
||||
if (!tfa.VerifyCode(user.TotpSecret, pin.Pin.Replace(" ", "").Trim()))
|
||||
{
|
||||
//case 4710: after too many wrong codes the password must be entered again
|
||||
if (TfaFailures.AddOrUpdate(user.Id, 1, (k, n) => n + 1) >= MAX_TFA_FAILURES)
|
||||
{
|
||||
TfaFailures.TryRemove(user.Id, out _);
|
||||
user.TempToken = null;
|
||||
await ct.SaveChangesAsync();
|
||||
}
|
||||
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
||||
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
||||
}
|
||||
|
||||
//User is valid and authenticated
|
||||
//clear temp token
|
||||
user.TempToken = string.Empty;
|
||||
//clear temp token (case 4710: null, not empty, so no value is left to match)
|
||||
user.TempToken = null;
|
||||
await ct.SaveChangesAsync();
|
||||
TfaFailures.TryRemove(user.Id, out _);
|
||||
|
||||
return await ReturnUserCredsOnSuccessfulAuthentication(user);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -52,6 +54,12 @@ namespace AyaNova.Api.Controllers
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
if (!await ContactMayUseCustomerAsync(newObject.CustomerId))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
//case 4712: a contact's request is always requested by that contact
|
||||
var userType = UserTypeFromContext.Type(HttpContext.Items);
|
||||
if (userType == UserType.Customer || userType == UserType.HeadOffice)
|
||||
newObject.RequestedByUserId = UserIdFromContext.Id(HttpContext.Items);
|
||||
CustomerServiceRequest o = await biz.CreateAsync(newObject);
|
||||
if (o == null)
|
||||
return BadRequest(new ApiErrorResponse(biz.Errors));
|
||||
@@ -76,6 +84,8 @@ namespace AyaNova.Api.Controllers
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
if (!await ContactMayUseExistingAsync(id))
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
var o = await biz.GetAsync(id);
|
||||
if (o == null) return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
return Ok(ApiOkResponse.Response(o));
|
||||
@@ -96,7 +106,11 @@ namespace AyaNova.Api.Controllers
|
||||
CustomerServiceRequestBiz biz = CustomerServiceRequestBiz.GetBiz(ct, HttpContext);
|
||||
if (!Authorized.HasModifyRole(HttpContext.Items, biz.BizType))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
var o = await biz.PutAsync(updatedObject);
|
||||
if (!await ContactMayUseExistingAsync(updatedObject.Id))
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
if (!await ContactMayUseCustomerAsync(updatedObject.CustomerId))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
var o = await biz.PutAsync(updatedObject);
|
||||
if (o == null)
|
||||
{
|
||||
if (biz.Errors.Exists(z => z.Code == ApiErrorCode.CONCURRENCY_CONFLICT))
|
||||
@@ -122,11 +136,32 @@ namespace AyaNova.Api.Controllers
|
||||
CustomerServiceRequestBiz biz = CustomerServiceRequestBiz.GetBiz(ct, HttpContext);
|
||||
if (!Authorized.HasDeleteRole(HttpContext.Items, biz.BizType))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
if (!await ContactMayUseExistingAsync(id))
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
if (!await biz.DeleteAsync(id))
|
||||
return BadRequest(new ApiErrorResponse(biz.Errors));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
//case 4712: a customer contact works only with its own customer's service requests (staff: always true)
|
||||
private async Task<bool> ContactMayUseCustomerAsync(long customerId)
|
||||
{
|
||||
var userType = UserTypeFromContext.Type(HttpContext.Items);
|
||||
if (userType != UserType.Customer && userType != UserType.HeadOffice)
|
||||
return true;
|
||||
return await UserBiz.CustomerContactOwnsCustomerAsync(ct, UserIdFromContext.Id(HttpContext.Items), customerId);
|
||||
}
|
||||
|
||||
//case 4712: same for an existing service request (true if it does not exist, so the normal not found applies)
|
||||
private async Task<bool> ContactMayUseExistingAsync(long id)
|
||||
{
|
||||
var userType = UserTypeFromContext.Type(HttpContext.Items);
|
||||
if (userType != UserType.Customer && userType != UserType.HeadOffice)
|
||||
return true;
|
||||
var customerId = await ct.CustomerServiceRequest.AsNoTracking().Where(z => z.Id == id).Select(z => (long?)z.CustomerId).FirstOrDefaultAsync();
|
||||
return customerId == null || await UserBiz.CustomerContactOwnsCustomerAsync(ct, UserIdFromContext.Id(HttpContext.Items), (long)customerId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ namespace AyaNova.Api.Controllers
|
||||
log.LogDebug($"Instantiating biz object handler for {selectedRequest.AType}");
|
||||
var biz = BizObjectFactory.GetBizObject(selectedRequest.AType, ct, UserId, UserRoles, UserTranslationId);
|
||||
log.LogDebug($"Fetching data for {selectedRequest.SelectedRowIds.Length} {selectedRequest.AType} items");
|
||||
string baseFileName = FileUtil.StringToSafeFileName($"{selectedRequest.AType.ToString().ToLowerInvariant()}-{FileUtil.GetSafeDateFileName()}");
|
||||
//case 4727 (F25): the name carries the user's id, so only that user can download it
|
||||
string baseFileName = FileUtil.StringToSafeFileName($"{selectedRequest.AType.ToString().ToLowerInvariant()}-u{UserId}-{FileUtil.GetSafeDateFileName()}");
|
||||
string outputSourceFileName = baseFileName + ".json";
|
||||
string outputSourceFullPath = System.IO.Path.Combine(FileUtil.TemporaryFilesFolder, outputSourceFileName);
|
||||
|
||||
@@ -130,13 +131,15 @@ namespace AyaNova.Api.Controllers
|
||||
if (!serverState.IsOpen)
|
||||
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
||||
|
||||
if (await UserBiz.ValidateDownloadTokenAndReturnUserAsync(t, ct) == null)
|
||||
var DownloadUser = await UserBiz.ValidateDownloadTokenAndReturnUserAsync(t, ct);
|
||||
if (DownloadUser == null)
|
||||
{
|
||||
await Task.Delay(ServerBootConfig.FAILED_AUTH_DELAY);//DOS protection
|
||||
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
||||
}
|
||||
|
||||
if (!FileUtil.TemporaryFileExists(fileName))
|
||||
//case 4727 (F25): only the caller's own export file (see the name in render), no other temporary file
|
||||
if (fileName == null || !fileName.Contains($"-u{DownloadUser.Id}-") || !fileName.EndsWith(".json") || !FileUtil.TemporaryFileExists(fileName))
|
||||
{
|
||||
await Task.Delay(ServerBootConfig.FAILED_AUTH_DELAY);//fishing protection
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
|
||||
@@ -109,6 +109,10 @@ namespace AyaNova.Api.Controllers
|
||||
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
||||
}
|
||||
|
||||
//case 4727 (F29): the DB ID and licensee go to staff only (Service, NotService)
|
||||
var userType = UserTypeFromContext.Type(HttpContext.Items);
|
||||
bool staff = userType == UserType.Service || userType == UserType.NotService;
|
||||
|
||||
bool IsSubscriptionBuild = false;
|
||||
|
||||
#if (SUBSCRIPTION_BUILD)
|
||||
@@ -136,8 +140,8 @@ namespace AyaNova.Api.Controllers
|
||||
//used to drive UI in case of unlicensed or attention required
|
||||
LicenseStatus = AyaNova.Core.License.ActiveKey.Status,
|
||||
MaintenanceExpired = AyaNova.Core.License.ActiveKey.MaintenanceExpired,
|
||||
ServerDbId = AyaNova.Core.License.ServerDbId,
|
||||
Company = AyaNova.Core.License.ActiveKey.RegisteredTo,
|
||||
ServerDbId = staff ? AyaNova.Core.License.ServerDbId : string.Empty,
|
||||
Company = staff ? AyaNova.Core.License.ActiveKey.RegisteredTo : string.Empty,
|
||||
SBuild = IsSubscriptionBuild,
|
||||
//used to drive UI in case of out of date release message to be shown to staff users non subscription only
|
||||
ShowUpdateAvailable = (Util.AyaNovaVersion.VersionString != AyaNova.Util.ServerGlobalOpsSettingsCache.LATEST_VERSION && !IsSubscriptionBuild),
|
||||
|
||||
@@ -79,9 +79,15 @@ namespace AyaNova.Api.Controllers
|
||||
GlobalOpsBackupSettingsBiz biz = GlobalOpsBackupSettingsBiz.GetBiz(ct, HttpContext);
|
||||
if (!Authorized.HasModifyRole(HttpContext.Items, biz.BizType))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
var o = await biz.PutAsync(updatedObject);
|
||||
var o = await biz.PutAsync(updatedObject);
|
||||
if (o == null)
|
||||
return StatusCode(409, new ApiErrorResponse(biz.Errors));
|
||||
{
|
||||
//case 4763: the settings can now fail validation, which is not a concurrency conflict
|
||||
if (biz.Errors.Exists(z => z.Code == ApiErrorCode.CONCURRENCY_CONFLICT))
|
||||
return StatusCode(409, new ApiErrorResponse(biz.Errors));
|
||||
else
|
||||
return BadRequest(new ApiErrorResponse(biz.Errors));
|
||||
}
|
||||
return Ok(ApiOkResponse.Response(new { Concurrency = o.Concurrency }));
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,10 @@ namespace AyaNova.Api.Controllers
|
||||
if (o == null)
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
|
||||
//case 4724: the SMTP password never goes to the client (detached first so the mask can't be saved)
|
||||
ct.Entry(o).State = EntityState.Detached;
|
||||
o.SmtpPassword = GlobalOpsNotificationSettingsBiz.SMTP_PASSWORD_MASK;
|
||||
|
||||
return Ok(ApiOkResponse.Response(o));
|
||||
}
|
||||
|
||||
|
||||
@@ -69,9 +69,10 @@ namespace AyaNova.Api.Controllers
|
||||
//stream the file contents into a json object and return
|
||||
|
||||
//build the full path from the log file name and defined path
|
||||
var logFilePath = System.IO.Path.Combine(ServerBootConfig.AYANOVA_LOG_PATH, logname);
|
||||
//case 4715: only a bare file name inside the log folder
|
||||
var logFilePath = FileUtil.FullPathInFolderOrNull(ServerBootConfig.AYANOVA_LOG_PATH, logname);
|
||||
//does file exist?
|
||||
if (!System.IO.File.Exists(logFilePath))
|
||||
if (logFilePath == null || !System.IO.File.Exists(logFilePath))
|
||||
{
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
}
|
||||
@@ -149,8 +150,9 @@ namespace AyaNova.Api.Controllers
|
||||
if (!Authorized.HasReadFullRole(user.Roles, AyaType.LogFile))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
|
||||
var logFilePath = System.IO.Path.Combine(ServerBootConfig.AYANOVA_LOG_PATH, logname);
|
||||
if (!System.IO.File.Exists(logFilePath))
|
||||
//case 4715: only a bare file name inside the log folder
|
||||
var logFilePath = FileUtil.FullPathInFolderOrNull(ServerBootConfig.AYANOVA_LOG_PATH, logname);
|
||||
if (logFilePath == null || !System.IO.File.Exists(logFilePath))
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
|
||||
//Note: this works with new Nlog file locking for write so it must be opening it as read and shareable
|
||||
|
||||
@@ -82,21 +82,49 @@ namespace AyaNova.Api.Controllers
|
||||
case "small":
|
||||
if (logo.Small == null)
|
||||
return NotFound();
|
||||
return new FileStreamResult(new MemoryStream(logo.Small), Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse(logo.SmallType));
|
||||
return LogoFile(logo.Small);
|
||||
|
||||
case "medium":
|
||||
if (logo.Medium == null)
|
||||
return NotFound();
|
||||
return new FileStreamResult(new MemoryStream(logo.Medium), Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse(logo.MediumType));
|
||||
return LogoFile(logo.Medium);
|
||||
|
||||
case "large":
|
||||
if (logo.Large == null)
|
||||
return NotFound();
|
||||
return new FileStreamResult(new MemoryStream(logo.Large), Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse(logo.LargeType));
|
||||
return LogoFile(logo.Large);
|
||||
}
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
}
|
||||
|
||||
//case 4727 (F23): the type comes from the bytes, never from what was sent or stored, and the response may not run
|
||||
//script: an SVG opened directly by its URL is shown, its script is not run (an <img> tag is not affected)
|
||||
private FileStreamResult LogoFile(byte[] bytes)
|
||||
{
|
||||
Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
|
||||
return new FileStreamResult(new MemoryStream(bytes), ImageTypeOrNull(bytes) ?? "application/octet-stream");
|
||||
}
|
||||
|
||||
//case 4727 (F23): PNG, JPEG, GIF, WebP or SVG, recognised by their content; null for anything else
|
||||
internal static string ImageTypeOrNull(byte[] b)
|
||||
{
|
||||
if (b == null || b.Length < 12)
|
||||
return null;
|
||||
if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47 && b[4] == 0x0D && b[5] == 0x0A && b[6] == 0x1A && b[7] == 0x0A)
|
||||
return "image/png";
|
||||
if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF)
|
||||
return "image/jpeg";
|
||||
if (b[0] == 'G' && b[1] == 'I' && b[2] == 'F' && b[3] == '8' && (b[4] == '7' || b[4] == '9') && b[5] == 'a')
|
||||
return "image/gif";
|
||||
if (b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' && b[8] == 'W' && b[9] == 'E' && b[10] == 'B' && b[11] == 'P')
|
||||
return "image/webp";
|
||||
var head = System.Text.Encoding.UTF8.GetString(b, 0, System.Math.Min(b.Length, 4096)).TrimStart((char)0xFEFF, ' ', '\t', '\r', '\n');
|
||||
if (head.StartsWith("<") && head.Contains("<svg", System.StringComparison.OrdinalIgnoreCase))
|
||||
return "image/svg+xml";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
@@ -118,7 +146,8 @@ namespace AyaNova.Api.Controllers
|
||||
if (!serverState.IsOpen)
|
||||
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
||||
|
||||
if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.Global))
|
||||
//case 4727 (F23): change rights (was read rights, which BizAdminRestricted has)
|
||||
if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.Global))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
|
||||
if (string.IsNullOrWhiteSpace(size))
|
||||
@@ -145,19 +174,22 @@ namespace AyaNova.Api.Controllers
|
||||
await file.CopyToAsync(memoryStream);
|
||||
if (memoryStream.Length > ServerBootConfig.MAX_LOGO_UPLOAD_BYTES)
|
||||
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_LENGTH_EXCEEDED, null, $"Logo files must be smaller than {ServerBootConfig.MAX_LOGO_UPLOAD_BYTES} maximum"));
|
||||
var imageType = ImageTypeOrNull(memoryStream.ToArray());
|
||||
if (imageType == null)
|
||||
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, null, "Logo files must be PNG, JPEG, GIF, WebP or SVG images"));
|
||||
switch (size)
|
||||
{
|
||||
case "small":
|
||||
logo.Small = memoryStream.ToArray();
|
||||
logo.SmallType = file.ContentType;
|
||||
logo.SmallType = imageType;
|
||||
break;
|
||||
case "medium":
|
||||
logo.Medium = memoryStream.ToArray();
|
||||
logo.MediumType = file.ContentType;
|
||||
logo.MediumType = imageType;
|
||||
break;
|
||||
case "large":
|
||||
logo.Large = memoryStream.ToArray();
|
||||
logo.LargeType = file.ContentType;
|
||||
logo.LargeType = imageType;
|
||||
break;
|
||||
|
||||
}
|
||||
@@ -179,7 +211,8 @@ namespace AyaNova.Api.Controllers
|
||||
if (!serverState.IsOpen)
|
||||
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
||||
|
||||
if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.Global))
|
||||
//case 4727 (F23): change rights (was read rights, which BizAdminRestricted has)
|
||||
if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.Global))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
|
||||
if (string.IsNullOrWhiteSpace(size))
|
||||
|
||||
@@ -51,7 +51,8 @@ namespace AyaNova.Api.Controllers
|
||||
if (AyaNova.Core.License.ActiveKey.Status == AyaNova.Core.License.AyaNovaLicenseKey.LicenseStatus.ActiveTrial)
|
||||
showSampleLogins = await AyaNova.Util.DbUtil.DBHasTrialUsersAsync(ct, log);
|
||||
|
||||
bool suIsDefault = await UserBiz.SuperIsDefaultCredsAsync(ct);
|
||||
//case 4725: the default SuperUser login is offered only while unlicensed or in trial
|
||||
bool suIsDefault = !AyaNova.Core.License.PurchasedLicense() && await UserBiz.SuperIsDefaultCredsAsync(ct);
|
||||
//confirm if there are logo's to show as well
|
||||
var logo = await ct.Logo.AsNoTracking().SingleOrDefaultAsync();
|
||||
|
||||
@@ -204,6 +205,10 @@ namespace AyaNova.Api.Controllers
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
|
||||
//case 4723: staff only, as in the client (no customer contacts or subcontractors)
|
||||
var userType = UserTypeFromContext.Type(HttpContext.Items);
|
||||
if (userType == UserType.Customer || userType == UserType.HeadOffice || userType == UserType.ServiceContractor)
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
|
||||
foreach (long l in notifyDirectMessage.Users)
|
||||
{
|
||||
@@ -247,11 +252,13 @@ namespace AyaNova.Api.Controllers
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
|
||||
if (!ServerGlobalOpsSettingsCache.Notify.SmtpDeliveryActive)
|
||||
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_MISSING_PROPERTY, null, "Email notifications are set to OFF at server, unable to send 'on request' type SMTP notification"));
|
||||
|
||||
//for now I'm allowing any authenticated user to call this route intentionally as the use case is for our own internal messaging from the Customer form only or for API users who want to send
|
||||
//email when rendering a report (case 4310).
|
||||
//Staff users may send to any address: the use case is our own messaging from the Customer form etc and
|
||||
//API users and report scripts that send email when rendering a report (case 4310).
|
||||
//case 4723: customer contacts may not use this route at all; an address taken from a record
|
||||
//needs read rights on that record
|
||||
var userType = UserTypeFromContext.Type(HttpContext.Items);
|
||||
if (userType == UserType.Customer || userType == UserType.HeadOffice)
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
|
||||
//validate incoming data
|
||||
if (string.IsNullOrWhiteSpace(notifyDirectSMTP.Subject))
|
||||
@@ -275,6 +282,8 @@ namespace AyaNova.Api.Controllers
|
||||
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_MISSING_PROPERTY, "ToAddress", "No address or object type and id specified no where to send this"));
|
||||
case AyaType.Customer:
|
||||
{
|
||||
if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.Customer))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
var o = await ct.Customer.AsNoTracking().Where(x => x.Id == notifyDirectSMTP.ObjectId).Select(x => new { x.Name, x.EmailAddress, x.Active }).FirstOrDefaultAsync();
|
||||
if (o == null)
|
||||
{
|
||||
@@ -289,6 +298,8 @@ namespace AyaNova.Api.Controllers
|
||||
break;
|
||||
case AyaType.HeadOffice:
|
||||
{
|
||||
if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.HeadOffice))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
var o = await ct.HeadOffice.AsNoTracking().Where(x => x.Id == notifyDirectSMTP.ObjectId).Select(x => new { x.Name, x.EmailAddress, x.Active }).FirstOrDefaultAsync();
|
||||
if (o == null)
|
||||
{
|
||||
@@ -303,6 +314,8 @@ namespace AyaNova.Api.Controllers
|
||||
break;
|
||||
case AyaType.Vendor:
|
||||
{
|
||||
if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.Vendor))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
var o = await ct.Vendor.AsNoTracking().Where(x => x.Id == notifyDirectSMTP.ObjectId).Select(x => new { x.Name, x.EmailAddress, x.Active }).FirstOrDefaultAsync();
|
||||
if (o == null)
|
||||
{
|
||||
@@ -317,15 +330,20 @@ namespace AyaNova.Api.Controllers
|
||||
break;
|
||||
case AyaType.User:
|
||||
{
|
||||
var o = await ct.User.Include(z => z.UserOptions).AsNoTracking().Where(x => x.Id == notifyDirectSMTP.ObjectId).Select(x => new { x.Name, x.UserOptions.EmailAddress, x.Active }).FirstOrDefaultAsync();
|
||||
var o = await ct.User.Include(z => z.UserOptions).AsNoTracking().Where(x => x.Id == notifyDirectSMTP.ObjectId).Select(x => new { x.Name, x.UserOptions.EmailAddress, x.Active, x.UserType }).FirstOrDefaultAsync();
|
||||
if (o == null)
|
||||
{
|
||||
return BadRequest(new ApiErrorResponse(ApiErrorCode.NOT_FOUND, "ObjectId"));
|
||||
}
|
||||
//a customer contact's user record is read with Customer rights, as on the Contact form
|
||||
bool isContact = o.UserType == UserType.Customer || o.UserType == UserType.HeadOffice;
|
||||
if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.User) && !(isContact && Authorized.HasReadFullRole(HttpContext.Items, AyaType.Customer)))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
//case 4762: these said "Vendor"
|
||||
if (string.IsNullOrWhiteSpace(o.EmailAddress))
|
||||
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_MISSING_PROPERTY, "EmailAddress", $"Vendor {o.Name} doesn't have an email address no where to send this"));
|
||||
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_MISSING_PROPERTY, "EmailAddress", $"User {o.Name} doesn't have an email address no where to send this"));
|
||||
if (o.Active == false)
|
||||
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "Active", $"Vendor {o.Name} is not active, only active Vendors can be emailed directly"));
|
||||
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "Active", $"User {o.Name} is not active, only active users can be emailed directly"));
|
||||
notifyDirectSMTP.ToAddress = o.EmailAddress;
|
||||
}
|
||||
break;
|
||||
@@ -335,6 +353,10 @@ namespace AyaNova.Api.Controllers
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (!ServerGlobalOpsSettingsCache.Notify.SmtpDeliveryActive)
|
||||
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_MISSING_PROPERTY, null, "Email notifications are set to OFF at server, unable to send 'on request' type SMTP notification"));
|
||||
|
||||
var UserId = UserIdFromContext.Id(HttpContext.Items);
|
||||
|
||||
IMailer m = AyaNova.Util.ServiceProviderProvider.Mailer;
|
||||
|
||||
@@ -75,6 +75,13 @@ namespace AyaNova.Api.Controllers
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
|
||||
|
||||
//case 4712: customer contacts get only the pick lists of the service request screen, limited to their own records
|
||||
var contactVariant = await ContactPickListVariantAsync(pickListParams.AyaType, pickListParams.ListVariant);
|
||||
if (contactVariant == CONTACT_DENIED)
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
if (contactVariant != null)
|
||||
pickListParams.ListVariant = contactVariant;
|
||||
|
||||
//Instantiate the business object handler
|
||||
PickListBiz biz = PickListBiz.GetBiz(ct, HttpContext);
|
||||
|
||||
@@ -122,6 +129,10 @@ namespace AyaNova.Api.Controllers
|
||||
if (!Authorized.HasSelectRole(HttpContext.Items, pickListSingleParams.AyaType))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
|
||||
//case 4712: a customer contact gets names only of its own customers, their units, and unit models
|
||||
if (!await ContactMayNameRecordAsync(pickListSingleParams.AyaType, pickListSingleParams.Id))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
|
||||
//Instantiate the business object handler
|
||||
PickListBiz biz = PickListBiz.GetBiz(ct, HttpContext);
|
||||
|
||||
@@ -276,9 +287,68 @@ namespace AyaNova.Api.Controllers
|
||||
}
|
||||
|
||||
|
||||
//case 4712: marker for a pick list a customer contact may not use
|
||||
private const string CONTACT_DENIED = "\u0000contact-denied";
|
||||
|
||||
//case 4712: for a customer contact, the variant that limits a pick list to its own records:
|
||||
//null when the caller is not a contact (request used as is), CONTACT_DENIED for any list the
|
||||
//customer screens do not use or a customer the contact does not own
|
||||
private async Task<string> ContactPickListVariantAsync(AyaType aType, string requestedVariant)
|
||||
{
|
||||
var userType = UserTypeFromContext.Type(HttpContext.Items);
|
||||
if (userType != UserType.Customer && userType != UserType.HeadOffice)
|
||||
return null;
|
||||
var userId = UserIdFromContext.Id(HttpContext.Items);
|
||||
var u = await ct.User.AsNoTracking().Where(x => x.Id == userId).Select(x => new { x.CustomerId, x.HeadOfficeId }).SingleOrDefaultAsync();
|
||||
long ownCustomerId = u?.CustomerId ?? 0;
|
||||
long ownHeadOfficeId = u?.HeadOfficeId ?? 0;
|
||||
switch (aType)
|
||||
{
|
||||
case AyaType.Customer:
|
||||
if (userType == UserType.Customer)
|
||||
return ownCustomerId == 0 ? CONTACT_DENIED : $"{ownCustomerId},{(int)AyaType.Customer}";
|
||||
return ownHeadOfficeId == 0 ? CONTACT_DENIED : $"{ownHeadOfficeId},{(int)AyaType.HeadOffice}";
|
||||
case AyaType.Unit:
|
||||
if (userType == UserType.Customer)
|
||||
return ownCustomerId == 0 ? CONTACT_DENIED : $"customerid:{ownCustomerId}";
|
||||
//head office contact: units of the customer chosen on the form, which must be one of its customers;
|
||||
//none until a customer is chosen
|
||||
var v = (requestedVariant ?? string.Empty).Split(':');
|
||||
long requestedCustomerId = 0;
|
||||
if (v.Length == 2 && v[0].Trim() == "customerid")
|
||||
long.TryParse(v[1].Trim(), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out requestedCustomerId);
|
||||
if (requestedCustomerId == 0)
|
||||
return "customerid:-1";
|
||||
return await UserBiz.CustomerContactOwnsCustomerAsync(ct, userId, requestedCustomerId) ? $"customerid:{requestedCustomerId}" : CONTACT_DENIED;
|
||||
case AyaType.UnitModel:
|
||||
return requestedVariant;//unit models are not customer data
|
||||
default:
|
||||
return CONTACT_DENIED;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//case 4712: may the caller see this record's pick list name (always true for staff)
|
||||
private async Task<bool> ContactMayNameRecordAsync(AyaType aType, long id)
|
||||
{
|
||||
var userType = UserTypeFromContext.Type(HttpContext.Items);
|
||||
if (userType != UserType.Customer && userType != UserType.HeadOffice)
|
||||
return true;
|
||||
var userId = UserIdFromContext.Id(HttpContext.Items);
|
||||
switch (aType)
|
||||
{
|
||||
case AyaType.Customer:
|
||||
return id == 0 || await UserBiz.CustomerContactOwnsCustomerAsync(ct, userId, id);
|
||||
case AyaType.Unit:
|
||||
if (id == 0)
|
||||
return true;
|
||||
var unitCustomerId = await ct.Unit.AsNoTracking().Where(x => x.Id == id).Select(x => (long?)x.CustomerId).FirstOrDefaultAsync();
|
||||
return unitCustomerId != null && await UserBiz.CustomerContactOwnsCustomerAsync(ct, userId, (long)unitCustomerId);
|
||||
case AyaType.UnitModel:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}//eoc
|
||||
}//ens
|
||||
@@ -76,6 +76,8 @@ namespace AyaNova.Api.Controllers
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
var o = await biz.GetAsync(id);
|
||||
if (o == null && !biz.HasErrors)
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
if (o == null)
|
||||
return BadRequest(new ApiErrorResponse(biz.Errors));
|
||||
return Ok(ApiOkResponse.Response(o));
|
||||
@@ -145,6 +147,8 @@ namespace AyaNova.Api.Controllers
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
var o = await biz.GetAsync(id);
|
||||
if (o == null && !biz.HasErrors)
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
if (o == null)
|
||||
return BadRequest(new ApiErrorResponse(biz.Errors));
|
||||
return Ok(ApiOkResponse.Response(new
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using AyaNova.Models;
|
||||
using AyaNova.Api.ControllerHelpers;
|
||||
using AyaNova.Biz;
|
||||
using EnumsNET;
|
||||
using AyaNova.Util;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
@@ -213,7 +214,9 @@ namespace AyaNova.Api.Controllers
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
var httpConnectionFeature = HttpContext.Features.Get<IHttpConnectionFeature>();
|
||||
var API_URL = $"http://127.0.0.1:{httpConnectionFeature.LocalPort}/api/{AyaNovaVersion.CurrentApiVersion}/";
|
||||
var result = await biz.RequestRenderReport(reportRequest, DateTime.UtcNow.AddMinutes(ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT), API_URL, UserNameFromContext.Name(HttpContext.Items));
|
||||
//case 4632: inside IIS the report page's API calls are relayed to where this request arrived
|
||||
var relayTarget = ServerBootConfig.HOSTED_IN_IIS ? ReportApiRelay.FromRequest(HttpContext) : null;
|
||||
var result = await biz.RequestRenderReport(reportRequest, DateTime.UtcNow.AddMinutes(ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT), API_URL, UserNameFromContext.Name(HttpContext.Items), relayTarget);
|
||||
if (result == null)
|
||||
return BadRequest(new ApiErrorResponse(biz.Errors));
|
||||
else
|
||||
@@ -288,7 +291,8 @@ namespace AyaNova.Api.Controllers
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
|
||||
if (await UserBiz.ValidateDownloadTokenAndReturnUserAsync(t, ct) == null)
|
||||
var DownloadUser = await UserBiz.ValidateDownloadTokenAndReturnUserAsync(t, ct);
|
||||
if (DownloadUser == null)
|
||||
{
|
||||
await Task.Delay(ServerBootConfig.FAILED_AUTH_DELAY);//DOS protection
|
||||
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
||||
@@ -300,6 +304,10 @@ namespace AyaNova.Api.Controllers
|
||||
{
|
||||
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
||||
}
|
||||
|
||||
//case 4727 (F22): only a template the user may use (its Roles) or may edit
|
||||
if (!DownloadUser.Roles.HasAnyFlags(o.Roles) && !Authorized.HasModifyRole(DownloadUser.Roles, AyaType.Report))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
var asText = Newtonsoft.Json.JsonConvert.SerializeObject(
|
||||
o,
|
||||
Newtonsoft.Json.Formatting.None,
|
||||
@@ -329,6 +337,9 @@ namespace AyaNova.Api.Controllers
|
||||
if (!serverState.IsOpen)
|
||||
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
||||
|
||||
//case 4718: importing a template creates one, so it needs the same right as creating one (checked before the upload is read)
|
||||
if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.Report))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
|
||||
// AyaTypeId attachToObject = null;
|
||||
ApiUploadProcessor.ApiUploadedFilesResult uploadFormData = null;
|
||||
|
||||
@@ -60,6 +60,8 @@ namespace AyaNova.Api.Controllers
|
||||
{
|
||||
if (!serverState.IsOpen)
|
||||
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
||||
if (!CanSeeServiceSchedule())
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
|
||||
@@ -152,6 +154,8 @@ namespace AyaNova.Api.Controllers
|
||||
{
|
||||
if (!serverState.IsOpen)
|
||||
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
||||
if (!CanSeeServiceSchedule())
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
|
||||
@@ -460,6 +464,15 @@ namespace AyaNova.Api.Controllers
|
||||
return v.Serial.ToString() + " " + v.CustomerName;
|
||||
}
|
||||
|
||||
//case 4712: the service schedule (and its per user drill down) needs the same rights the client
|
||||
//uses to show the Schedule menu: open work orders, quotes or PMs
|
||||
private bool CanSeeServiceSchedule()
|
||||
{
|
||||
return Authorized.HasReadFullRole(HttpContext.Items, AyaType.WorkOrder)
|
||||
|| Authorized.HasReadFullRole(HttpContext.Items, AyaType.Quote)
|
||||
|| Authorized.HasReadFullRole(HttpContext.Items, AyaType.PM);
|
||||
}
|
||||
|
||||
|
||||
public enum ScheduleWorkOrderColorSource : int
|
||||
{
|
||||
|
||||
@@ -193,6 +193,8 @@ namespace AyaNova.Api.Controllers
|
||||
AYANOVA_LOG_PATH = ServerBootConfig.AYANOVA_LOG_PATH,
|
||||
AYANOVA_LOG_LEVEL = ServerBootConfig.AYANOVA_LOG_LEVEL,
|
||||
AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG = ServerBootConfig.AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG,
|
||||
AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE = ServerBootConfig.AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE,
|
||||
AYANOVA_TRUSTED_PROXIES = ServerBootConfig.AYANOVA_TRUSTED_PROXIES,
|
||||
serverinfo = ServerBootConfig.BOOT_DIAGNOSTIC_INFO,
|
||||
dbserverinfo = ServerBootConfig.DBSERVER_DIAGNOSTIC_INFO
|
||||
}));
|
||||
@@ -315,6 +317,8 @@ namespace AyaNova.Api.Controllers
|
||||
sbRet.AppendLine($"AYANOVA_LOG_PATH: {ServerBootConfig.AYANOVA_LOG_PATH}");
|
||||
sbRet.AppendLine($"AYANOVA_LOG_LEVEL: {ServerBootConfig.AYANOVA_LOG_LEVEL}");
|
||||
sbRet.AppendLine($"AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG: {ServerBootConfig.AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG}");
|
||||
sbRet.AppendLine($"AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE: {ServerBootConfig.AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE}");
|
||||
sbRet.AppendLine($"AYANOVA_TRUSTED_PROXIES: {ServerBootConfig.AYANOVA_TRUSTED_PROXIES}");
|
||||
|
||||
sbRet.AppendLine("#########################################################");
|
||||
sbRet.AppendLine("SERVER DIAGNOSTICS");
|
||||
|
||||
@@ -326,6 +326,9 @@ namespace AyaNova.Api.Controllers
|
||||
if (!serverState.IsOpen)
|
||||
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
||||
|
||||
//case 4718: importing a translation creates one, so it needs the same right as creating one (checked before the upload is read)
|
||||
if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.Translation))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
|
||||
// AyaTypeId attachToObject = null;
|
||||
ApiUploadProcessor.ApiUploadedFilesResult uploadFormData = null;
|
||||
|
||||
@@ -80,6 +80,9 @@ namespace AyaNova.Api.Controllers
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
//case 4712: a contact adds units only to its own customer (or, head office contact, one of its customers)
|
||||
if (!await UserBiz.CustomerContactOwnsCustomerAsync(ct, biz.UserId, newObject.CustomerId))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
long? o = await biz.CustomerCreateAsync(newObject);
|
||||
if (o == null)
|
||||
return BadRequest(new ApiErrorResponse(biz.Errors));
|
||||
@@ -394,7 +397,8 @@ namespace AyaNova.Api.Controllers
|
||||
if (!serverState.IsOpen)
|
||||
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
||||
UnitMeterReadingBiz biz = UnitMeterReadingBiz.GetBiz(ct, HttpContext);
|
||||
if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.Unit))
|
||||
//case 4735: meter reading rights (were the Unit rights, which let Accounting enter meter readings)
|
||||
if (!Authorized.HasCreateRole(HttpContext.Items, biz.BizType))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
@@ -416,7 +420,8 @@ namespace AyaNova.Api.Controllers
|
||||
if (!serverState.IsOpen)
|
||||
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
||||
UnitMeterReadingBiz biz = UnitMeterReadingBiz.GetBiz(ct, HttpContext);
|
||||
if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.Unit))
|
||||
//case 4735: meter reading rights (were the Unit rights, which let Accounting enter meter readings)
|
||||
if (!Authorized.HasReadFullRole(HttpContext.Items, biz.BizType))
|
||||
return StatusCode(403, new ApiNotAuthorizedResponse());
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(new ApiErrorResponse(ModelState));
|
||||
|
||||
@@ -416,20 +416,20 @@ namespace AyaNova.DataList
|
||||
dtAfter = ClientToday.AddDays(-1);
|
||||
dtAfter = dtAfter.AddSeconds(-1);
|
||||
dtBefore = ClientToday;
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*today*":
|
||||
//Between yesterday at midnight and tommorow at midnight
|
||||
dtAfter = ClientToday.AddSeconds(-1);
|
||||
dtBefore = ClientToday.AddDays(1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
case "*tomorrow*":
|
||||
//Between Tonight at midnight and day after tommorow at midnight
|
||||
dtAfter = ClientToday.AddDays(1).AddSeconds(-1);
|
||||
dtBefore = ClientToday.AddDays(2);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
case "*lastweek*":
|
||||
//Between two Sundays ago at midnight and last sunday at midnight
|
||||
@@ -442,7 +442,7 @@ namespace AyaNova.DataList
|
||||
//go to very start of eighth dayahead
|
||||
dtBefore = dtAfter.AddDays(8);
|
||||
dtAfter = dtAfter.AddSeconds(-1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
case "*thisweek*":
|
||||
//Between Sunday at midnight and Next sunday at midnight
|
||||
@@ -467,7 +467,7 @@ namespace AyaNova.DataList
|
||||
dtBefore = dtBefore.AddDays(1);
|
||||
}
|
||||
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
case "*nextweek*":
|
||||
//Between Next Sunday at midnight and Next Next sunday at midnight
|
||||
@@ -486,7 +486,7 @@ namespace AyaNova.DataList
|
||||
dtBefore = dtAfter.AddDays(7);
|
||||
dtAfter = dtAfter.AddSeconds(-1);
|
||||
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
case "*lastmonth*":
|
||||
//start with the first day of this month
|
||||
@@ -500,7 +500,7 @@ namespace AyaNova.DataList
|
||||
//case 1155
|
||||
dtAfter = dtAfter.AddSeconds(-1);
|
||||
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
case "*thismonth*":
|
||||
//start with the first day of this month
|
||||
@@ -512,7 +512,7 @@ namespace AyaNova.DataList
|
||||
//case 1155
|
||||
dtAfter = dtAfter.AddSeconds(-1);
|
||||
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*nextmonth*":
|
||||
@@ -526,7 +526,7 @@ namespace AyaNova.DataList
|
||||
|
||||
//case 1155
|
||||
dtAfter = dtAfter.AddSeconds(-1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*14daywindow*":
|
||||
@@ -540,21 +540,21 @@ namespace AyaNova.DataList
|
||||
//case 1155
|
||||
dtAfter = dtAfter.AddSeconds(-1);
|
||||
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*past*":
|
||||
//Forever up to Now
|
||||
dtAfter = new DateTime(1753, 1, 2);//this was for sql server but even then was probably outdated good enough though for our purposes
|
||||
dtBefore = ClientNow;
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*future*":
|
||||
//From Now to forever (999 years from now)
|
||||
dtAfter = ClientNow;
|
||||
dtBefore = dtAfter.AddYears(999);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*lastyear*":
|
||||
@@ -562,7 +562,7 @@ namespace AyaNova.DataList
|
||||
dtAfter = new DateTime(ClientNow.AddYears(-1).Year, 1, 1, 0, 0, 00).AddSeconds(-1);
|
||||
//To zero hour January 1 this year
|
||||
dtBefore = new DateTime(ClientNow.Year, 1, 1, 0, 0, 00);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*thisyear*":
|
||||
@@ -570,7 +570,7 @@ namespace AyaNova.DataList
|
||||
dtAfter = new DateTime(ClientNow.Year, 1, 1).AddSeconds(-1);
|
||||
//To zero hour Jan 1 next year
|
||||
dtBefore = new DateTime(ClientNow.AddYears(1).Year, 1, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*last3months*":
|
||||
@@ -578,7 +578,7 @@ namespace AyaNova.DataList
|
||||
dtAfter = ClientNow.AddMonths(-3).AddSeconds(-1);
|
||||
//To Now
|
||||
dtBefore = ClientNow;
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*last6months*":
|
||||
@@ -586,7 +586,7 @@ namespace AyaNova.DataList
|
||||
dtAfter = ClientNow.AddMonths(-6).AddSeconds(-1);
|
||||
//To Now
|
||||
dtBefore = ClientNow;
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*pastyear*": //within the prior 365 days before today
|
||||
@@ -594,7 +594,7 @@ namespace AyaNova.DataList
|
||||
dtAfter = ClientNow.AddDays(-365).AddSeconds(-1);
|
||||
//To Now
|
||||
dtBefore = ClientNow;
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*past90days*":
|
||||
@@ -602,21 +602,21 @@ namespace AyaNova.DataList
|
||||
dtAfter = ClientNow.AddDays(-90).AddSeconds(-1);
|
||||
//To Now
|
||||
dtBefore = ClientNow;
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
case "*past30days*":
|
||||
//From Now minus 30 days
|
||||
dtAfter = ClientNow.AddDays(-30).AddSeconds(-1);
|
||||
//To Now
|
||||
dtBefore = ClientNow;
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
case "*past7days*":
|
||||
//From Now minus 7 days
|
||||
dtAfter = ClientNow.AddDays(-7).AddSeconds(-1);
|
||||
//To Now
|
||||
dtBefore = ClientNow;
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*past24hours*":
|
||||
@@ -624,7 +624,7 @@ namespace AyaNova.DataList
|
||||
dtAfter = ClientNow.AddHours(-24).AddSeconds(-1);
|
||||
//To Now
|
||||
dtBefore = ClientNow;
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*past6hours*":
|
||||
@@ -632,7 +632,7 @@ namespace AyaNova.DataList
|
||||
dtAfter = ClientNow.AddHours(-6).AddSeconds(-1);
|
||||
//To Now
|
||||
dtBefore = ClientNow;
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*january*":
|
||||
@@ -640,67 +640,67 @@ namespace AyaNova.DataList
|
||||
dtAfter = new DateTime(ClientNow.Year, 1, 1).AddSeconds(-1);
|
||||
//To zero hour feb 1 this year
|
||||
dtBefore = new DateTime(ClientNow.Year, 2, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*february*":
|
||||
dtAfter = new DateTime(ClientNow.Year, 2, 1).AddSeconds(-1);
|
||||
dtBefore = new DateTime(ClientNow.Year, 3, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*march*":
|
||||
dtAfter = new DateTime(ClientNow.Year, 3, 1).AddSeconds(-1);
|
||||
dtBefore = new DateTime(ClientNow.Year, 4, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*april*":
|
||||
dtAfter = new DateTime(ClientNow.Year, 4, 1).AddSeconds(-1);
|
||||
dtBefore = new DateTime(ClientNow.Year, 5, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*may*":
|
||||
dtAfter = new DateTime(ClientNow.Year, 5, 1).AddSeconds(-1);
|
||||
dtBefore = new DateTime(ClientNow.Year, 6, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*june*":
|
||||
dtAfter = new DateTime(ClientNow.Year, 6, 1).AddSeconds(-1);
|
||||
dtBefore = new DateTime(ClientNow.Year, 7, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*july*":
|
||||
dtAfter = new DateTime(ClientNow.Year, 7, 1).AddSeconds(-1);
|
||||
dtBefore = new DateTime(ClientNow.Year, 8, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*august*":
|
||||
dtAfter = new DateTime(ClientNow.Year, 8, 1).AddSeconds(-1);
|
||||
dtBefore = new DateTime(ClientNow.Year, 9, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*september*":
|
||||
dtAfter = new DateTime(ClientNow.Year, 9, 1).AddSeconds(-1);
|
||||
dtBefore = new DateTime(ClientNow.Year, 10, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*october*":
|
||||
dtAfter = new DateTime(ClientNow.Year, 10, 1).AddSeconds(-1);
|
||||
dtBefore = new DateTime(ClientNow.Year, 11, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*november*":
|
||||
dtAfter = new DateTime(ClientNow.Year, 11, 1).AddSeconds(-1);
|
||||
dtBefore = new DateTime(ClientNow.Year, 12, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*december*":
|
||||
@@ -708,7 +708,7 @@ namespace AyaNova.DataList
|
||||
dtAfter = new DateTime(ClientNow.Year, 12, 1).AddSeconds(-1);
|
||||
//To zero hour Jan 1 next year
|
||||
dtBefore = new DateTime(ClientNow.AddYears(1).Year, 1, 1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
case "*lastyearlastmonth*":
|
||||
//start with the first day of this month
|
||||
@@ -718,7 +718,7 @@ namespace AyaNova.DataList
|
||||
//Add one month to dtAfter to get end date
|
||||
dtBefore = dtAfter.AddMonths(1);
|
||||
dtAfter = dtAfter.AddSeconds(-1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*lastyearthismonth*":
|
||||
@@ -729,7 +729,7 @@ namespace AyaNova.DataList
|
||||
//Add one month to dtAfter to get end date
|
||||
dtBefore = dtAfter.AddMonths(1);
|
||||
dtAfter = dtAfter.AddSeconds(-1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
case "*lastyearnextmonth*":
|
||||
@@ -742,7 +742,7 @@ namespace AyaNova.DataList
|
||||
//Add one month to dtAfter to get end date
|
||||
dtBefore = dtAfter.AddMonths(1);
|
||||
dtAfter = dtAfter.AddSeconds(-1);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore);
|
||||
BuildSQLBetweenTwoTokenDerivedDates(SqlColumnNameToFilter, sb, dtAfter, dtBefore, clientTimeStamp.Offset);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -1075,19 +1075,27 @@ namespace AyaNova.DataList
|
||||
//This is only used by the token date query code above and that code does NOT convert to UTC time to match the DB so this function will handle that
|
||||
//The other non tokenized date criteria builders are all working with dates that come from the client in UTC already and they don't use this method
|
||||
//so nothing required there
|
||||
private static void BuildSQLBetweenTwoTokenDerivedDates(string sColumn, StringBuilder sb, DateTime dtAfter, DateTime dtBefore)
|
||||
//case 4697: the token dates are the user's local times (from clientTimeStamp), so they are converted to UTC with the
|
||||
//user's offset; ToUniversalTime() used the server's time zone. A range that crosses a daylight saving change can be
|
||||
//an hour off at its far end, as the client sends only its current offset
|
||||
private static void BuildSQLBetweenTwoTokenDerivedDates(string sColumn, StringBuilder sb, DateTime dtAfter, DateTime dtBefore, TimeSpan clientOffset)
|
||||
{
|
||||
|
||||
sb.Append(">'");
|
||||
sb.Append(PostgresDateFormat(MaxSeconds(dtAfter.ToUniversalTime())));
|
||||
sb.Append(PostgresDateFormat(MaxSeconds(ClientLocalToUtc(dtAfter, clientOffset))));
|
||||
sb.Append("' AND ");
|
||||
sb.Append(sColumn);
|
||||
sb.Append(" ");
|
||||
sb.Append("<'");
|
||||
sb.Append(PostgresDateFormat(ZeroSeconds(dtBefore.ToUniversalTime())));
|
||||
sb.Append(PostgresDateFormat(ZeroSeconds(ClientLocalToUtc(dtBefore, clientOffset))));
|
||||
sb.Append("'");
|
||||
}
|
||||
|
||||
private static DateTime ClientLocalToUtc(DateTime clientLocal, TimeSpan clientOffset)
|
||||
{
|
||||
return DateTime.SpecifyKind(clientLocal - clientOffset, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
|
||||
private static DateTime ZeroSeconds(DateTime d)
|
||||
{
|
||||
|
||||
@@ -121,7 +121,7 @@ namespace AyaNova.PickList
|
||||
int nType = 0;
|
||||
if (!int.TryParse(crit[1], out nType)) return string.Empty;
|
||||
AyaType forType = (AyaType)nType;
|
||||
if (forType != AyaType.HeadOffice) return string.Empty;
|
||||
if (forType != AyaType.HeadOffice && forType != AyaType.Customer) return string.Empty;
|
||||
|
||||
long lId = 0;
|
||||
if (!long.TryParse(crit[0], out lId)) return string.Empty;
|
||||
@@ -134,6 +134,10 @@ namespace AyaNova.PickList
|
||||
{
|
||||
return $"acustomer.headofficeid = {lId}";
|
||||
}
|
||||
case AyaType.Customer://case 4712: one customer only (a customer contact's own)
|
||||
{
|
||||
return $"acustomer.id = {lId}";
|
||||
}
|
||||
}
|
||||
}
|
||||
return string.Empty;
|
||||
|
||||
@@ -56,6 +56,11 @@ namespace AyaNova.PickList
|
||||
|
||||
//GET DATA RETURN ROWS
|
||||
command.CommandText = q;
|
||||
//case 4716: search texts go in as parameters (same %text% match as before, wildcards typed by the user still apply)
|
||||
if (q.Contains("@plq"))
|
||||
command.Parameters.Add(new Npgsql.NpgsqlParameter("plq", $"%{autoCompleteQuery}%"));
|
||||
if (q.Contains("@pltag"))
|
||||
command.Parameters.Add(new Npgsql.NpgsqlParameter("pltag", $"%{tagSpecificQuery}%"));
|
||||
try
|
||||
{
|
||||
using (var dr = await command.ExecuteReaderAsync())
|
||||
|
||||
@@ -104,13 +104,15 @@ namespace AyaNova.PickList
|
||||
}
|
||||
}
|
||||
|
||||
//case 4716: the search texts are SQL parameters (@plq, @pltag), added by PickListFetcher, never part of the SQL text
|
||||
|
||||
//PROCESS TAG SPECIFIC QUERY
|
||||
//
|
||||
if (HasTagSpecificQuery)
|
||||
{
|
||||
//get the tag column
|
||||
AyaPickListFieldDefinition tagColumn = pickList.ColumnDefinitions.FirstOrDefault(z => z.ColumnDataType == UiFieldDataType.Tags);
|
||||
TagSpecificWhereFragment = $"(array_to_string({tagColumn.GetSqlValueColumnName()},',') like '%{tagSpecificQuery}%')";
|
||||
TagSpecificWhereFragment = $"(array_to_string({tagColumn.GetSqlValueColumnName()},',') like @pltag)";
|
||||
}
|
||||
|
||||
|
||||
@@ -150,9 +152,9 @@ namespace AyaNova.PickList
|
||||
if (HasAutoCompleteQuery && !HasTagSpecificQuery)
|
||||
{
|
||||
if (ServerGlobalBizSettings.Cache.FilterCaseSensitive)
|
||||
sWhere = $"(array_to_string({valueColumnName},',') like '%{autoCompleteQuery}%')";
|
||||
sWhere = $"(array_to_string({valueColumnName},',') like @plq)";
|
||||
else
|
||||
sWhere = $"(lower(array_to_string({valueColumnName},',')) like lower('%{autoCompleteQuery}%'))";
|
||||
sWhere = $"(lower(array_to_string({valueColumnName},',')) like lower(@plq))";
|
||||
|
||||
}
|
||||
|
||||
@@ -165,9 +167,9 @@ namespace AyaNova.PickList
|
||||
lOrderBy.Add(valueColumnName);
|
||||
if (HasAutoCompleteQuery)
|
||||
if (ServerGlobalBizSettings.Cache.FilterCaseSensitive)
|
||||
sWhere = $"({valueColumnName} like '%{autoCompleteQuery}%')";
|
||||
sWhere = $"({valueColumnName} like @plq)";
|
||||
else
|
||||
sWhere = $"(lower({valueColumnName}) like lower('%{autoCompleteQuery}%'))";
|
||||
sWhere = $"(lower({valueColumnName}) like lower(@plq))";
|
||||
|
||||
}
|
||||
else
|
||||
@@ -187,9 +189,9 @@ namespace AyaNova.PickList
|
||||
//(cast (aworkorder.serial as text) like '%some%')
|
||||
if (HasAutoCompleteQuery)
|
||||
if (ServerGlobalBizSettings.Cache.FilterCaseSensitive)
|
||||
sWhere = $"(cast ({valueColumnName} as text) like '%{autoCompleteQuery}%')";
|
||||
sWhere = $"(cast ({valueColumnName} as text) like @plq)";
|
||||
else
|
||||
sWhere = $"(lower(cast ({valueColumnName} as text)) like lower('%{autoCompleteQuery}%'))";
|
||||
sWhere = $"(lower(cast ({valueColumnName} as text)) like lower(@plq))";
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -139,8 +139,10 @@ namespace AyaNova.PickList
|
||||
switch (v[0])
|
||||
{
|
||||
case "customerid":
|
||||
if (string.IsNullOrWhiteSpace(v[1]) || v[1] == "0") return string.Empty;
|
||||
return $"aunit.customerid = {v[1]}";
|
||||
//case 4716: the id goes into the SQL, so it must be a number (anything else: no restriction, as for 0)
|
||||
if (!long.TryParse(v[1].Trim(), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out long customerId) || customerId == 0)
|
||||
return string.Empty;
|
||||
return $"aunit.customerid = {customerId}";
|
||||
|
||||
}
|
||||
return string.Empty;
|
||||
|
||||
@@ -228,6 +228,9 @@ namespace AyaNova
|
||||
if (ServerBootConfig.AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG)
|
||||
logger.Warn("AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG is enabled, this will cause the server to run very slowly");
|
||||
|
||||
if (ServerBootConfig.AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE)
|
||||
logger.Warn("AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE is enabled, the SMTP server's certificate is not checked");
|
||||
|
||||
|
||||
//Log environmental settings
|
||||
long UtilityFilesAvailableSpace = 0;
|
||||
|
||||
@@ -40,6 +40,7 @@ namespace AyaNova
|
||||
}
|
||||
|
||||
private readonly ILogger _newLog;
|
||||
private static int _untrustedProxyWarned = 0;
|
||||
private string _connectionString = "";
|
||||
private readonly Microsoft.AspNetCore.Hosting.IWebHostEnvironment _hostingEnvironment;
|
||||
|
||||
@@ -228,27 +229,41 @@ namespace AyaNova
|
||||
#endregion
|
||||
|
||||
#region JWT AUTHENTICATION
|
||||
//get the key if specified
|
||||
var secretKey = ServerBootConfig.AYANOVA_JWT_SECRET;
|
||||
//case 4711: the signing key is no longer a setting (placeholders and weak values could be used to forge tokens);
|
||||
//it is generated once and kept in a file so sign-ins survive a restart. Deleting the file signs everyone out.
|
||||
if (ServerBootConfig.AYANOVA_JWT_SECRET_SETTING_PRESENT)
|
||||
_newLog.LogWarning("AYANOVA_JWT_SECRET is no longer used and can be removed from the configuration: AyaNova generates and keeps its own sign-in key");
|
||||
|
||||
//If no key specified make a unique one
|
||||
//This means the jwt creds won't survive a server reboot
|
||||
//so in that case users need to specify an AyaNova_JWT_SECRET environment variable
|
||||
if (string.IsNullOrWhiteSpace(secretKey))
|
||||
ServerBootConfig.AYANOVA_JWT_SECRET = LoadOrCreateJwtSigningKey(ServerBootConfig.JWT_SIGNING_KEY_FILE_PATH);
|
||||
|
||||
string LoadOrCreateJwtSigningKey(string path)
|
||||
{
|
||||
_newLog.LogWarning("AYANOVA_JWT_SECRET configuration setting is missing; AyaNova will randomly generate one. Any Users who were logged in when the server restarted will need to login to get a fresh auth token. See manual 'AYANOVA_JWT_SECRET' page for details.");
|
||||
secretKey = Util.Hasher.GenerateSalt();
|
||||
}
|
||||
//WAS "UNLICENSED5G*QQJ8#bQ7$Xr_@sXfHq4"
|
||||
|
||||
|
||||
//If secretKey is less than 32 characters, pad it
|
||||
if (secretKey.Length < 32)
|
||||
{
|
||||
secretKey = secretKey.PadRight(32, '-');
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
var existing = System.IO.File.ReadAllText(path).Trim();
|
||||
if (existing.Length >= 32)
|
||||
return existing;
|
||||
_newLog.LogWarning($"Sign-in key file {path} is not valid; generating a new one (users will need to log in again)");
|
||||
}
|
||||
var newKey = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(48));
|
||||
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path));
|
||||
var options = new System.IO.FileStreamOptions { Mode = System.IO.FileMode.Create, Access = System.IO.FileAccess.Write };
|
||||
if (!OperatingSystem.IsWindows())
|
||||
options.UnixCreateMode = System.IO.UnixFileMode.UserRead | System.IO.UnixFileMode.UserWrite;
|
||||
using (var writer = new System.IO.StreamWriter(path, System.Text.Encoding.ASCII, options))
|
||||
writer.Write(newKey);
|
||||
_newLog.LogInformation($"Generated a new sign-in key in {path}");
|
||||
return newKey;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_newLog.LogWarning(ex, $"Could not read or create the sign-in key file {path}; using a temporary key for this run (users will need to log in again after a restart)");
|
||||
return Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(48));
|
||||
}
|
||||
}
|
||||
|
||||
ServerBootConfig.AYANOVA_JWT_SECRET = secretKey;
|
||||
var signingKey = new SymmetricSecurityKey(System.Text.Encoding.ASCII.GetBytes(ServerBootConfig.AYANOVA_JWT_SECRET));
|
||||
|
||||
_newLog.LogDebug("Authorization");
|
||||
@@ -310,10 +325,43 @@ namespace AyaNova
|
||||
//Store a reference to the dependency injection service for static classes
|
||||
ServiceProviderProvider.Provider = app.ApplicationServices;
|
||||
|
||||
//case 4632: inside IIS the report page's API calls are relayed (ReportApiRelay)
|
||||
ServerBootConfig.HOSTED_IN_IIS = app.ApplicationServices.GetService<Microsoft.AspNetCore.Hosting.Server.IServer>()?.GetType().FullName?.Contains(".IIS.", StringComparison.Ordinal) == true;
|
||||
if (ServerBootConfig.HOSTED_IN_IIS)
|
||||
_newLog.LogInformation("Running inside IIS: report API calls are relayed to the address of the users' requests");
|
||||
|
||||
//Enable ability to handle reverse proxy
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
//case 4732: a proxy on this computer (loopback) is always trusted; proxies on other computers only when named in AYANOVA_TRUSTED_PROXIES
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_TRUSTED_PROXIES))
|
||||
{
|
||||
foreach (var entry in ServerBootConfig.AYANOVA_TRUSTED_PROXIES.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (entry.Contains('/') && System.Net.IPNetwork.TryParse(entry, out var network))
|
||||
forwardedHeadersOptions.KnownIPNetworks.Add(network);
|
||||
else if (!entry.Contains('/') && System.Net.IPAddress.TryParse(entry, out var address))
|
||||
forwardedHeadersOptions.KnownProxies.Add(address);
|
||||
else
|
||||
_newLog.LogWarning($"AYANOVA_TRUSTED_PROXIES entry \"{entry}\" is not an IP address or network and is ignored");
|
||||
}
|
||||
//follow a chain of trusted proxies (e.g. a load balancer behind another proxy) back to the first address that is not trusted
|
||||
forwardedHeadersOptions.ForwardLimit = null;
|
||||
}
|
||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||
|
||||
//case 4732: X-Forwarded-For from a proxy that is not trusted is ignored (no X-Original-For is set), so every user appears to come
|
||||
//from the proxy's address and shares one failed login limit; say so once in the log
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (_untrustedProxyWarned == 0
|
||||
&& context.Request.Headers.ContainsKey("X-Forwarded-For")
|
||||
&& !context.Request.Headers.ContainsKey("X-Original-For")
|
||||
&& System.Threading.Interlocked.Exchange(ref _untrustedProxyWarned, 1) == 0)
|
||||
_newLog.LogWarning($"A request came through a reverse proxy at {(context.Connection.RemoteIpAddress.IsIPv4MappedToIPv6 ? context.Connection.RemoteIpAddress.MapToIPv4() : context.Connection.RemoteIpAddress)} that is not trusted, so users connecting through it all appear to come from that address and share one limit on failed login attempts. If this is your reverse proxy, add its address to the AYANOVA_TRUSTED_PROXIES setting");
|
||||
await next.Invoke();
|
||||
});
|
||||
|
||||
|
||||
@@ -324,6 +372,16 @@ namespace AyaNova
|
||||
// await next.Invoke();
|
||||
// });
|
||||
|
||||
//case 4727 (F35): safe headers on every response, so self-hosted installs get them too (hosted droplets
|
||||
//also get them from nginx: same values, so the duplicates agree)
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
context.Response.Headers["X-Frame-Options"] = "SAMEORIGIN";
|
||||
context.Response.Headers["Referrer-Policy"] = "strict-origin";
|
||||
await next.Invoke();
|
||||
});
|
||||
|
||||
|
||||
#region STATIC FILES
|
||||
_newLog.LogDebug("Static files");
|
||||
@@ -391,8 +449,9 @@ namespace AyaNova
|
||||
var ct = context.RequestServices.GetService<AyContext>();
|
||||
|
||||
//get the user record
|
||||
var u = await ct.User.AsNoTracking().Where(a => a.Id == userId).Select(m => new { roles = m.Roles, name = m.Name, m.UserType, id = m.Id, translationId = m.UserOptions.TranslationId, currentAuthToken = m.CurrentAuthToken }).FirstAsync();
|
||||
context.Request.HttpContext.Items["AY_ROLES"] = u.roles;
|
||||
var u = await ct.User.AsNoTracking().Where(a => a.Id == userId).Select(m => new { roles = m.Roles, name = m.Name, m.UserType, id = m.Id, translationId = m.UserOptions.TranslationId, currentAuthToken = m.CurrentAuthToken, m.Active, m.AllowLogin, m.Login, m.Salt, m.Password }).FirstAsync();
|
||||
//case 4713: a customer contact acts only with the customer roles, whatever roles are stored
|
||||
context.Request.HttpContext.Items["AY_ROLES"] = UserBiz.EffectiveRoles(u.UserType, u.roles);
|
||||
context.Request.HttpContext.Items["AY_USERNAME"] = u.name;
|
||||
context.Request.HttpContext.Items["AY_USER_ID"] = u.id;
|
||||
context.Request.HttpContext.Items["AY_TRANSLATION_ID"] = u.translationId;
|
||||
@@ -407,31 +466,32 @@ namespace AyaNova
|
||||
|
||||
|
||||
//CHECK JWT
|
||||
//case 4725: the login and prelogin routes are matched exactly (was any path ending in "/auth" or "notify/hello"),
|
||||
//and a user who is inactive or may not log in is refused like a replaced token
|
||||
var routeKey = AyaNova.Api.ControllerHelpers.CustomerContactRoutes.RouteKey(context);
|
||||
if (
|
||||
!context.Request.Path.Value.EndsWith("/auth") &&
|
||||
!context.Request.Path.Value.EndsWith("notify/hello") &&
|
||||
u.currentAuthToken != JWT
|
||||
routeKey != "POST auth" &&
|
||||
routeKey != "GET notify/hello" &&
|
||||
(u.currentAuthToken != JWT || !u.Active || !u.AllowLogin)
|
||||
)//except "/api/v8/auth" and prelogin notify/hello routes so user can login
|
||||
{
|
||||
|
||||
//It may be a local report render request from a job
|
||||
//case 4632: from this machine only; the host name is no longer required to be 127.0.0.1, inside IIS the
|
||||
//request is relayed to the site's own address and host name (ReportApiRelay)
|
||||
bool bLocalReportRenderRequest = false;
|
||||
if (context.Request.Host.Host == "127.0.0.1")
|
||||
if (context.Request.IsLocal())
|
||||
{
|
||||
//check if token has j value set
|
||||
if (context.Request.IsLocal())
|
||||
//if this is a generate report internal token this will be the override language set
|
||||
//and it's existance is enough to indicate it was set internally by this server
|
||||
var rpl = context.User.Claims.FirstOrDefault(c => c.Type == "rpl");
|
||||
if (rpl != null)
|
||||
{
|
||||
//if this is a generate report internal token this will be the override language set
|
||||
//and it's existance is enough to indicate it was set internally by this server
|
||||
var rpl = context.User.Claims.FirstOrDefault(c => c.Type == "rpl");
|
||||
if (rpl != null)
|
||||
{
|
||||
//it's a local request, the jwt already passed earlier so we issued it and it has the correct claim set so allow it
|
||||
bLocalReportRenderRequest = true;
|
||||
//set override language
|
||||
context.Request.HttpContext.Items["AY_TRANSLATION_ID"] = Convert.ToInt64(rpl.Value);
|
||||
await next.Invoke();
|
||||
}
|
||||
//it's a local request, the jwt already passed earlier so we issued it and it has the correct claim set so allow it
|
||||
bLocalReportRenderRequest = true;
|
||||
//set override language
|
||||
context.Request.HttpContext.Items["AY_TRANSLATION_ID"] = Convert.ToInt64(rpl.Value);
|
||||
await next.Invoke();
|
||||
}
|
||||
}
|
||||
if (!bLocalReportRenderRequest)
|
||||
@@ -444,6 +504,24 @@ namespace AyaNova
|
||||
}
|
||||
else
|
||||
{
|
||||
//case 4712: customer contacts may only call the routes the customer screens use
|
||||
//case 4725: with a purchased licence, a SuperUser with the default password may only change it
|
||||
if (((u.UserType == UserType.Customer || u.UserType == UserType.HeadOffice) && !AyaNova.Api.ControllerHelpers.CustomerContactRoutes.IsAllowed(context)) ||
|
||||
(u.id == 1 && AyaNova.Core.License.PurchasedLicense() && UserBiz.IsDefaultSuperUserCreds(u.Login, u.Salt, u.Password) &&
|
||||
!AyaNova.Api.ControllerHelpers.SuperUserDefaultPasswordRoutes.IsAllowed(context)))
|
||||
{
|
||||
context.Response.StatusCode = 403;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsync(Newtonsoft.Json.JsonConvert.SerializeObject(new AyaNova.Api.ControllerHelpers.ApiNotAuthorizedResponse(),
|
||||
new Newtonsoft.Json.JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore
|
||||
}));
|
||||
return;
|
||||
}
|
||||
//case 4632: inside IIS, where this signed-in request arrived is where notification reports send their API calls
|
||||
ReportApiRelay.Remember(context);
|
||||
await next.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -596,6 +674,9 @@ namespace AyaNova
|
||||
//Log the license info so it's on the record
|
||||
_newLog.LogInformation($"License - [{AyaNova.Core.License.LicenseInfoLogFormat}]");
|
||||
|
||||
//case 4684: so the log of a restarted server shows whether, and when, the next backup will run
|
||||
_newLog.LogInformation(ServerGlobalOpsSettingsCache.BackupStatusText());
|
||||
|
||||
|
||||
//Check for SuperUser password override
|
||||
if (!string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_SET_SUPERUSER_PW))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AyaNova.Util;
|
||||
using AyaNova.Models;
|
||||
|
||||
@@ -91,7 +93,9 @@ namespace AyaNova.Biz
|
||||
case AyaType.WorkOrderItemTravel:
|
||||
case AyaType.WorkOrderItemUnit:
|
||||
case AyaType.WorkOrderItemOutsideService:
|
||||
return new WorkOrderBiz(ct, userId, translationId, roles, UserType.Service);
|
||||
//case 4720: the real user type, the subcontractor restrictions depend on it (report data and export come through here)
|
||||
var workOrderUserType = ct.User.AsNoTracking().Where(z => z.Id == userId).Select(z => z.UserType).FirstOrDefault();
|
||||
return new WorkOrderBiz(ct, userId, translationId, roles, workOrderUserType);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -297,6 +297,7 @@ namespace AyaNova.Biz
|
||||
| AuthorizationRoles.Sales
|
||||
| AuthorizationRoles.SalesRestricted
|
||||
| AuthorizationRoles.Tech
|
||||
| AuthorizationRoles.TechRestricted//case 4735: as documented, like Tech
|
||||
,
|
||||
Select = AuthorizationRoles.All
|
||||
});
|
||||
@@ -344,6 +345,7 @@ namespace AyaNova.Biz
|
||||
ReadFullRecord = AuthorizationRoles.Service
|
||||
| AuthorizationRoles.Sales
|
||||
| AuthorizationRoles.Tech
|
||||
| AuthorizationRoles.TechRestricted//case 4735: as documented, like Tech
|
||||
| AuthorizationRoles.BizAdminRestricted
|
||||
| AuthorizationRoles.ServiceRestricted
|
||||
| AuthorizationRoles.SalesRestricted
|
||||
@@ -398,7 +400,8 @@ namespace AyaNova.Biz
|
||||
| AuthorizationRoles.ServiceRestricted
|
||||
| AuthorizationRoles.Sales
|
||||
| AuthorizationRoles.SalesRestricted
|
||||
| AuthorizationRoles.Tech,
|
||||
| AuthorizationRoles.Tech
|
||||
| AuthorizationRoles.TechRestricted,//case 4735: as documented, like Tech
|
||||
Select = AuthorizationRoles.All
|
||||
});
|
||||
|
||||
@@ -407,11 +410,13 @@ namespace AyaNova.Biz
|
||||
//
|
||||
roles.Add(AyaType.UnitMeterReading, new BizRoleSet()
|
||||
{
|
||||
Change = AuthorizationRoles.BizAdmin | AuthorizationRoles.Service | AuthorizationRoles.Tech | AuthorizationRoles.Accounting,
|
||||
//case 4735: Accounting no longer changes meter readings; Tech restricted reads them, as documented
|
||||
Change = AuthorizationRoles.BizAdmin | AuthorizationRoles.Service | AuthorizationRoles.Tech,
|
||||
ReadFullRecord = AuthorizationRoles.BizAdminRestricted
|
||||
| AuthorizationRoles.ServiceRestricted
|
||||
| AuthorizationRoles.Sales
|
||||
| AuthorizationRoles.SalesRestricted,
|
||||
| AuthorizationRoles.SalesRestricted
|
||||
| AuthorizationRoles.TechRestricted,
|
||||
Select = AuthorizationRoles.All
|
||||
});
|
||||
|
||||
@@ -780,7 +785,8 @@ namespace AyaNova.Biz
|
||||
roles.Add(AyaType.ServerMetrics, new BizRoleSet()
|
||||
{
|
||||
Change = AuthorizationRoles.OpsAdmin,//this is to turn on extra metrics (profiler)
|
||||
ReadFullRecord = AuthorizationRoles.OpsAdmin | AuthorizationRoles.OpsAdminRestricted
|
||||
//case 4735: Business administration views metrics, as documented and as for the other operations forms
|
||||
ReadFullRecord = AuthorizationRoles.OpsAdmin | AuthorizationRoles.OpsAdminRestricted | AuthorizationRoles.BizAdmin | AuthorizationRoles.BizAdminRestricted
|
||||
});
|
||||
|
||||
|
||||
@@ -873,7 +879,9 @@ namespace AyaNova.Biz
|
||||
//
|
||||
roles.Add(AyaType.Report, new BizRoleSet()
|
||||
{
|
||||
Change = AuthorizationRoles.BizAdmin | AuthorizationRoles.BizAdminRestricted,
|
||||
//case 4719: BizAdminRestricted only views templates, as ay-biz-admin-roles.md describes (a template's script runs
|
||||
//with the rights of whoever renders it, so changing one is an administrator right)
|
||||
Change = AuthorizationRoles.BizAdmin,
|
||||
ReadFullRecord = AuthorizationRoles.All,
|
||||
Select = AuthorizationRoles.All
|
||||
});
|
||||
@@ -898,6 +906,7 @@ namespace AyaNova.Biz
|
||||
// (everyone but outside users Customer and HO can send and receive memos)
|
||||
roles.Add(AyaType.Memo, new BizRoleSet()
|
||||
{
|
||||
//case 4735: no Subcontractor roles; subcontractors have no memo feature (as documented)
|
||||
Change = AuthorizationRoles.BizAdminRestricted
|
||||
| AuthorizationRoles.BizAdmin
|
||||
| AuthorizationRoles.ServiceRestricted
|
||||
@@ -907,8 +916,6 @@ namespace AyaNova.Biz
|
||||
| AuthorizationRoles.Accounting
|
||||
| AuthorizationRoles.TechRestricted
|
||||
| AuthorizationRoles.Tech
|
||||
| AuthorizationRoles.SubContractorRestricted
|
||||
| AuthorizationRoles.SubContractor
|
||||
| AuthorizationRoles.Sales
|
||||
| AuthorizationRoles.SalesRestricted
|
||||
| AuthorizationRoles.OpsAdminRestricted
|
||||
@@ -922,8 +929,6 @@ namespace AyaNova.Biz
|
||||
| AuthorizationRoles.Accounting
|
||||
| AuthorizationRoles.TechRestricted
|
||||
| AuthorizationRoles.Tech
|
||||
| AuthorizationRoles.SubContractorRestricted
|
||||
| AuthorizationRoles.SubContractor
|
||||
| AuthorizationRoles.Sales
|
||||
| AuthorizationRoles.SalesRestricted
|
||||
| AuthorizationRoles.OpsAdminRestricted
|
||||
@@ -937,8 +942,6 @@ namespace AyaNova.Biz
|
||||
| AuthorizationRoles.Accounting
|
||||
| AuthorizationRoles.TechRestricted
|
||||
| AuthorizationRoles.Tech
|
||||
| AuthorizationRoles.SubContractorRestricted
|
||||
| AuthorizationRoles.SubContractor
|
||||
| AuthorizationRoles.Sales
|
||||
| AuthorizationRoles.SalesRestricted
|
||||
| AuthorizationRoles.OpsAdminRestricted
|
||||
|
||||
@@ -133,7 +133,7 @@ MULTIPLE discount / markup ITEMS
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(m => m.Id == id);
|
||||
|
||||
if (populateDisplayFields)
|
||||
if (populateDisplayFields && ret != null)
|
||||
await PopulateVizFields(ret);
|
||||
|
||||
if (logTheGetEvent && ret != null)
|
||||
|
||||
@@ -339,6 +339,12 @@ namespace AyaNova.Biz
|
||||
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("CustomerServiceRequest"));
|
||||
if (await ct.PurchaseOrder.AnyAsync(m => m.DropShipToCustomerId == inObj.Id))
|
||||
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PurchaseOrder"));
|
||||
if (await ct.WorkOrder.AnyAsync(z => z.CustomerId == inObj.Id))
|
||||
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrder"));
|
||||
if (await ct.Quote.AnyAsync(z => z.CustomerId == inObj.Id))
|
||||
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Quote"));
|
||||
if (await ct.PM.AnyAsync(z => z.CustomerId == inObj.Id))
|
||||
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PM"));
|
||||
|
||||
// await Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ namespace AyaNova.Biz
|
||||
AddError(ApiErrorCode.NOT_FOUND);
|
||||
return false;
|
||||
}
|
||||
ValidateCanDelete(dbObject);
|
||||
await ValidateCanDeleteAsync(dbObject);
|
||||
if (HasErrors)
|
||||
return false;
|
||||
{
|
||||
@@ -239,12 +239,12 @@ namespace AyaNova.Biz
|
||||
|
||||
}
|
||||
|
||||
private void ValidateCanDelete(CustomerServiceRequest inObj)
|
||||
private async Task ValidateCanDeleteAsync(CustomerServiceRequest inObj)
|
||||
{
|
||||
//whatever needs to be check to delete this object
|
||||
//can't delete if it's accepted
|
||||
|
||||
|
||||
//FOREIGN KEY CHECKS
|
||||
//an accepted request is linked from the work order item made from it
|
||||
if (await ct.WorkOrderItem.AnyAsync(z => z.FromCSRId == inObj.Id))
|
||||
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrder"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ namespace AyaNova.Biz
|
||||
|
||||
public static class FormFieldOptionalCustomizableReference
|
||||
{
|
||||
private static Dictionary<string, List<FormField>> _formFields;
|
||||
private static List<string> _formFieldKeys = null;
|
||||
//case 4693: volatile, and published only once it is fully built (see FormFieldReferenceList)
|
||||
private static volatile Dictionary<string, List<FormField>> _formFields;
|
||||
private static volatile List<string> _formFieldKeys = null;
|
||||
|
||||
public static List<string> FormFieldKeys
|
||||
{
|
||||
@@ -23,23 +24,25 @@ namespace AyaNova.Biz
|
||||
{
|
||||
if (_formFieldKeys == null)
|
||||
{
|
||||
_formFieldKeys = new List<string>();
|
||||
//case 4693: built in a local and published when complete, as FormFieldReferenceList
|
||||
var formFieldKeys = new List<string>();
|
||||
var values = Enum.GetValues(typeof(AyaType));
|
||||
foreach (AyaType t in values)
|
||||
{
|
||||
if (t.HasAttribute(typeof(CoreBizObjectAttribute)))
|
||||
{
|
||||
_formFieldKeys.Add(t.ToString());
|
||||
formFieldKeys.Add(t.ToString());
|
||||
}
|
||||
}
|
||||
//No type / not corebiz form keys:
|
||||
_formFieldKeys.Add("Contact");
|
||||
formFieldKeys.Add("Contact");
|
||||
//_formFieldKeys.Add(AyaType.ServiceBank.ToString());
|
||||
_formFieldKeys.Add(AyaType.QuoteStatus.ToString());
|
||||
_formFieldKeys.Add(AyaType.WorkOrderStatus.ToString());
|
||||
_formFieldKeys.Add(AyaType.WorkOrderItemStatus.ToString());
|
||||
_formFieldKeys.Add(AyaType.WorkOrderItemPriority.ToString());
|
||||
_formFieldKeys.Add(AyaType.UnitMeterReading.ToString());
|
||||
formFieldKeys.Add(AyaType.QuoteStatus.ToString());
|
||||
formFieldKeys.Add(AyaType.WorkOrderStatus.ToString());
|
||||
formFieldKeys.Add(AyaType.WorkOrderItemStatus.ToString());
|
||||
formFieldKeys.Add(AyaType.WorkOrderItemPriority.ToString());
|
||||
formFieldKeys.Add(AyaType.UnitMeterReading.ToString());
|
||||
_formFieldKeys = formFieldKeys;
|
||||
}
|
||||
return _formFieldKeys;
|
||||
}
|
||||
@@ -54,9 +57,11 @@ namespace AyaNova.Biz
|
||||
public static List<FormField> FormFieldReferenceList(string key)
|
||||
{
|
||||
//Initialize the static list here on first retrieval
|
||||
//case 4693: built in a local and published when complete: requests arriving during the build after a
|
||||
//restart saw a half-filled list ("is not valid"); two building at once is harmless, the result is the same
|
||||
if (_formFields == null)
|
||||
{
|
||||
_formFields = new Dictionary<string, List<FormField>>();
|
||||
var formFields = new Dictionary<string, List<FormField>>();
|
||||
/* ***************************** WARNING: Be careful here, if a standard field is hideable and also it's DB SCHEMA is set to NON NULLABLE then the CLIENT end needs to set a default
|
||||
***************************** Otherwise the hidden field can't be set and the object can't be saved EVER
|
||||
*/
|
||||
@@ -86,7 +91,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "UserCustom14", FieldKey = "UserCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "UserCustom15", FieldKey = "UserCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "UserCustom16", FieldKey = "UserCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.User.ToString(), l);
|
||||
formFields.Add(AyaType.User.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -115,7 +120,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "UserCustom14", FieldKey = "UserCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "UserCustom15", FieldKey = "UserCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "UserCustom16", FieldKey = "UserCustom16", IsCustomField = true });
|
||||
_formFields.Add("Contact", l);
|
||||
formFields.Add("Contact", l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -171,7 +176,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "CustomerCustom14", FieldKey = "CustomerCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "CustomerCustom15", FieldKey = "CustomerCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "CustomerCustom16", FieldKey = "CustomerCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.Customer.ToString(), l);
|
||||
formFields.Add(AyaType.Customer.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -222,7 +227,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "HeadOfficeCustom14", FieldKey = "HeadOfficeCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "HeadOfficeCustom15", FieldKey = "HeadOfficeCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "HeadOfficeCustom16", FieldKey = "HeadOfficeCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.HeadOffice.ToString(), l);
|
||||
formFields.Add(AyaType.HeadOffice.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -251,7 +256,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "ContractCustom14", FieldKey = "ContractCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "ContractCustom15", FieldKey = "ContractCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "ContractCustom16", FieldKey = "ContractCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.Contract.ToString(), l);
|
||||
formFields.Add(AyaType.Contract.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -295,7 +300,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "LoanUnitCustom14", FieldKey = "LoanUnitCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "LoanUnitCustom15", FieldKey = "LoanUnitCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "LoanUnitCustom16", FieldKey = "LoanUnitCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.LoanUnit.ToString(), l);
|
||||
formFields.Add(AyaType.LoanUnit.ToString(), l);
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -323,7 +328,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "MemoCustom14", FieldKey = "MemoCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "MemoCustom15", FieldKey = "MemoCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "MemoCustom16", FieldKey = "MemoCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.Memo.ToString(), l);
|
||||
formFields.Add(AyaType.Memo.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -351,7 +356,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "ReminderCustom14", FieldKey = "ReminderCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "ReminderCustom15", FieldKey = "ReminderCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "ReminderCustom16", FieldKey = "ReminderCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.Reminder.ToString(), l);
|
||||
formFields.Add(AyaType.Reminder.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -377,7 +382,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "ReviewCustom14", FieldKey = "ReviewCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "ReviewCustom15", FieldKey = "ReviewCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "ReviewCustom16", FieldKey = "ReviewCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.Review.ToString(), l);
|
||||
formFields.Add(AyaType.Review.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -415,7 +420,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "PartCustom14", FieldKey = "PartCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "PartCustom15", FieldKey = "PartCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "PartCustom16", FieldKey = "PartCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.Part.ToString(), l);
|
||||
formFields.Add(AyaType.Part.ToString(), l);
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -445,7 +450,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "PartWarehouseCustom14", FieldKey = "PartWarehouseCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "PartWarehouseCustom15", FieldKey = "PartWarehouseCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "PartWarehouseCustom16", FieldKey = "PartWarehouseCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.PartWarehouse.ToString(), l);
|
||||
formFields.Add(AyaType.PartWarehouse.ToString(), l);
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -474,7 +479,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "PartAssemblyCustom14", FieldKey = "PartAssemblyCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "PartAssemblyCustom15", FieldKey = "PartAssemblyCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "PartAssemblyCustom16", FieldKey = "PartAssemblyCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.PartAssembly.ToString(), l);
|
||||
formFields.Add(AyaType.PartAssembly.ToString(), l);
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -507,7 +512,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "ProjectCustom14", FieldKey = "ProjectCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "ProjectCustom15", FieldKey = "ProjectCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "ProjectCustom16", FieldKey = "ProjectCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.Project.ToString(), l);
|
||||
formFields.Add(AyaType.Project.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -555,7 +560,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "PurchaseOrderCustom14", FieldKey = "PurchaseOrderCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "PurchaseOrderCustom15", FieldKey = "PurchaseOrderCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "PurchaseOrderCustom16", FieldKey = "PurchaseOrderCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.PurchaseOrder.ToString(), l);
|
||||
formFields.Add(AyaType.PurchaseOrder.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -588,7 +593,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "ServiceRateCustom14", FieldKey = "ServiceRateCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "ServiceRateCustom15", FieldKey = "ServiceRateCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "ServiceRateCustom16", FieldKey = "ServiceRateCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.ServiceRate.ToString(), l);
|
||||
formFields.Add(AyaType.ServiceRate.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -619,7 +624,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "TravelRateCustom14", FieldKey = "TravelRateCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "TravelRateCustom15", FieldKey = "TravelRateCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "TravelRateCustom16", FieldKey = "TravelRateCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.TravelRate.ToString(), l);
|
||||
formFields.Add(AyaType.TravelRate.ToString(), l);
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -631,7 +636,7 @@ namespace AyaNova.Biz
|
||||
// l.Add(new FormField { TKey = "TaskGroupNotes", FieldKey = "Notes" });
|
||||
|
||||
|
||||
// _formFields.Add(AyaType.TaskGroup.ToString(), l);
|
||||
// formFields.Add(AyaType.TaskGroup.ToString(), l);
|
||||
|
||||
// }
|
||||
// #endregion
|
||||
@@ -661,7 +666,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "TaxCodeCustom14", FieldKey = "TaxCodeCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "TaxCodeCustom15", FieldKey = "TaxCodeCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "TaxCodeCustom16", FieldKey = "TaxCodeCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.TaxCode.ToString(), l);
|
||||
formFields.Add(AyaType.TaxCode.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -671,7 +676,7 @@ namespace AyaNova.Biz
|
||||
// l.Add(new FormField { TKey = "ServiceBankCurrency", FieldKey = "Currency" });
|
||||
// l.Add(new FormField { TKey = "ServiceBankHours", FieldKey = "Hours" });
|
||||
// l.Add(new FormField { TKey = "ServiceBankIncidents", FieldKey = "Incidents" });
|
||||
// _formFields.Add(AyaType.ServiceBank.ToString(), l);
|
||||
// formFields.Add(AyaType.ServiceBank.ToString(), l);
|
||||
// }
|
||||
// #endregion
|
||||
|
||||
@@ -727,7 +732,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "UnitCustom14", FieldKey = "UnitCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "UnitCustom15", FieldKey = "UnitCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "UnitCustom16", FieldKey = "UnitCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.Unit.ToString(), l);
|
||||
formFields.Add(AyaType.Unit.ToString(), l);
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -765,7 +770,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "UnitModelCustom14", FieldKey = "UnitModelCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "UnitModelCustom15", FieldKey = "UnitModelCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "UnitModelCustom16", FieldKey = "UnitModelCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.UnitModel.ToString(), l);
|
||||
formFields.Add(AyaType.UnitModel.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -816,7 +821,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "VendorCustom14", FieldKey = "VendorCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "VendorCustom15", FieldKey = "VendorCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "VendorCustom16", FieldKey = "VendorCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.Vendor.ToString(), l);
|
||||
formFields.Add(AyaType.Vendor.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -1052,7 +1057,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "LineTotal", FieldKey = "OutsideServiceLineTotalViz", TKeySection = "WorkOrderItemOutsideService", Requireable = false });
|
||||
|
||||
|
||||
_formFields.Add(AyaType.WorkOrder.ToString(), l);
|
||||
formFields.Add(AyaType.WorkOrder.ToString(), l);
|
||||
|
||||
}
|
||||
#endregion
|
||||
@@ -1278,7 +1283,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "TaxBAmt", FieldKey = "OutsideServiceTaxBViz", TKeySection = "WorkOrderItemOutsideService", Requireable = false });
|
||||
l.Add(new FormField { TKey = "LineTotal", FieldKey = "OutsideServiceLineTotalViz", TKeySection = "WorkOrderItemOutsideService", Requireable = false });
|
||||
|
||||
_formFields.Add(AyaType.Quote.ToString(), l);
|
||||
formFields.Add(AyaType.Quote.ToString(), l);
|
||||
|
||||
}
|
||||
#endregion quote
|
||||
@@ -1287,7 +1292,7 @@ namespace AyaNova.Biz
|
||||
{
|
||||
List<FormField> l = new List<FormField>();
|
||||
l.Add(new FormField { TKey = "StatusNotes", FieldKey = "StatusNotes" });
|
||||
_formFields.Add(AyaType.QuoteStatus.ToString(), l);
|
||||
formFields.Add(AyaType.QuoteStatus.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -1295,7 +1300,7 @@ namespace AyaNova.Biz
|
||||
{
|
||||
List<FormField> l = new List<FormField>();
|
||||
l.Add(new FormField { TKey = "StatusNotes", FieldKey = "StatusNotes" });
|
||||
_formFields.Add(AyaType.WorkOrderStatus.ToString(), l);
|
||||
formFields.Add(AyaType.WorkOrderStatus.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -1303,21 +1308,21 @@ namespace AyaNova.Biz
|
||||
{
|
||||
List<FormField> l = new List<FormField>();
|
||||
l.Add(new FormField { TKey = "WorkOrderItemStatusNotes", FieldKey = "WorkOrderItemStatusNotes" });
|
||||
_formFields.Add(AyaType.WorkOrderItemStatus.ToString(), l);
|
||||
formFields.Add(AyaType.WorkOrderItemStatus.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region WorkOrderItemPriority
|
||||
{
|
||||
List<FormField> l = new List<FormField>();
|
||||
_formFields.Add(AyaType.WorkOrderItemPriority.ToString(), l);
|
||||
formFields.Add(AyaType.WorkOrderItemPriority.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UnitmeterReading
|
||||
{
|
||||
List<FormField> l = new List<FormField>();
|
||||
_formFields.Add(AyaType.UnitMeterReading.ToString(), l);
|
||||
formFields.Add(AyaType.UnitMeterReading.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -1545,7 +1550,7 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "TaxBAmt", FieldKey = "OutsideServiceTaxBViz", TKeySection = "WorkOrderItemOutsideService", Requireable = false });
|
||||
l.Add(new FormField { TKey = "LineTotal", FieldKey = "OutsideServiceLineTotalViz", TKeySection = "WorkOrderItemOutsideService", Requireable = false });
|
||||
|
||||
_formFields.Add(AyaType.PM.ToString(), l);
|
||||
formFields.Add(AyaType.PM.ToString(), l);
|
||||
|
||||
}
|
||||
#endregion quote
|
||||
@@ -1583,12 +1588,13 @@ namespace AyaNova.Biz
|
||||
l.Add(new FormField { TKey = "CustomerServiceRequestCustom14", FieldKey = "CustomerServiceRequestCustom14", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "CustomerServiceRequestCustom15", FieldKey = "CustomerServiceRequestCustom15", IsCustomField = true });
|
||||
l.Add(new FormField { TKey = "CustomerServiceRequestCustom16", FieldKey = "CustomerServiceRequestCustom16", IsCustomField = true });
|
||||
_formFields.Add(AyaType.CustomerServiceRequest.ToString(), l);
|
||||
formFields.Add(AyaType.CustomerServiceRequest.ToString(), l);
|
||||
}
|
||||
#endregion
|
||||
|
||||
|
||||
//******************************************************
|
||||
_formFields = formFields;
|
||||
}
|
||||
|
||||
if (!_formFields.ContainsKey(key))
|
||||
|
||||
@@ -112,8 +112,12 @@ namespace AyaNova.Biz
|
||||
//Can save or update?
|
||||
private void Validate(GlobalOpsBackupSettings inObj)
|
||||
{
|
||||
|
||||
//currently nothing to validate
|
||||
#if (!SUBSCRIPTION_BUILD)
|
||||
//case 4763: at least the newest backup is always kept, so fewer than 1 is not a setting (it was treated as 1)
|
||||
//the subscription build always keeps 1 and its form doesn't show this setting
|
||||
if (inObj.BackupSetsToKeep < 1)
|
||||
AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "BackupSetsToKeep");
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace AyaNova.Biz
|
||||
|
||||
internal class GlobalOpsNotificationSettingsBiz : BizObject
|
||||
{
|
||||
//case 4724: shown to the client in place of the SMTP password, which never leaves the server
|
||||
internal const string SMTP_PASSWORD_MASK = "********";
|
||||
|
||||
internal GlobalOpsNotificationSettingsBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles)
|
||||
{
|
||||
@@ -62,6 +64,9 @@ namespace AyaNova.Biz
|
||||
if (dbObject == null)
|
||||
throw new System.Exception("GlobalOpsNotificationSettings::PutAsync -> Settings object not found in database!!");
|
||||
|
||||
//case 4724: the client gets the mask, not the password; the mask or an empty value keeps the stored password
|
||||
if (string.IsNullOrEmpty(putObject.SmtpPassword) || putObject.SmtpPassword == SMTP_PASSWORD_MASK)
|
||||
putObject.SmtpPassword = dbObject.SmtpPassword;
|
||||
CopyObject.Copy(putObject, dbObject, "Id");
|
||||
ct.Entry(dbObject).OriginalValues["Concurrency"] = putObject.Concurrency;
|
||||
Validate(dbObject);
|
||||
|
||||
@@ -234,11 +234,18 @@ namespace AyaNova.Biz
|
||||
}
|
||||
}
|
||||
|
||||
//case 4735: an integrated application, whatever role it signs in as, works with its own data; switching an
|
||||
//integration on or off is for Business administration (Integrated applications form)
|
||||
if (!isNew && proposedObj.Active != currentObj.Active && !CurrentUserRoles.HasFlag(AuthorizationRoles.BizAdmin))
|
||||
AddError(ApiErrorCode.NOT_AUTHORIZED, "Active");
|
||||
|
||||
}
|
||||
|
||||
private void ValidateCanDelete(Integration inObj)
|
||||
{
|
||||
//whatever needs to be check to delete this object
|
||||
//case 4735: deleting an integration is for Business administration (Integrated applications form)
|
||||
if (!CurrentUserRoles.HasFlag(AuthorizationRoles.BizAdmin))
|
||||
AddError(ApiErrorCode.NOT_AUTHORIZED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ namespace AyaNova.Biz
|
||||
AddError(ApiErrorCode.NOT_FOUND);
|
||||
return false;
|
||||
}
|
||||
ValidateCanDelete(dbObject);
|
||||
await ValidateCanDeleteAsync(dbObject);
|
||||
if (HasErrors)
|
||||
return false;
|
||||
{
|
||||
@@ -230,9 +230,15 @@ namespace AyaNova.Biz
|
||||
|
||||
}
|
||||
|
||||
private void ValidateCanDelete(LoanUnit inObj)
|
||||
private async Task ValidateCanDeleteAsync(LoanUnit inObj)
|
||||
{
|
||||
//whatever needs to be check to delete this object
|
||||
//FOREIGN KEY CHECKS
|
||||
if (await ct.WorkOrderItemLoan.AnyAsync(z => z.LoanUnitId == inObj.Id))
|
||||
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemLoan"));
|
||||
if (await ct.QuoteItemLoan.AnyAsync(z => z.LoanUnitId == inObj.Id))
|
||||
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Quote") + " - " + await Translate("WorkOrderItemLoan"));
|
||||
if (await ct.PMItemLoan.AnyAsync(z => z.LoanUnitId == inObj.Id))
|
||||
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PM") + " - " + await Translate("WorkOrderItemLoan"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,13 @@ namespace AyaNova.Biz
|
||||
// PROCESS STANDARD CREATE NOTIFICATION
|
||||
//
|
||||
//
|
||||
//case 4712: object events (created, modified, deleted, age) go only to subscribers with read rights on that type
|
||||
private static async Task<bool> SubscriberMayReadAsync(AyContext ct, long userId, AyaType aType)
|
||||
{
|
||||
var u = await ct.User.AsNoTracking().Where(z => z.Id == userId).Select(z => new { z.UserType, z.Roles }).FirstOrDefaultAsync();
|
||||
return u != null && AyaNova.Api.ControllerHelpers.Authorized.HasReadFullRole(UserBiz.EffectiveRoles(u.UserType, u.Roles), aType);
|
||||
}
|
||||
|
||||
public static async Task ProcessStandardObjectCreatedEvents(ICoreBizObjectModel newObject, AyContext ct)
|
||||
{
|
||||
//CREATED SUBSCRIPTIONS
|
||||
@@ -81,6 +88,8 @@ namespace AyaNova.Biz
|
||||
{
|
||||
//not for inactive users
|
||||
if (!await UserBiz.UserIsActive(sub.UserId)) continue;
|
||||
//case 4712: not for users who may not read this type of record
|
||||
if (!await SubscriberMayReadAsync(ct, sub.UserId, newObject.AyaType)) continue;
|
||||
if (ObjectHasAllSubscriptionTags(newObject.Tags, sub.Tags))
|
||||
{
|
||||
NotifyEvent n = new NotifyEvent()
|
||||
@@ -107,6 +116,8 @@ namespace AyaNova.Biz
|
||||
{
|
||||
//not for inactive users
|
||||
if (!await UserBiz.UserIsActive(sub.UserId)) continue;
|
||||
//case 4712: not for users who may not read this type of record
|
||||
if (!await SubscriberMayReadAsync(ct, sub.UserId, newObject.AyaType)) continue;
|
||||
if (ObjectHasAllSubscriptionTags(newObject.Tags, sub.Tags))
|
||||
{
|
||||
//Note: age is set by advance notice which is consulted by CoreJobNotify in it's run so the deliver date is not required here only the reference EventDate to check for deliver
|
||||
@@ -145,6 +156,8 @@ namespace AyaNova.Biz
|
||||
{
|
||||
//not for inactive users
|
||||
if (!await UserBiz.UserIsActive(sub.UserId)) continue;
|
||||
//case 4712: not for users who may not read this type of record
|
||||
if (!await SubscriberMayReadAsync(ct, sub.UserId, newObject.AyaType)) continue;
|
||||
if (ObjectHasAllSubscriptionTags(newObject.Tags, sub.Tags))
|
||||
{
|
||||
NotifyEvent n = new NotifyEvent()
|
||||
@@ -185,6 +198,8 @@ namespace AyaNova.Biz
|
||||
{
|
||||
//not for inactive users
|
||||
if (!await UserBiz.UserIsActive(sub.UserId)) continue;
|
||||
//case 4712: not for users who may not read this type of record
|
||||
if (!await SubscriberMayReadAsync(ct, sub.UserId, bizObject.AyaType)) continue;
|
||||
if (ObjectHasAllSubscriptionTags(bizObject.Tags, sub.Tags))
|
||||
{
|
||||
//TODO: On deliver should point to history event log record or take from there and insert into delivery message?
|
||||
|
||||
@@ -38,6 +38,9 @@ namespace AyaNova.Biz
|
||||
//
|
||||
internal async Task<NotifySubscription> CreateAsync(NotifySubscription newObject)
|
||||
{
|
||||
//case 4712: a subscription is the caller's own unless the caller administers users
|
||||
if (!AyaNova.Api.ControllerHelpers.Authorized.HasModifyRole(CurrentUserRoles, AyaType.User))
|
||||
newObject.UserId = UserId;
|
||||
await ValidateAsync(newObject);
|
||||
if (HasErrors)
|
||||
return null;
|
||||
@@ -78,6 +81,9 @@ namespace AyaNova.Biz
|
||||
AddError(ApiErrorCode.NOT_FOUND, "id");
|
||||
return null;
|
||||
}
|
||||
//case 4712: an update cannot move a subscription to another user unless the caller administers users
|
||||
if (!AyaNova.Api.ControllerHelpers.Authorized.HasModifyRole(CurrentUserRoles, AyaType.User))
|
||||
putObject.UserId = dbObject.UserId;
|
||||
if (dbObject.Concurrency != putObject.Concurrency)
|
||||
{
|
||||
AddError(ApiErrorCode.CONCURRENCY_CONFLICT);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user