128 Commits

Author SHA1 Message Date
737951548c case 4765 - changelog: release dates for 8.3.0 and 8.3.1; release procedure sets the date before the build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:23:10 -07:00
1393907bc7 case 4759 - changelog: Linux upgrade script runtime check; 8.3.1 section final
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:56:31 -07:00
541d56fc05 case 4751 - manual: subscription service updates, notices, downtime and times
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:36:39 -07:00
7d440530b3 case 4762 - direct email to a user: messages say "User", not "Vendor"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:32:20 -07:00
413af08918 case 4761 - emailed report PDFs have the content type application/pdf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:32:19 -07:00
36fe2eaf53 case 4630 - release procedure: client copyright year is automatic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:20:11 -07:00
3c3e431306 devdocs - Deferred: date picker Today/Now use the browser clock, not the time zone override (found in case 4683)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:06:46 -07:00
d4bec5b381 case 4739 - changelog; Deferred: fallback fixed, report default zone noted
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:58:28 -07:00
910e022016 devdocs - startsql.bat: PostgreSQL log to a fixed file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:29:48 -07:00
393a60c57d case 4763 - backup sets to keep must be at least 1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:11:20 -07:00
c52273f284 case 4685 - backup schedule logged at Debug level when it changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:01:07 -07:00
be500d2279 case 4684 - server log records the next automatic backup at startup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:01:06 -07:00
d34adeb321 case 4652 - changelog wording
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:57:51 -07:00
e8b3486a9e case 4653 - deleting a record still in use is refused with "in use by" instead of a server error
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:57:42 -07:00
6d3b169f23 case 4652 - a record that no longer exists gives "not found" instead of a server error; reports leave it out
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:40:01 -07:00
91e8f54074 case 4759 - upgrade from 8.2.4 or older: reminder of the 8.3.0 pre-upgrade step, Linux upgrade scripts check for the .NET 10 runtime
The upgrade overview page has a section for upgrades from 8.2.4 or older, and
the 8.3.1 changelog section opens with a line pointing to it (the "new version
available" notice links to the newest section); the release procedure's
version bump now carries that line into each new section. Both Linux upgrade
scripts first check for the .NET 10 ASP.NET Core runtime and stop with nothing
changed if it is missing (tested on devtest: present, dotnet not found, only
an older runtime). The desktop script's download now uses curl -f, as the
server's does since case 4727, so a failed download stops before files are
removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:38:48 -07:00
ea0a3615ee case 4755 - automatic backup no longer skips a day after a restart or a manual backup
SetNextBackup counted "tomorrow" from the day the server started, using the
newest backup file as the last backup, so a restart between midnight and the
backup time (every Thursday reboot), a manual backup, or a backup finishing
after midnight skipped a day. The next backup is now simply the next time the
backup time comes round on the server's clock. A time that happens twice when
the clocks go back runs at the first one only. Tested with a minute-by-minute
simulation over both daylight saving changes (one backup every day; the old
code skipped days) and on the local server. Changelog line; two Deferred
entries (dead inputs, safe headers missing on handler 500s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:27:23 -07:00
3b80f019ad case 4757 - manual revision stamp and copyright year set automatically when the manuals are built
A MkDocs hook (docs/8.0/rev_date.py), used by both manuals, replaces REV-AUTO
and YEAR-AUTO in the copyright line with the date of the last commit that
changed that manual, or today if it has uncommitted changes or git is not
available. A missing placeholder fails the strict build. The customer manual's
stamp had not been updated since 2022. The home page sentence about the stamp
no longer reads like a placeholder. Release procedure step 2, the docs
Dockerfile usage lines and .gitignore (__pycache__) follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:58:07 -07:00
2968ae7dc7 version bump 8.3.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:50:02 -07:00
bd8896fdb6 case 4754 - licence text: forum registration sentences removed
The AyaNova support forum is being retired, and public registration had been
closed for years. Removes "register on our AyaNova Support Forum" from the
"Data obtained" and "Use and Sharing of Data" sections and the forum data
deletion clause (iv); the purchase information clause becomes (iv).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:08:34 -07:00
ad5b797854 case 4690 - release procedure: publish order, check downloads before the droplet upgrades
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:25:51 -07:00
903f14ba1d devdocs - CLAUDE.md: check Sockeye for low-level server fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:37:33 -07:00
3968302f75 case 4690 - Deferred: browser without a time zone name falls back to New York
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:54:20 -07:00
557cca3472 case 4690 - record: final build, staging, devtest, tags
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:28:36 -07:00
05cf4df632 case 4690 - manual revision date 2026-10-26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:46:24 -07:00
15094275af case 4690 - changelog for 8.3.0 finalised; two-factor wording on the password page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:46:19 -07:00
d37688494a case 4690 - record: final gate, release candidate build, Cypress 27/27, performance figures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:16:57 -07:00
c19a3b047f case 4690 - Deferred: shutdown ObjectDisposedException is pre-existing, cause and fix recorded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:46:00 -07:00
1dd6783aff case 4690 - release procedure: release test droplets are temporary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:42:33 -07:00
be29f49ec1 case 4690 - release procedure: one documented sequence from code-complete to announcement
devdocs/build/RELEASE-PROCEDURE.md replaces John's notes from several info
cases: release order with subscription droplets upgraded before the
announcement, git tagging, scp uploads, the download-back check, upgrade
commands with curl -f, the one-time 8.3.0 droplet steps (harden-830.sh), the
weekly OS update and the full version bump list. BUILD.md's partial version
table now points to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:27:48 -07:00
c6c9e6c5d3 case 4727 - docs: Linux server permissions, service security settings, 127.0.0.1 behind NGINX
Install: program files read only for the service account, config.json 640,
systemd security settings in the service file, and a final step binding
AyaNova to 127.0.0.1 once NGINX works. Upgrade: the pre-8.3.0 step sets the
same permissions instead of re-adding group write on the program folder,
offers the security settings as a drop-in, and the upgrade commands use
curl -fO so a failed download stops before the program files are deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:14:05 -07:00
ad5da2ffa5 case 4727 - record: F36 and F38 fixed for hosted, case done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:00:02 -07:00
b9035b93b0 case 4727 - record: F10 moved to case 4736
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:43:35 -07:00
72a64159c4 case 4735 - record marked done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:33:50 -07:00
bf1e447eee Docs cases 4699, 4700, 4633, 4661, 4636, 4634, 4623 - record
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:33:31 -07:00
2bcd51c21d case 4636 - docs: v7 migration with two-factor authentication or a default SuperUser password
Turn two-factor authentication off on the AyaNova 8 SuperUser for the
migration (the V8 Migrate plugin can not log in with it). On a server with a
purchased license the SuperUser password must first be changed from the
default (case 4725).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:33:13 -07:00
4ca6c9b2e3 case 4623 - docs: what the v7 to v8 migration erases and keeps
The erased-database section said "all user entered data ... except"; it now
lists what is erased and what is kept, from the erase code (tax codes are
erased by the migration, AyaNova 7's replace them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:33:13 -07:00
5afd804586 case 4634 - docs: checkbox custom fields on existing records
Records created before a True / False custom field was enabled have no value,
so data tables show nothing for them until it is set and saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:33:02 -07:00
aca09a8c85 case 4661 - docs: password guidance
Removed the advice to use song lyrics, quotes or poem lines (guessing tools
try those and digit-for-letter swaps first). Now: no length or character
rules; use a password manager or six dice-chosen words from the EFF long
word list; two-factor authentication.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:33:02 -07:00
8a2b5d4829 case 4633 - docs: backup utility path may contain spaces
Confirmed fixed by the port (pg_dump run directly, no shell): a backup with
AYANOVA_BACKUP_PG_DUMP_PATH in a folder path with spaces works. No manual
workaround to remove; changelog line extended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:33:02 -07:00
356c73d854 case 4700 - docs: restore when AyaNova connects with its own PostgreSQL user
pg_restore --no-owner --no-privileges --role=<user>, so the restored tables
belong to that user (otherwise the integrity check stops AyaNova).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:33:02 -07:00
176a90d3f3 case 4699 - docs: Linux desktop data folder
The manual had users create ~/.ayanova-data with sudo, but AyaNova runs with
sudo so ~ is root's home and that folder was never used. AyaNova creates the
data folder itself; the step now says where it is (/root/.ayanova-data) and
how to choose another location.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:33:02 -07:00
a715c3913c case 4602 - record
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:11:41 -07:00
a9be1ba9e7 case 4628 - deleting a work order made from a service request reopens the request
The request's link to the work order item blocked the delete with a database
error (500). Deleting the item now unlinks any request accepted into it and
sets it back to Open, in the same transaction, with a history entry and no
notification. Service request manual page and changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:52:34 -07:00
a9d5cab7e5 case 4682 - automatic backup runs at the chosen time on the server's clock all year
The backup time is stored as the UTC moment it meant on the day it was set
and was run at that UTC hour every day, so on servers with daylight saving it
ran an hour off the chosen time for part of the year. SetNextBackup now
recovers the clock time with the stored date's offset and runs it on the
server's local days. No migration. Backup manual page and changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:09:50 -07:00
e5b1f1dc14 case 4687 - closed without change, idle cost measured (record only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:24:16 -07:00
c55b2fc723 case 4703 - report script console.error details in render errors
The page log showed console.error objects only as JSHandle@object and errors
without their stack. Each ERROR line is still logged at once; object and
Error arguments are then fetched from the page and added as detail lines
(object JSON, error stack without Puppeteer frames and with file names only).
A failing render waits up to 2 s for pending details. Page log appends are
locked. Report designer manual (Script errors) and changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:14:52 -07:00
64edeea874 case 4707 - record; release checklist: Cypress expected server version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:04:48 -07:00
1c97678498 case 4706 - remove the unused RunProgram.Run and its shell helpers
Run, RunWindows (cmd.exe /C), RunLinuxShell (/bin/sh -c) and RunOSX have had
no callers since the backup moved to RunNoShell. Backup through the API and
the integration suite unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:02:40 -07:00
b3b7fa6a9d case 4705 - licence fetch and trial request fall back to every licence server
DoFetchAsync rethrew a connection error after the fourth server, so
sockeye.ayanova.com was never tried; it now rethrows after the last one. The
trial request (rvr) went to rockfish only and now uses the same fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:43:54 -07:00
4aa9ee54e6 case 4697 - relative date filters use the user's time zone
Token date ranges (*today*, *lastmonth* ...) are built from the user's local
time in clientTimeStamp but were converted to UTC with ToUniversalTime(), the
server's time zone. They are now converted with clientTimeStamp.Offset. Lists
and dashboard widgets share this builder. Deferred: daylight saving edge and
dashboard grouping by UTC day (both need the client's time zone name).
Changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:26:55 -07:00
8f7396a021 case 4696 - confirmed resolved by 4711 on a standalone install (record only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:46:20 -07:00
ba84ebf7c9 case 4695 - Visual C++ runtime DLLs shipped with the embedded PostgreSQL
msvcp140.dll, vcruntime140.dll and vcruntime140_1.dll (14.44.35211.0,
Microsoft-signed) in postgres-standalone\bin, app-local, so the embedded
PostgreSQL starts on Windows without the Visual C++ runtime. Changelog;
BUILD.md notes they are replaced by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:45:39 -07:00
450acc5ef7 case 4694 - standalone setup stops a running embedded PostgreSQL first
The 8.2.4 and older launchers left PostgreSQL running after the in-app
shutdown, so every standalone upgrade stopped at "files in use" and could not
close it. PrepareToInstall and uninstall now run the installed pg_ctl stop.
Changelog; BUILD.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:45:39 -07:00
7959ad9cec case 4735 - manual: travel rate roles, customer service requests by customer contacts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:58:27 -07:00
27b56db6b0 case 4735 - role rights match the documentation
Audit of every documented role right against BizRoles, decided with John:
Tech restricted reads tax codes, projects, unit models and meter readings;
Business administration reads server metrics; Accounting no longer enters
meter readings (both meter reading routes checked Unit rights, now meter
reading rights); no Subcontractor roles on memos; integrations: data
operations open to all full staff roles, switching Active and deleting are
Business administration only. Integrated applications page and changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:58:27 -07:00
ed641c4985 case 4654 - every direct notification message to a user is delivered
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). Changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:27:34 -07:00
a53a4b6ef7 case 4692 - the SuperUser account can not be deleted
UserBiz.ValidateCanDelete refused a delete only when the user had related
records, so SuperUser with none could be deleted and nothing could bring it
back. User 1 is now refused (2004) before any other check. Changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:18:39 -07:00
b116034dc9 Release 8.3.0 cases: 4735 roles audit recorded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:14:26 -07:00
a807693187 case 4719 - report templates no longer hold the renderer's token or download key
The report page gets placeholders for the token and download key; request
interception (ReportApiRelay, now in every kind of install) puts the real
values only on the page's requests to ayApiUrl, then relays them (IIS) or
lets the browser continue. Requests to other addresses go out unchanged,
without credentials. Notification reports keep their rights. Report
scripting manual and changelog updated. F20 fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:04:55 -07:00
2f616cba7a case 4719 - report templates changed only by Business administration
Business administration - restricted could create, edit and import report
templates, although the roles page uses exactly this as its example of a
view-only restricted role. A template's script runs with the rights of
whoever renders it (SuperUser for customer notifications), so changing one
is an administrator right (F20). Report templates page and changelog
updated. Deferred: audit every documented role right against BizRoles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:33:35 -07:00
a6447ecb37 case 4632 - reports render under IIS: the report page's API calls are relayed by the server
Inside IIS (in-process) AyaNova has no port of its own, so the report page
could not reach the API: user reports called http://127.0.0.1:<request port>
(reset on https), notification reports 127.0.0.1:7575 (refused). Under IIS the
page's requests under the API URL are now intercepted 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; notification reports use the
latest signed-in request, else the override. Outside IIS nothing changes.
The notification token check requires a request from this machine, no longer
host 127.0.0.1. Manual (IIS install, override page) and changelog updated.
Deferred: emailed PDF content type application/pdf/pdf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:04:24 -07:00
ce5fb9e11c Release checklist: download every published installer back and check its version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:44:44 -07:00
e9b79242fd case 4579 - Windows installers: program folder no longer writable by all users
Both installers drop users-modify from the program folder (and local-postgres
in the single-user installer) and run icacls to remove the Users group's own
grants (by SID) from the program folder after copying files, so upgrades over
8.2.4 lose them too. The data folder keeps users-modify and config.json stays
readable by local users (the running account is not known at install time);
both documented in the single-user and LAN install pages. Changelog updated.
F2 fixed (write access), the rest accepted. Deferred: report on a missing
record fails with a generic error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:39:00 -07:00
c04de33af2 case 4732 - trusted reverse proxies setting
New config setting AYANOVA_TRUSTED_PROXIES (IP addresses or CIDR networks)
for reverse proxies on another computer: their X-Forwarded-For is used, a
chain of trusted proxies is followed, invalid entries are warned at boot.
X-Forwarded-For from a proxy that is not trusted logs one warning per server
start naming the proxy's address. Without the setting nothing changes (only
a loopback proxy is trusted). Shown in server info; manual page, Linux install
note, changelog. F6 follow-up recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:58:01 -07:00
344106ae27 dev-raven.bat: client dev server starts in the ayanova folder
Since c2f2f922 the script opens raven-client's root in VS Code, and the
window running appdev.bat inherited that folder, so npm run serve could
not find package.json. Start that window in raven-client\ayanova.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:55:43 -07:00
b99cbf126b Release 8.3.0 cases: 4579 plan recorded (awaiting approval); Sockeye deploy note for 4726
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:34:54 -07:00
d084a461ea Security review: summary status of F10, F12, F36, F39 brought up to date
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:31:41 -07:00
07d2b07fab case 4727 - remove the unused Docker option
dist/docker (a runtime dockerfile and a compose file that published
PostgreSQL with a fixed password) was from early development and never
offered. Removed with its commented-out lines in build-release.bat and
the BUILD.md mention; the build-environment Dockerfiles for the docs and
the client stay. F38 (4727 step 7, the last step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:30:31 -07:00
1ccbd7745c case 4727 - safe security headers on every response
Every response carries X-Content-Type-Options: nosniff, X-Frame-Options:
SAMEORIGIN and Referrer-Policy: strict-origin, so self-hosted installs
get them too (the hosted nginx sends the same values). F35 (4727 step 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:01:38 -07:00
cce07f3e6b case 4727 - attached-by override for admins only, export download by its owner only
OverrideUserId on attachment upload is used only from SuperUser or a user
with change rights on User, otherwise ignored. Export files carry the
user's id in their name and export/download serves only the key user's
own export files. F25 (4727 step 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:39:50 -07:00
b0e3933a9f case 4727 - report template export only to users who may use or edit it
report/export/{id} by download key gives a template only to a user whose
roles include one of the template's roles, or who may edit templates (a
user with no roles got any template). Job owner check accepted (no owner
recorded; random job ids). F22 (4727 step 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:30:47 -07:00
973b64a682 case 4727 - DB ID and licence to staff only
server-info returns the DB ID and licence, and global-biz-setting/client
the DB ID and licensee, only to Service and NotService users, as the About
form already shows them; customer contacts and subcontractors get empty
values. F29 fixed, AyaNova half of F39 done (4727 step 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:19:38 -07:00
fa7affc91c case 4727 - API root page shows no licence details or DB ID
The anonymous page at /api/v8/ shows the version and its links only: the
licence block is removed and the support link no longer carries the DB
ID and licensee. F11 fixed (4727 step 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:05:49 -07:00
8dc0ffd481 case 4727 - John's decisions on F10, F12, F22, F25, F35, F36, F38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:57:27 -07:00
289058bad2 case 4727 - logo uploads checked by content, change rights, served sandboxed
Logo upload and delete need change rights on Global (was read rights,
which BizAdminRestricted has). The image type is taken from the bytes:
PNG, JPEG, GIF, WebP or SVG, anything else refused; the claimed type is
not stored. Downloads send nosniff and a sandbox Content-Security-Policy,
so an SVG opened by its URL runs no script. F23 fixed (4727 step 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:56:56 -07:00
eb1deff2ba case 4693 - form field lists published only when fully built
FormFieldReferenceList and FormFieldKeys built their static lists lazily
and published them before filling them, so requests arriving during the
first build after a restart saw a half-filled list ("is not valid").
Both are now built in a local and published when complete (volatile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:28:11 -07:00
d8be701bcd case 4726 - login lockout per login name and per address
Failed logins are counted in memory (util/LoginThrottle.cs): 10 per login
name or 50 per client address within 15 minutes, then attempts are refused
with 429 for the rest of the window without checking the password. A
correct password clears the login's count; loopback is not counted per
address. Each lockout is logged. F6 fixed (2FA part in case 4710).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:12:26 -07:00
80f81aea0f case 4725 - sessions end on password reset and login disabled, exact login route, default SuperUser limited when licensed
A password set by someone else (reset code, administrator) or a disabled
login replaces the user's session token and download key; changing one's
own password keeps the session. PasswordResetCodeExpire is cleared (was
DlKeyExpire). The token middleware exempts only the exact login and
notify/hello routes and refuses inactive users and users who may not log
in. With a purchased licence, notify/hello no longer offers the default
SuperUser login and a SuperUser with the default password may only change
it (SuperUserDefaultPasswordRoutes). F7, F8 and F9 fixed. Manual and
changelog updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:54:03 -07:00
4141217c04 case 4724 - SMTP certificate checked, password never returned
Mailer checks the SMTP server's certificate (trust and name; no revocation
check). New config setting AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE accepts any
certificate (boot warning, shown in server info, manual page).
The notification settings GET returns ******** instead of the SMTP
password; a PUT with ******** or empty keeps the stored password.
F30 and F31 fixed. Changelog and manual updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:09:02 -07:00
94a6f3b653 case 4731 noted in Deferred: setting for direct email to any address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:36:48 -07:00
f3ba5156c7 case 4723 - direct email and direct notification for staff only, record rights
notify/direct-smtp: customer contacts refused; 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). Staff may still give any
address (report scripts use it). The delivery-on check runs after these.
notify/direct-message: customer contacts and subcontractors refused, as the
client already hides it from them. F3 and F4 fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:30:08 -07:00
52d66bcf10 case 4722 - scheduled work order widget checked; performance changelog line to confirm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:10:59 -07:00
7204b9ff6d case 4722 - F34 fixed, case done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:06:47 -07:00
9fe52514b9 case 4721 - F33 fixed, case done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:19:56 -07:00
823bd5029a Release 8.3.0 cases: 4632 and 4719 moved to the end, to be investigated on an IIS + TLS test setup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:32:36 -07:00
d254affc8b case 4720 - restricted roles get filtered work order items in report data, rendering and export
Opening a work order filters it for restricted users (TechRestricted,
SubContractor, SubContractorRestricted): only items they are scheduled on, and
on those only their own labour, travel and scheduling, and for some roles no
parts, units, expenses etc. Report data, rendering and export went through
WorkOrderGetPartialAsync, which filtered nothing for child types (security
review F27). Two causes:
- the partial fetch now applies the same filtering: the item only if the user
  is scheduled on it (checked in the database), then its details filtered; the
  existing per-item filtering moved unchanged into RemoveRestrictedUserItemDetails
  so both fetches share it;
- BizObjectFactory created WorkOrderBiz with UserType.Service for everyone, so
  in these paths a subcontractor was never treated as one; it now passes the
  user's real type (only the subcontractor flags depend on it).

Checked: report data for a restricted subcontractor on work order 1 went from
the whole item with parts to none; scheduled on an item, it gets the item
without parts or other users' labour. Full suite: 111 passed, 5 skipped, the
same 2 known failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:27:59 -07:00
ee59efff48 case 4713 - customer contacts hold and act with customer roles only
Users who may edit contacts but not users (Service, Sales, Accounting) could
give a contact login any role, BizAdmin and OpsAdmin included, and roles are
what every check uses (security review F28). Now:
- saving a Customer or HeadOffice user with any role other than Customer or
  CustomerRestricted is refused (error on Roles);
- UserBiz.EffectiveRoles masks a contact's roles to the customer roles wherever
  they are applied: the token middleware, download-key routes, and the
  notification subscriber check, so contacts that already have staff roles
  stored act without them.

Checked: Service creating a contact with BizAdmin refused, with Customer works;
a contact with OpsAdmin stored in the database gets 403 downloading a log file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:59:35 -07:00
6edb57c749 case 4712 - John's click-through as customer contacts passed; contact attachments checked
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:50:12 -07:00
87fdc12dce case 4712 - notification subscriptions are the caller's own; object events only to subscribers who may read the record type
Step 6 of 4712 (security review F17). A subscription kept the UserId sent by
the client, and object events (created, modified, deleted, age) were matched by
record type only, so a user could get record names of types it may not read,
delivered to any address. Now a subscription is created for the caller and an
update cannot move it to another user, unless the caller administers users; and
object events skip subscribers without read rights on the record type.

Checked: Service's subscription asked for SuperUser is saved as its own; with
"customer created" subscriptions, Inventory (no read on customers) got 0 events,
Service 1. Full suite: 109 passed, 5 skipped, the same 2 known failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:39:40 -07:00
351eb2d851 case 4712 - service schedule needs work order, quote or PM rights
Step 5 of 4712 (security review F16, staff side; contacts are already refused by
step 1). schedule/svc and schedule/user had no role check, so any login got the
whole service schedule (work order numbers, customer names, technicians). They
now need read rights on work orders, quotes or PMs, the test the client uses to
show the Schedule menu, so no role that sees the schedule loses it; only
Inventory and OpsAdmin roles (no menu) are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:25:23 -07:00
443d382394 case 4712 - customer contacts' pick lists limited to their own records
Step 4 of 4712 (security review F15). Pick lists checked only the Select role,
which customer contacts have for most types, and took the columns from the
request, so a contact could list every customer with phones and emails. For
contacts now: the customer list holds only their own customer (head office
contacts: their head office's customers); the unit list only their own
customer's units (head office contacts: the chosen customer, which must be
theirs; none until one is chosen); unit models as before; any other list 403.
pick-list/single names only those records. New CustomerPickList variant for a
single customer. Staff unchanged.

Checked: the contact's customer list went from 72 customers to its own one;
units only its own; vendors and users 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:14:43 -07:00
692fececc7 case 4716 - unit pick-list customer variant parsed as a number
UnitPickList.GetVariantCriteria put the value of a "customerid:<value>"
variant from the request into the query unparsed, reachable by any login
(security review F43, found during case 4712). The value is now parsed as a
long; anything else means no restriction, as 0 already did. The client's
format ("customerid: 5") still works.

Checked: new test fails on the old code, passes now; pick-list tests 10/10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:08:01 -07:00
1e32d59b8f Security review: F43, unit pick-list variant put into SQL unparsed (found during case 4712)
Also corrects the pass 6 note that all pick-list variants were parsed or matched
to fixed strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:03:12 -07:00
d75c3eecea case 4712 - customer contacts work only with their own service requests and units
Step 3 of 4712 (security review F14). The Customer role has change rights on
service requests and nothing checked whose they were. For customer contacts:
another customer's request is not found (read, update, delete); creating or
moving a request to a customer the contact does not own is refused (403); a
contact's request is always requested by that contact. unit/customerunit adds
units only to the contact's own customer (or one of its head office's).

Checked: another customer's request 404 (was 200), create for another customer
403, own request created and deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:02:19 -07:00
bd4494abac case 4712 - customer contacts reach only their own customer's work orders
Step 2 of 4712 (security review F13). The customer work-order rights checked
only tag-based feature switches, never which customer a work order belongs to.
New UserBiz.CustomerContactOwnsCustomerAsync (own customer, or for a head
office contact a customer of its head office), used by:
- workorder/customer/{id}: another customer's work order is not found (404);
- customer work order report (-100): exactly one work order, which must be the
  contact's (the renderer used every id it was given);
- work order attachment list and download for contacts; other attachment types
  are refused to contacts (the customer screens show them only on work orders).

Checked: other customer's work order 404 (was 200); report on another's or on
two work orders not found; own customer report still accepted (202).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:52:31 -07:00
2b1adaa496 case 4712 - customer contacts limited to the routes the customer screens use
Customer contacts had the Customer role's Select and read rights on most record
types and could call any route whose staff role check they passed, including
several with no role check (security review F13 to F17). Step 1 of 4712: the
token middleware now answers 403 (same body as other 403s) to Customer and
HeadOffice users for any route not in CustomerContactRoutes, the routes the
customer screens use (read from raven-client). Anonymous routes are always
allowed. New routes stay closed to contacts unless added on purpose.
Ownership of the records these routes return follows in the next steps.

Checked: full integration suite 103 passed, 5 skipped, the same 2 known
failures as the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:44:02 -07:00
7b40caec8b case 4681 - done in sockeye: F40 fixed; repeatable fetch and fingerprint noted as a later case
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:55:10 -07:00
445f9b0d0a case 4717 - KPI interval accepted only as a known date unit
Six KPIs put the request's interval criterion into the query as
DATE_TRUNC('<interval>', ...) unchecked, reachable by staff down to
TechRestricted (security review F26). New KPIFetcher.DateTruncUnitOrNull
accepts only PostgreSQL's date_trunc unit names (any case); the seven KPIs that
read interval refuse anything else with "Invalid 'interval' criteria", the same
form as their existing missing-criteria errors. The client only sends day or
month. (WorkOrderByStatusList reads interval without using it; checked too.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:36:52 -07:00
b1944b7ca3 case 4716 - pick-list search text passed as SQL parameters
PickListSqlBuilder put the search text and the tag search text into the SQL
inside quotes with no escaping, reachable by any login (security review F18);
an apostrophe in a search also made the query fail. The builder now writes
@plq / @pltag and PickListFetcher adds them as Npgsql parameters (same %text%
match; wildcards typed by the user still apply).

Changelog: searching selection lists for text with an apostrophe now works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:28:43 -07:00
e58ab2c100 case 4715 - download file names checked
report/download and export/download (any download key), the log-file routes and
backup download built a path from the file name in the URL with Path.Combine
and no check, so on Windows a name with a folder part or an absolute path could
reach files outside the folder (security review F21). New helper
FileUtil.FullPathInFolderOrNull accepts only a bare file name that resolves
directly inside the folder; TemporaryFileExists and BackupFileExists use it
(the download routes already call them first), and so do both log routes.
A refused name gets the same 404 as a missing file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:20:25 -07:00
705a54f9d8 case 4718 - report and translation import need create rights
report/upload and translation/upload had no role check, so any login, customer
contacts included, could import a report template (whose JavaScript runs in the
server's browser at render time) or a translation (security review F19, F24).
Both now require the same right as creating one, checked before the upload is
read. The import screens are already limited to those roles.

Checked locally: customer contact and Service 403, BizAdmin 202.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:10:00 -07:00
a843bd3132 case 4711 - JWT signing key generated by the server; AYANOVA_JWT_SECRET removed
The LAN/IIS and Linux templates shipped a placeholder secret printed in the
manual, and installs that kept it signed logins with a public key; any setting
also invited short or reused values (security review F1). The setting is now
ignored (one warning at boot if still present): the server generates a key once
(48 random bytes) into jwt-signing.key in the data folder (beside the app if
there is none; owner-only on Linux) and reuses it, so sign-ins also survive a
restart where the server used to make a new key each boot (standalone, Linux
desktop). If the file cannot be used, a temporary key is used for that run.

Templates and dev launch configs drop the setting; manual updated (key page
rewritten, setting steps removed from install pages); changelog line.

Checked locally: token forged with the old placeholder 401; key file created
and reused after restart (old token 200); auth and 2FA tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:01:08 -07:00
5bf1f2700a case 4710 - done in raven and sockeye: F5 and F41 fixed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:36:50 -07:00
141bdeb89c case 4710 - 2FA step requires the temp token from the password step
The 2FA step matched the user by temp token alone, and the token was left
empty after a successful 2FA login (null right after turning 2FA on), so an
empty or missing token skipped the password step (security review F5).
Now: an empty or missing temp token is refused before the lookup; the token is
cleared to null after success; after 5 wrong codes the token is cleared, so the
password must be entered again (counted in memory per user).

Changelog: general security notice for self-hosted upgrades, and the one
behaviour change users can see. SECURITY-REVIEW.md: F5 fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:28:17 -07:00
27e187bac6 Release 8.3.0 cases: John's edits of 2026-10-24
4713 takes F28; 4698 folded into 4711; F2 moved into 4579 (now priority 00);
4665 moved to the release after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:42:32 -07:00
229fe208a7 Release 8.3.0 cases: Sockeye case 4681 takes F40 and the Sockeye half of F39
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:17:04 -07:00
67205ccf41 Release 8.3.0 case list, and security findings mapped to cases
John's "next release" cases (exported 2026-10-24) listed in NET10-PORT.md;
SECURITY-REVIEW.md maps each finding to its case. F28 has no case yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:09:59 -07:00
ba60825167 Security review: finding IDs are permanent
Cases will refer to finding numbers, so they are never renumbered or reused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 06:00:25 -07:00
8a54c020c5 Security review: John's decisions on F41, F42 and F32
2FA turned off on the affected accounts until the F5/F41 fix; F42 key not
rotated now, moving it out of source is a later case; FastMail password
rotation after the old Sockeye server is retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:15:16 -07:00
d6a7e024f0 Security review pass 11: Sockeye public routes; all passes done
From code: the 2FA flaw (F5) is also in Sockeye and the business server (F41,
critical; mitigation: turn 2FA off until fixed); the licence-signing private
keys are hardcoded in Sockeye's source (F42); one-shot licence fetch by DB ID
(F39); trial request routes without rate limits (F40). Review only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:51:03 -07:00
234d96aaa6 Security review: John's answers on F36, F37 and F9
Hosted droplets have a restrictive cloud firewall (F36 re-rated low); standalone
PostgreSQL trust accepted (F37); default SuperUser password is deliberate for
trials, but the licensed-mode change is enforced by the client only (F9 note).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:48:44 -07:00
40eb36080b Security review pass 10: deployment
From code: AyaNova listens on all interfaces on plain HTTP 7575 in every
template, hosted droplets included, with no firewall from the setup script (F36);
standalone PostgreSQL trusts local connections (F37); Linux hardening (F38).
F2 confirmed and extended; F9 extended (hosted droplets start with the default
SuperUser password). Review only, no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:40:46 -07:00
fc3342df48 Security review: F34 client library upgrades approved for 8.3.0
John's decision: DOMPurify to 2.5.x and Vuetify to the latest 2.x (2.6.16 as the
fallback if 2.7 does not build on Node 12.22.9), as an exception to the
front-end dependency rule, each proven by a full client build and Cypress.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:38:59 -07:00
52763366c2 Security review pass 9: client, headers, CORS, Swagger
From code: some record text is rendered as HTML without escaping (F33); shipped
DOMPurify and Vuetify are behind on security fixes (F34, needs a decision on
front-end packages); headers, CORS, Swagger and keys in URLs (F35). npm audit
run read-only and triaged. Review only, no code or dependency changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:33:26 -07:00
f1f01b47f1 Security review pass 8: email and notifications
From code: F3 and F4 confirmed; F17 confirmed and re-rated medium (names and
links reach any address, no reports). New: SMTP certificate never checked (F30),
SMTP password returned to read-only roles (F31), SMTP credentials in the DEBUG
defaults of raven and sockeye source (F32; password to be rotated). Review only,
nothing sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:28:02 -07:00
f90dfe98c7 Security review pass 7: staff roles
F27 (high, confirmed on the local server): restricted roles get whole work-order
items through report data, rendering and export. From code: users with change
rights on Customer can give contact logins any role (F28); server-info gives
the DB ID and licence to every login (F29). Remaining routes without a role
check found OK. Review only, no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:23:42 -07:00
8642d6e627 Security review pass 6: raw SQL
From code: KPI interval criterion placed in SQL unchecked, reachable by staff
down to TechRestricted (F26, critical). F18 (pick-list search text) completed
by code. Data-list filters, sort, ClientCriteria, saved filters, search, other
KPI criteria and remaining raw queries traced and found safe. Review only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:17:02 -07:00
52c85f3aaa Security review pass 5: file handling
From code: logo upload accepts any content type and is served inline from the
app's origin (F23); translation import has no role check (F24); attachment and
export gaps (F25). F21 extended to the log and backup download routes; backup
delete found safe. Review only, no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:11:05 -07:00
21c55e8d01 Security review pass 4: report rendering
F19 (critical, confirmed on the local server): report template import has no
role check, so any login, customer contacts included, can add a template whose
JavaScript runs in the server's browser. From code: F20 (templates get the
renderer's token, SuperUser for customer notification reports, and unrestricted
network access), F21 (download file names from the URL not checked; Windows),
F22 (minor job and template gaps). Review only, no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:04:23 -07:00
07c10f6542 Security review: draft customer contact route allowlist for the F13 to F17 fix
Routes the client calls for customer contacts, read from raven-client, with the
ownership check each id route needs, and the proposed shape of the fix
(allowlist in the token middleware plus per-route ownership checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:00:31 -07:00
2fca018add Security review: F5, F1 and F18 also fixed in 8.3.0; findings gate the release
John's decision: 8.3.0 is not released until every finding is fixed or
explicitly accepted. The review therefore runs before the release, not after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:58:31 -07:00
ee6def1932 Security review: F13 to F17 (customer contact isolation) fixed in 8.3.0
John's decision: urgent, "next release" cases before 8.3.0; hosted and
self-hosted installations equally. Recorded in SECURITY-REVIEW.md and in the
NET10-PORT.md list of cases for this release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:55:50 -07:00
2004bf197a Security review pass 3: what customer contact users can reach
Confirmed on the local server as the seeded contact: any customer's work order
by id (F13), other customers' service requests (F14), the full customer list
with phones and emails through pick lists (F15), the whole service schedule
(F16). Candidates from code: notification subscriptions for another user (F17),
pick-list search text placed in SQL unescaped (F18). Review only, no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:50:21 -07:00
26173b3926 Security review pass 2: login, tokens, password reset, 2FA, anonymous routes
F5 (critical, confirmed on the local server): the 2FA step accepts an empty or
missing temp token, so users with 2FA on can be logged into without a password.
F1 confirmed: with the placeholder secret a user's current token can be rebuilt.
F8 confirmed. New findings F6, F7, F9 to F12. Review only, no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:24:45 -07:00
2a9664dbe0 Security review pass 1: scope, threat model, attack-surface map, first candidate findings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:00:18 -07:00
67cc5eafad case 4690 - CLAUDE.md and NET10-PORT: port merged into main; release work continues on main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:24:43 -07:00
3663301101 Merge net10-port: .NET 10 port (case 4690) 2026-10-23 17:23:51 -07:00
140 changed files with 3660 additions and 778 deletions

2
.gitignore vendored
View File

@@ -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
View File

@@ -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",

View File

@@ -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 |

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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.

View 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.

View File

@@ -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) |

View 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** |

View 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
View File

@@ -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.

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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"]

View File

@@ -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

View File

@@ -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";

View File

@@ -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

View File

@@ -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"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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

View File

@@ -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.

View File

@@ -22,11 +22,9 @@ Full access
- Business administration
- Business administration - restricted
Read only access
- none
- Business administration - restricted
## How to access report templates

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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
![report edit form helpers tab](img/report-edit-form-helpers-tab.png)
@@ -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**.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"`

View 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"`

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 && \

View File

@@ -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 && \

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -8,7 +8,10 @@ site_name: AyaNova manual
site_dir: "../../../server/AyaNova/wwwroot/docs"
site_url: https://ayanova.com/docs/
strict: true
copyright: Copyright &copy; 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 &copy; 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"

View File

@@ -8,7 +8,10 @@ theme:
site_name: AyaNova Customer user manual
site_dir: '../../../server/AyaNova/wwwroot/cust'
strict: true
copyright: Copyright &copy; 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 &copy; 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
View 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

View File

@@ -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>

View 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}";
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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

View File

@@ -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());
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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));

View File

@@ -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),

View File

@@ -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 }));
}

View File

@@ -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));
}

View File

@@ -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

View File

@@ -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))

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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
{

View File

@@ -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");

View File

@@ -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;

View File

@@ -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));

View File

@@ -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)
{

View File

@@ -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;

View File

@@ -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())

View File

@@ -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))";
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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))

View File

@@ -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);

View File

@@ -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

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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"));
}

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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"));
}

View File

@@ -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?

View File

@@ -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