25 Commits
8.1.3 ... 8.2.1

Author SHA1 Message Date
c5de97bdfd version bump 8.2.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:10:07 -07:00
048fae4ac7 case 4779 - review imminent notice saved as the review's, so a changed or deleted review's notice is cleared
The notice was saved with the reviewed record's type (Review.SockType), so
the clean-up, which looks for type Review, never found it: each save of a
review added a notice, a deleted review's notice still went out, and its
link opened the record of the reviewed type with the review's id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:05:24 -07:00
32018cfd98 devdocs - trial request test emails example.com, which a DEBUG build doesn't send
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:51:25 -07:00
33894c3abb case 4688 - agent write tools for cases: create, update, add and replace notes, tags
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:51:25 -07:00
19b2093b98 case 4688 - agent read tools: get_case shows triage fields; list_cases with status, product and release filters
Also the review test uses a past review date, so it no longer schedules a
"review imminent" email for every such subscription (see case 4779).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:38:49 -07:00
59c6683e87 case 4688 - agent tokens can be made able to change records (schema 32)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:56:12 -07:00
d92d5b8fa0 case 4688 - agent calls: MCP SDK INFO lines filtered from the server log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:44:19 -07:00
b149766f0f devdocs - case 4688 phase 2 design: write tools for cases
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:36:21 -07:00
72ff2ca38f case 4745 - convert existing cases from the old tags (schema 31)
Status, Product and Release from the old tags with the rules and the
single-case decisions agreed with John against production's counts;
bug-report becomes bug, request becomes customer; the retired tags are
removed and the tag repository loses only their case uses. One DO block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:26 -07:00
d2aaaea95a case 4745 - case save answers with the stored Status, Closed and Release
The form showed and sent back what it had, not what the save stored, so a
status saved as Never showed blank and the next save reset it to New.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:52:57 -07:00
f17ee28b20 case 4745 - batch close sets the chosen status (Done or Never); reopening sets New
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:52:57 -07:00
ac606c8bfc case 4745 - case list Status, Product and Release columns; Status and Product value lists
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:21:50 -07:00
cecf5fc56c case 4776 - form field lists published only when fully built (AyaNova 4693)
FormFieldReferenceList and FormFieldKeys built their static lists lazily
and published them before filling them, so a request 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),
as AyaNova case 4693 (raven eb1deff2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:21:50 -07:00
5c38ac7fdb case 4745 - case Status, Product and Release fields (schema 30) and their rules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:26:45 -07:00
bead8231ed devdocs - case 4745 design note: case Status, Product and Release fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:51:38 -07:00
0ac2e4efc3 case 4688 - AI agent access by MCP (phase 1): agent Users, agent tokens, rate limits, read-only tools; schema 29
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:19:12 -07:00
f51e30803f case 4678 - passkey sign-in (Fido2), schema 28; superuser reset also turns off two-factor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:46:27 -07:00
60b456e697 case 4738 - security advisory monitor (Ubuntu, PostgreSQL, CISA KEV), schema 27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:12:37 -07:00
537380fa53 devdocs - case 4688 design note: agent Users, licensing, rate limits, OAuth
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:12:37 -07:00
8af2244b10 devdocs - design notes for 8.2.0 cases 4738, 4678, 4688
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:35:19 -07:00
a04e27acaa version bump 8.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:16:48 -07:00
8faac1c571 case 4769 - remove the subscription server last update age notification
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:46:50 -07:00
670e68de34 case 4675 - remove purchases and vendor notifications (unused since 2023); products and vendors stay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:28:18 -07:00
b9a21ba220 case 4487 - trial request: approve or reject while awaiting email validation; delete works
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:08:08 -07:00
48c3b83430 version bump 8.1.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:48:54 -07:00
78 changed files with 5741 additions and 3477 deletions

View File

@@ -0,0 +1,95 @@
# Case 4678 — Passkey sign-in: design note
Status: decided 2026-11-02; being built. Release: Sockeye 8.2.0, then AyaNova.
Case notes: "Add passkey to sockeye first, confirm then add to AyaNova." So this is built for
AyaNova from the start, and Sockeye is the trial run. Everything marked **Decide** needs John's answer before code.
## How Sockeye signs in today
- `POST auth` checks the login and password. If two-factor is on, it returns a temporary token (`tt`) and the client asks for a TOTP code (`auth/tfa-authenticate`).
- Success goes through `ReturnUserCredsOnSuccessfulAuthentication`, which issues a 5-day JWT and stores it as the user's **single** `CurrentAuthToken`. Every request is checked against it, so a new login replaces the previous one.
- `LoginThrottle` (case 4726) refuses repeated failures by login name and address.
A passkey sign-in should end in the same `ReturnUserCredsOnSuccessfulAuthentication`, so everything after sign-in stays as it is.
## Library
- **ASP.NET Core 10's built-in passkeys belong to ASP.NET Core Identity.** Sockeye and AyaNova don't use Identity, so that support doesn't fit without a large change.
- **`Fido2` (fido2-net-lib)** is the established .NET library. NuGet today has **4.3.2** as the latest stable release (targets net8.0, which runs on .NET 10). 5.0.0 is still in preview (preview.9, 2026-10-29) and targets net10.0.
- **`Fido2` 4.3.2 stable,** approved by John 2026-11-02. Move to 5.x once it's released.
## Server
### Table (one schema step)
`auserpasskey`:
- id, userid (FK to auser, cascade delete)
- credential ID (bytea, unique), public key (bytea), signature counter, AAGUID, transports
- a name the user gives it ("YubiKey", "Phone")
- created, last used
### Routes (`auth/passkey/...`)
| Route | Who | Does |
|---|---|---|
| `register-options` | signed-in user | Returns creation options with a fresh challenge |
| `register` | signed-in user | Verifies the attestation and stores the passkey |
| `login-options` | anyone | Returns request options with a fresh challenge. Discoverable credentials, so no user name is needed first |
| `login` | anyone | Verifies the assertion, updates the counter, then calls `ReturnUserCredsOnSuccessfulAuthentication` |
| `GET` / `DELETE` list | signed-in user (or an admin, for any user) | See and remove one's passkeys |
- **Challenges:** kept in server memory for 5 minutes, keyed by a random ID that the client sends back. Sockeye runs as one process, so memory is enough. AyaNova is also one process per customer.
- **User verification is required** (a PIN or biometric on the device). A passkey sign-in is then two factors by itself, so it **skips the TOTP prompt**.
- **The throttle:** failed passkey sign-ins count against `LoginThrottle` by address.
- **Event log:** registering, removing and signing in with a passkey are logged, the same as password changes.
### Relying party (which site the passkey belongs to)
A passkey is bound to a domain, called the RP ID.
- Sockeye production: `sockeye.ayanova.com`. Local development: `localhost`; browsers allow passkeys on localhost without HTTPS.
- AyaNova: each customer's own host name.
So the RP ID and allowed origins come from configuration, `SOCKEYE_PASSKEY_RPID` and `SOCKEYE_PASSKEY_ORIGINS`, with a sensible default from the request host. They are never hard-coded.
## Client (Vue 2)
- **Login page:** a "Sign in with a passkey" button. It calls `navigator.credentials.get`, converting base64url to and from the browser's binary format.
- **User settings:** a Passkeys section that lists, adds and removes passkeys.
- **UI text uses translation keys,** because this goes to AyaNova (the standing rule for AyaNova-bound features).
- **Browser support:** all current browsers have passkeys. Sockeye's admin UI is used only by you.
## Decided (John, 2026-11-02)
1. **Password stays as a fallback** in 8.2.0. Later, an option lets a user turn off password sign-in once they have at least two passkeys.
2. **Admin recovery:** an admin can remove a user's passkeys. The superuser's break-glass reset (`SOCKEYE_SET_SUPERUSER_PW`, `AYANOVA_SET_SUPERUSER_PW`), which needs administrator access to the server, is the last resort. That is still standard practice for self-hosted software. It must restore plain password sign-in completely: set the password, turn off two-factor, and (once the option exists) turn password sign-in back on. Today it only sets the password, so a superuser who loses their authenticator app can't get in: AyaNova case **4775**. Sockeye's copy of the code gets the same fix inside 4678.
3. **One session per user** stays as it is (as in case 4688).
### Answers to John's questions
- **Mixed networks (self-hosted):**
- A passkey is tied to a domain name and only works over HTTPS (or on `localhost`).
- Users who reach the server at its domain name over HTTPS can use passkeys, including internal users if the office uses the same name (split DNS).
- Users who reach it by IP address or a plain-http local name can't. Browsers don't allow it, so they keep using their password and nothing breaks.
- Internal and external names on one domain can share passkeys: set the passkey domain to the parent domain and list both addresses as allowed origins.
- **How many passkeys:** up to 20 per user, each with its own name, each removable on its own.
- **QBI:** it signs in with login and password (with two-factor support) and is unaffected, because passkeys add a sign-in method and don't take the password away. A user who later turns password sign-in off can't use QBI; the setting and the manual must say so. Passkeys inside QBI itself (Windows' `webauthn.dll`) would be a separate case, if customers ask.
- **V8Migrate:** needs no passkey support and isn't blocked by passkeys. Its documentation should say to sign in with an account that uses a password, without two-factor, and without "password sign-in off".
### Documentation (AyaNova customer manual, with the AyaNova case)
- Passkeys need HTTPS and a domain name; how to set the passkey domain and allowed origins.
- Managing passkeys; recovery; the break-glass reset.
- QBI and V8Migrate: use an account with password sign-in on.
## Tests
- **Integration tests with a software authenticator:** the test project generates its own key pair and builds the `none` attestation and the assertion itself, so the full flow runs with no hardware. Cases:
- register, then sign in;
- a wrong challenge, a wrong origin, or a reused counter is refused;
- a removed passkey can't sign in;
- the throttle applies.
- **Hardware check:** you sign in once with a real device (Windows Hello or a phone) before release.
## Then AyaNova
The same table, routes and client pieces. Run `compare-with-raven.sh` to map the names. The changes for AyaNova:
- the RP ID comes from each customer's configuration;
- the customer manual needs a page on passkeys.
It would be its own AyaNova case, for 8.4.0.

View File

@@ -0,0 +1,148 @@
# Case 4688 — AI agent access (MCP): design note
Status: phase 1 built 2026-11-02 (Sockeye 8.2.0). Phase 2 (write tools for cases) built
2026-11-02, below; release Sockeye 8.2.1. First version 2026-11-01; revised 2026-11-02 after John's
answers on agent Users, licensing, rate limits and OAuth. Then AyaNova.
Case notes: let AI agents (Claude Code and others) use Sockeye directly for everyday tasks. Try it
on Sockeye first, then build the same into AyaNova. Everything marked **Decide** needs John's answer before code.
## Decided (John, 2026-11-02)
1. **The package `ModelContextProtocol.AspNetCore` 2.3.0** is approved. The test project uses `ModelContextProtocol.Core` 2.3.0, the same family, for its MCP client.
2. **Rate limits:** the defaults in the table above.
3. **Phase 1 tools:** as listed. John expects agents mostly to *do* things rather than look them up, so phase 2's write tools matter most. For now the tools serve testing and development lookups.
4. **Token expiry:** 90 days by default, with "never" allowed.
## As built (phase 1, Sockeye 8.2.0)
- **Agent flag:** `auser.agent` (schema 29), the "AI agent" checkbox on the user form. The superuser can't be an agent. Agents are refused at the login page, by password and by passkey.
- **Tokens:**
- **Table `auserapitoken`:** id, userid, name, SHA-256 token hash, read-only, created, expires, last used, revoked.
- **Routes:** `agent-token/{userId}` (list), `POST agent-token` (create; the token is in that response only), `DELETE agent-token/{id}` (revoke).
- **Rights:** User rights.
- **Phase 1 tokens are read-only.**
- **Last-use time:** written at most once a minute.
- **The gate:** `AgentAccess`, a middleware after Sockeye's usual one.
- Only `/mcp` is affected.
- It needs `Authorization: Bearer sk_...` for an active agent User.
- It applies the rate limits: one chained `PartitionedRateLimiter`, with a per-agent token bucket, a per-agent concurrency limit and a server-wide concurrency limit.
- The concurrency limits queue a few calls rather than refuse them. Over the limit the answer is 429 with Retry-After, and it's logged.
- It then sets the request up as that User, so the tools see its roles.
- A person's sign-in token is not accepted at `/mcp`.
- **Settings:** `SOCKEYE_AGENT_RATE_BURST` (30), `SOCKEYE_AGENT_RATE_PER_SECOND` (5), `SOCKEYE_AGENT_CONCURRENT` (4), `SOCKEYE_AGENT_CONCURRENT_TOTAL` (8).
- **The MCP endpoint:** `/mcp`, stateless streamable HTTP, `SockeyeMcpTools`.
- Each tool checks read rights for its record type, as the UI does.
- Each returns at most 100 records.
- **Audit:** tool calls go to the **server log** with the agent's name, not the event log. One event row per call would bloat the event log the way health checks did (case 4749). Changes made by phase 2 write tools will go in the event log like any other change.
- **nginx:** `/mcp` can answer as a stream (server-sent events), so the production nginx location needs `proxy_buffering off` for `/mcp`. Check this at deploy.
## Tests
- **Integration tests:**
- agent User:
- an agent User can't sign in on the login page;
- a person's account is refused at `/mcp`.
- tokens:
- a token works;
- a revoked or expired token is refused;
- a read-only token can't write;
- a person's browser session survives while an agent works.
- rate limits:
- the per-agent limits return 429 with Retry-After;
- one agent at its limit doesn't block a second agent or a person;
- MCP:
- connect with the SDK's client, list the tools, call each one;
- an agent User without rights to a type gets nothing from it;
- results are capped.
- **Live check:** you connect it from Claude Code and use it for a week before phase 2.
## Order across the three 8.2.0 cases
4738 (advisory monitor) touches nothing else and goes first. 4678 (passkeys) and 4688 both change
sign-in; passkeys go first so the sign-in code changes in one place at a time. Passkeys are for
people signing in on the web page; agent Users never use that page, so the two don't overlap.
## Phase 2: write tools for cases (built 2026-11-02)
John: agents writing cases matters most, because work waits while he enters or updates cases by hand.
No week of live use first: there's little to learn from contrived use, so it goes straight on.
Built on the case fields of case 4745 (Status, Product, Release).
### Decided (John, 2026-11-02)
1. **Any status**, the closing ones included (Done, Never, Duplicate). The event log records each change, and there are backups.
2. **Notes are added at the top**, John's way: the date and time, ` - `, the agent User's name, `: `
and the note; then a separator line `-=-=-=-=-=-=-` directly between it and the notes as they were.
Newest first, because it scans faster. A case with no notes yet gets no separator.
3. **Replacing the whole notes is allowed too**, to fix typos in a case being worked on. Sockeye only:
AyaNova's agent access needs its own thought about this.
4. **The agent account** has Accounting (can change cases) and BizAdminRestricted.
5. **Release:** with 4745 and 4776 in Sockeye 8.2.1. OAuth (claude.ai and Desktop) is a later phase.
### Tools
Every write goes through `GZCaseBiz`, as the case form does: the same validation (a new case needs a
Product, Built needs a Release), Closed following Status, the search index, the tag repository and the
event log (Created / Modified, by the agent User). A tool loads the case, changes only what it was
given and saves the whole record, so a field it wasn't given is never reset (the trap found while
building 4745: a save without Status reads as New).
| Tool | Does |
|---|---|
| `create_case` | name, product (required); notes, status (default New), release, tags, customer (id) optional. Returns the case number |
| `update_case` | case number, plus any of: name, status, product, release, customer. Only what's given changes |
| `add_case_note` | case number, text: added at the top in John's format |
| `replace_case_notes` | case number, the whole new notes, and the `concurrency` value from `get_case`. Refused if the case changed since it was read, so a person's edit is never lost |
| `set_case_tags` | case number, tags to add, tags to remove |
No delete: closing as Never or Duplicate is the way out.
The read tools change with them:
- **`get_case`** also returns status, product, release and concurrency.
- **`list_open_cases` becomes `list_cases`:**
- filters: status, product, release, tag;
- open cases only by default, `include_closed` to widen;
- oldest first on request, for the backlog review.
- **Names, not numbers:** tools take and return Status and Product by name ("Investigate", "AyaNova 7"), so an agent can't send a wrong number.
**The note stamp:** the date and time in the agent User's time zone (its user options time zone
override; UTC without one), so John sets the agent account to his own time zone once:
```
2026-11-02 14:30 - Claude: <the note>
-=-=-=-=-=-=-
<the notes as they were>
```
The agent's name is in the stamp (John, 2026-11-02): the event log says who changed the case, and
in the notes it shows at a glance which entries are John's.
### Tokens
- **A choice at creation:** a token is created read-only (as now) or **able to change records**. The choice is a checkbox in the user form's token section ("Can change records"), off by default.
- It needs translation keys, since agent access goes to AyaNova.
- Existing tokens stay read-only. At release John makes a new one for Claude Code and revokes the old.
- **What a write needs:** both a token able to change records and a User whose roles allow the change.
- **When either is missing,** the tool answers with a plain error saying which one.
- **Write tools are marked as such** for MCP clients (not `ReadOnly`; `replace_case_notes` also marked destructive), so a client can ask before using them.
### Logging
- **One server log line per tool call stays,** with the agent, the tool and the case number. It's the call record.
- **The MCP SDK's own Info lines go:** `ModelContextProtocol.*` is filtered below Warn, like `Microsoft.*`, except at Trace. In production they made four lines per call around the one that matters.
### Tests
- **Integration**, through the SDK's MCP client as in phase 1:
- a read-only token can't write;
- a writing token whose User lacks Change rights can't either;
- create a case, check its fields and its Created event;
- update one field and check the others are unchanged (status stays as it was);
- add a note: separator, stamp in the User's time zone, newest at the top;
- replace notes: works with the current concurrency, refused with an old one;
- tags added and removed, with the tag repository counts right;
- closing by status sets Closed;
- list filters by status and product.
- **Token form:** a token made with "Can change records" is listed as such.

View File

@@ -0,0 +1,94 @@
# Case 4738 — Security advisory monitor: design note
Status: proposal for John's review, 2026-11-01. Nothing built yet. Release: Sockeye 8.2.0.
The case notes are the spec; this note checks them against the live sources and the Sockeye code,
and turns them into a build plan. Everything marked **Decide** needs John's answer before code.
## What the live sources confirm (checked 2026-11-01)
- **Ubuntu releases:** `https://ubuntu.com/security/releases.json` lists `noble` = 24.04 LTS and
`resolute` = 26.04 LTS. Both codenames in the case are right.
- **Notices:** `https://ubuntu.com/security/notices.json?release=<codename>&limit=&offset=` returns
`{notices, offset, limit, total_results}`. Each notice has `id` (`USN-8958-1`), `published`,
`title`, `summary`, `cves_ids`, and `release_packages`: a map from codename to a list of
`{name, version, description, is_source}`. Match on the entries with `is_source: true`.
One notice covers several releases, so the same USN shows up for noble and resolute.
- **CVEs:** `https://ubuntu.com/security/cves/<id>.json` has `priority`
(critical/high/medium/low/negligible), plus `cvss3`, `published` and `updated_at`.
Priority is there as the case expects.
- **CISA KEV:** `https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json`,
about 1.8 MB with 1,766 entries (catalogVersion 2026.10.30). Each entry has `cveID` and `dateAdded`.
- **Still to do at build time:** check the DTO field names against `https://ubuntu.com/security/api/spec.json`,
as the case asks. The fields above came from live responses, not the spec.
## What the fleet actually runs (ayanova-2, read-only check)
- 26.04 (resolute), kernel `7.0.0-38-generic`: the kernel's source package is `linux`, as the case assumed.
- .NET (`dotnet10`) and nginx come from Ubuntu's own archive, so Ubuntu's notices cover them.
- PostgreSQL 15 comes from **PGDG**: the known gap in the case. It is a real exposure on every droplet that runs Sockeye or AyaNova.
- 451 distinct source packages are installed.
## How it fits Sockeye
| Need | Existing Sockeye piece to follow |
|---|---|
| Run every few hours | A new `generator/SockBotSecurityAdvisories.cs`, called from the `JobsBiz` loop with its own interval, like `SockBotSubscriptionServerHealthChecks` (static `lastSweep`, interval constant, shorter in DEBUG) |
| Send the alert | A new notify event type, delivered through the existing subscription system (email or in-app). Not a hard-coded email |
| Source failing for more than 24 h | `NotifyEventHelper.AddOpsProblemEvent`, which already reaches whoever subscribes to `ServerOperationsProblem` |
| Outbound HTTP | Use the same HttpClient pattern as the health checks, with a User-Agent of `Sockeye/<version> (support@ayanova.com)` |
| Settings UI | A new admin page. Sockeye-only, so plain English text, no translation keys |
### New event types (`NotifyEventType`)
- `SecurityAdvisoryAlert = 43`: high or critical priority, or listed in KEV. Sent immediately.
- `SecurityAdvisoryDigest = 44`: the optional Wednesday digest of medium-priority matches.
The numbers 39 and 42 were retired in 8.1.4 and stay unused.
### Tables (one schema step)
- `asecurityasset`: id, name ("Ubuntu 26.04 fleet"), platform (`ubuntu:resolute`), components (text[] of source package names), alert threshold, digest threshold, active. This is the case's asset/watchlist model; phase 1 holds two rows.
- `asecurityadvisory`: the processed-advisory store. Columns: source, source ID (unique together), published, title, url, cve IDs (text[]), matched assets and packages, normalised severity, raw severity, KEV flag, outcome (alerted / digest / ignored / no match), processed timestamp.
- `asecuritycve`: the CVE cache. Columns: id, priority, cvss3, fetched, updated_at. A kernel USN lists dozens of CVEs, so each CVE is fetched once and refreshed only when stale.
- `asecuritysource`: per-source health. Columns: name, checkpoint (newest processed ID or date), last attempt, last success, last error.
The code follows the case's expansion design. An `IAdvisorySource` interface has one implementation, `UbuntuUsnSource`, and `CisaKevEnricher` is a separate step. Only these two exist in phase 1.
## Behaviour beyond the case
- **First run must not alert.** Without a checkpoint, the job would treat every notice back to the start as new. The first run records the last 30 days as processed without alerting. That is the case's dry run, done once automatically.
- **Dry-run report on demand:** an admin route that re-runs the last N days, sends nothing, and returns what *would* have alerted, digested or been ignored. This is the noise check the case asks for before going live.
- **KEV can change after the fact.** A CVE is often added to KEV days after its USN is out. Each run therefore also checks advisories already processed in the last 90 days against KEV entries whose `dateAdded` is newer than the last check, and alerts once if one turns up.
- **One alert per USN,** even when it matches both releases. The alert lists both releases.
## Decided (John, 2026-11-01)
1. **Watchlist: full inventory.** The settings page takes the pasted output of `dpkg-query -W -f='${source:Package}\n' | sort -u` for each release. Priority, not the package list, controls the noise.
2. **Interval:** every 3 hours.
3. **Wednesday digest:** in phase 1.
4. **PGDG gap:** in phase 1, as a second source, the PostgreSQL project's own security announcements (feed to be found and verified at build time). It watches the major versions in use (15 today).
Why the gap exists: Ubuntu ships one PostgreSQL major per release (18 on 26.04, 16 on 24.04), and the droplets run 15 from PGDG, which Ubuntu's notices never cover. Standardising PostgreSQL versions and sources everywhere is a separate, later job.
## As built (differences from the plan above)
- **Severity lookups are expensive on kernel notices.** Ubuntu has no batch query for CVE priority, so each CVE is one request (about a second, 25–60 KB). A kernel notice can list about 1,000 CVEs (USN-8887-1, `linux`: 1,050). Three measures keep this manageable:
- Lookups run four at a time.
- Each priority is cached for 30 days, since a priority rarely changes once set. Other kernel notices listing the same CVEs then cost nothing.
- The first run of a source skips lookups altogether, because it records quietly and needs no severity.
- **The dry run runs in the background.** `POST security-advisory/dry-run?days=` starts it, and `GET security-advisory/dry-run` returns its progress and report. A run that has to fill the cache takes minutes, longer than a request can stay open behind nginx.
- **The PostgreSQL source reads the HTML table** on `https://www.postgresql.org/support/security/`; the project publishes no feed.
- Severity comes from the CVSS score: 9 and up is critical, 7 to 9 high, 4 to 7 medium.
- The page has no dates, so the processed store decides what is new.
- A page with no rows counts as a failed read, so a layout change gets reported.
- **Platforms:** `ubuntu:noble`, `ubuntu:resolute`, `postgresql`. PostgreSQL components are major versions.
- **The admin page** is under Operations, "Security advisories". It shows:
- the sources' health;
- the watched assets, edited by pasting the package list;
- the dry run;
- the matched advisories of the last 60 days.
- **To receive anything,** subscribe to the event "Security advisory alert" and, for the digest, "Security advisory digest", by email or in-app, like any other notification.
## Tests
- The matching and severity rules are plain classes. Unit tests in `sockeye.tests` run them on saved notice and CVE JSON: a high-priority kernel notice alerts; a medium one goes to the digest; a notice for an unwatched package is ignored; a KEV hit alerts regardless of priority; the same USN never alerts twice.
- An integration test calls the dry-run route against the live feed and checks the report's shape. It sends nothing.

View File

@@ -0,0 +1,159 @@
# Case 4745 — Case triage: Status, Product and Release fields: design note
Status: design agreed with John, 2026-11-02. Nothing built yet. Release: the next Sockeye release,
before case 4688 phase 2, whose write tools for cases are built on these fields.
Case notes: the priority tags (00/1/2-priority, next-release, vX.Y.Z) grew into a slush pile with no
clear purpose. Replace them with a triage system whose parts each have one purpose, that works for
John and for agents alike.
## Decided (John, 2026-11-02)
1. **Status and Release are fields, not tags.** Each case has exactly one of each, and they are the
business process itself. Fields also become typed agent tool parameters; tags drift in spelling.
2. **Product is a field, one product per case.** A fix both products need gets one case per product
(as 4774 and 4775 did), so each case is built, released and closed with its own product.
3. **The status list below**, with Duplicate added, and **Done** rather than Released: Business and
Website cases have no release, and for product cases done means released.
4. **QBI (ravenqbi) is a product of its own:** it connects to AyaNova only through the API and has
its own, much rarer, release cadence. The launcher ships inside AyaNova releases: Product AyaNova.
## The fields
**Status** (required, one value):
| Status | Meaning | Open or closed |
|---|---|---|
| New | Entered, not looked at yet. The default, so entering a case on the fly needs no decision | Open |
| Investigate | Worth a look; not decided. Goes on to Accepted or Never | Open |
| Accepted | Will go into the release in the Release field | Open |
| Built | Coded and committed, not released yet | Open |
| Done | Finished. For a product case with a Release: shipped in that release | Closed |
| Never | Considered and rejected; kept for searches | Closed |
| Duplicate | Same as another case, which carries on. The notes of both name the other | Closed |
| Reference | Not work: how-tos, cheat sheets, pricing and procedure notes. Out of every triage list, still searchable and on the phone | Open |
- No Immediate status: an emergency goes through the same statuses in hours. An `urgent` tag marks it
if John wants to find emergencies later.
- **Closed follows Status.** Setting a closing status sets the Closed date; setting an open status
again clears it. The Closed date keeps its traditional meaning: the last step, done when the work
reached the world.
**Product** (required, one value): AyaNova, Sockeye, QBI, AyaNova 7, Website, Business.
- The `raven`, `ayanova`, `ayanova-7.x`, `v7`, `website` and `biz` tags become this field.
- AyaNova includes the server, client, manual and launcher, which ship together. QBI has its own versions.
**Release** (text, empty until Accepted): a version, e.g. `8.3.2`, read together with Product
(AyaNova and Sockeye versions are both 8.x and can coincide).
- Set when a case is Accepted. A Built case must have one, and Done keeps it. A case that finishes
without shipping anything (research answered, already works) is Done with no Release; a Business or
Website case never needs one. (Requiring one for every Done product case would have refused the
form's Close button and the batch close for those.)
- A caller that changes only the Closed date (the form's Close / Reopen button, the batch close job)
moves Status with it: closing makes it Done, reopening makes it New.
- A build list is a data list filter: Product AyaNova, Release 8.3.2, Status Accepted or Built.
## Tags after the change
A short, fixed vocabulary, for what can have more than one value:
- **Kind:** `bug`, `feature`, `docs`, `security`, `maintenance` (dependencies, housekeeping,
development process), `ops` (servers, business processes).
- **Fit:** `patch`: small enough for a patch release (fixes only, no schema change, no customer
upgrade step). Helps choose between 8.3.2 and 8.4.0.
- **Source:** `customer`: a customer reported or asked for it. It weighs in decisions.
- Area tags that help a search (`client`, `server`, `notification`, `subscription-server`...) can stay.
- **Retired:** 00-priority, 1-priority, 2-priority, next-release, any-time, triage, idea, request,
bug-report (becomes `bug`), info, biz-ref-docs, how-to's (become Status Reference), every vX.Y.Z
(becomes Release), and the product tags above.
## How the work goes
- **Entering:** a case starts as New with a Product. Nothing else is required.
- **Triage:** New cases go to Investigate, Accepted, Never, Duplicate or Reference.
- **Release planning:** the Accepted list for a product and release is the build list. The oldest
Investigate cases are reviewed each time, a fixed number per round, so nothing languishes unseen.
An agent prepares each one (reads the notes, checks the code, proposes Accept with a release, Never
or Duplicate of N); John decides, in batches.
- **Coding:** the commit sets Built. The case keeps its Release.
- **Release day:** select Product and Release, Status Built, and close them as Done with the
existing batch close extension (`job-operations/batch-close-cases`), which gets a status choice.
Today nothing prompts this: 4675 and 4769 (v8.1.4, released 2026-11-01) are still open.
- **Duplicates:** closing one adds a line to each case naming the other, and anything new in the
duplicate moves into the case that carries on. No link field: not worth a schema change.
## Build plan
- **Schema:** `agzcase.status` (integer enum, not null, default New), `agzcase.product` (integer
enum, not null), `agzcase.release` (text, null). Index on (product, status).
- **Model and biz:** the fields on `GZCase`; `GZCaseBiz.ValidateAsync` enforces the rules above
(Built needs a Release; Closed follows Status). Search index includes Release.
- **Data list:** Status, Product and Release columns and filters in `GZCaseDataList`. A saved list
per everyday view: Triage (New), Investigate (oldest first), Build list, Reference.
- **Case form** (`sockeye-client` `biz-gzcase.vue`): the three fields, near the top. Closed shown,
set by Status.
- **Batch close extension:** choose the closing status.
- **Labels:** plain English, Sockeye only. How the enum lists fit the translated enum list code
(`EnumListController`) is checked at build time.
- **Agent tools** (4688 phase 2, separate commits): `list_cases` gains Status, Product and Release
filters; write tools set them (`set_case_status(case, status, release)` and the like).
## Converting existing cases
One-time, in the schema migration or a script John runs; the mapping and its counts are shown to
John before it runs. In order, first match wins:
| Case has | Status | Release |
|---|---|---|
| Closed date | Done | from a vX.Y.Z tag, if any |
| `info`, `biz-ref-docs`, `how-to's` | Reference | |
| a vX.Y.Z tag | Built | from the tag |
| `next-release` | Accepted | **set by hand**: next-release says nothing about which |
| a priority tag, `investigate` or `triage` | Investigate | |
| none of these | New | |
- Product from the product tags; a case with both `ayanova` and `sockeye` is listed for John, since
it may need splitting into two cases.
- Retired tags are removed in the same run.
- Then the backlog review: Investigate cases in batches, oldest first, agent-prepared.
**As built (schema 31, agreed with John 2026-11-02 against production's 3,743 cases):**
- **More tags than planned:** priorities run 00 to 5; `backburner` and `any-time` mean Investigate;
`rockfish-biz-guide` means Reference; closed cases tagged `rejected` or `nff` are Never, `duplicate` is Duplicate.
- **Products:** `rockfish` (Sockeye's predecessor) is Sockeye. AyaNova 7 covers every pre-v8 product
tag (`ayanova-7.x`, `v7`, `wbi-7.x`, `ri-7.x`, `qbi-7.x`, `qboi-7.x`, `pti-7.x`, all `legacy-*`
except `legacy-ayanova-8.x`, which is AyaNova). With several product tags the first of AyaNova,
Sockeye, QBI, AyaNova 7, Website, Business wins: shared-code cases are AyaNova (Sockeye's need is
checked when the fix is built, as for any low-level fix), and a product beats Business or Website.
- **John's decisions on single cases** (by case number in the migration): products for the open cases
without a product tag; 4487, 4675, 4769 Done (released in 8.1.4); 4678 and 4738 Done, Sockeye 8.2.0
(4678's AyaNova port is 4777); 4688 Accepted, Sockeye 8.2.1; 4766 and 4775 Accepted, AyaNova 8.4.0;
4547 and 4611 Never; 3812 and 4734 Business.
- **Tags:** `bug-report` becomes `bug`, `request` becomes `customer`. Removed: the priorities,
`investigate`, `triage`, `backburner`, `any-time`, `next-release`, `idea`, `info`, `biz-ref-docs`,
`how-to's`, `rejected`, `duplicate`, `completed`, `released`, the product tags (`ayanova`, `raven`,
`sockeye`, `rockfish`, `qbi`, `ayanova-7.x`, `v7`, `website`, `biz`) and every vX.Y.Z. Descriptive
tags stay (`legacy-*`, `wbi-7.x`, `client`, `nff`...). The tag repository's counts lose only the case
uses: other records keep theirs (18 customers are tagged `raven`).
- **Search** keeps the removed tag words for a case until it is next saved (there is no reindex).
- **Result on production (read-only preview):** open 637 (Investigate 571, Reference 36, Built 14,
New 12, Accepted 4), every open case with a product; closed 3,107 (Done 3,098, Never 8, Duplicate 1).
## Tests
- **Integration tests:**
- Built without a Release is refused.
- A new case without a Product is refused.
- Changing only Closed moves Status (Done, New).
- A closing status sets Closed; reopening clears it.
- The data list filters by Status, Product and Release.
- Batch close sets the chosen status.
- **Conversion:** run on a copy of the production database; check the counts against the mapping.
## Carries over to AyaNova
The design is meant to show how agents and people share one case process before agent access comes
to AyaNova. AyaNova has no case object, so nothing here is ported directly.

View File

@@ -0,0 +1,114 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Sockeye.Api.ControllerHelpers;
using Sockeye.Biz;
using Sockeye.Models;
using Sockeye.Util;
namespace Sockeye.Api.Controllers
{
/// <summary>
/// case 4688: tokens an agent User connects to /mcp with. Administrators (User rights) create and revoke them on the
/// agent User's record; a token is shown once, at creation
/// </summary>
[ApiController]
[ApiVersion("8.0")]
[Route("api/v{version:apiVersion}/agent-token")]
[Produces("application/json")]
[Authorize]
public class AgentTokenController : ControllerBase
{
private readonly AyContext ct;
private readonly ApiServerState serverState;
public AgentTokenController(AyContext dbcontext, ApiServerState apiServerState)
{
ct = dbcontext;
serverState = apiServerState;
}
public sealed class NewTokenParam
{
public long UserId { get; set; }
public string Name { get; set; }
public int? ExpiresDays { get; set; }//null: the default (90); 0: never
public bool CanChange { get; set; }//phase 2: false (the default) is read-only; true lets the agent's write tools work, within its roles
}
/// <summary>
/// An agent User's tokens (never the tokens themselves)
/// </summary>
[HttpGet("{userId}")]
public async Task<IActionResult> GetTokens([FromRoute] long userId)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasReadFullRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
return Ok(ApiOkResponse.Response(await ct.UserApiToken.AsNoTracking().Where(z => z.UserId == userId).OrderByDescending(z => z.Created)
.Select(z => new { z.Id, z.Name, z.ReadOnly, z.Created, z.Expires, z.LastUsed, z.Revoked }).ToListAsync()));
}
/// <summary>
/// A new token for an agent User; the response is the only time the token is shown
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateToken([FromBody] NewTokenParam param)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasModifyRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
var user = await ct.User.AsNoTracking().FirstOrDefaultAsync(z => z.Id == param.UserId);
if (user == null)
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
if (!user.Agent)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "userId", "Agent tokens are only for Users marked Agent"));
if (param.ExpiresDays < 0)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "expiresDays"));
var token = AgentAccess.NewToken();
var days = param.ExpiresDays ?? AgentAccess.DEFAULT_EXPIRY_DAYS;
var row = new UserApiToken
{
UserId = user.Id,
Name = string.IsNullOrWhiteSpace(param.Name) ? "Agent token" : StringUtil.MaxLength(param.Name.Trim(), 100),
TokenHash = AgentAccess.Hash(token),
ReadOnly = !param.CanChange,
Created = DateTime.UtcNow,
Expires = days == 0 ? null : DateTime.UtcNow.AddDays(days)
};
ct.UserApiToken.Add(row);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserIdFromContext.Id(HttpContext.Items), user.Id, SockType.User, SockEvent.Modified, $"Agent token created: {row.Name}{(row.ReadOnly ? "" : " (can change records)")}"), ct);
return Ok(ApiOkResponse.Response(new { row.Id, row.Name, row.ReadOnly, row.Created, row.Expires, token }));
}
/// <summary>
/// Revoke a token: refused from the next request on
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> RevokeToken([FromRoute] long id)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasModifyRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
var row = await ct.UserApiToken.FirstOrDefaultAsync(z => z.Id == id);
if (row == null)
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
if (row.Revoked == null)
{
row.Revoked = DateTime.UtcNow;
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserIdFromContext.Id(HttpContext.Items), row.UserId, SockType.User, SockEvent.Modified, $"Agent token revoked: {row.Name}"), ct);
}
return NoContent();
}
}//eoc
}//eons

View File

@@ -95,7 +95,7 @@ namespace Sockeye.Api.Controllers
//Multiple users are allowed the same password and login
//Salt will differentiate them so get all users that match login, then try to match pw
var users = await ct.User.Where(z => z.Login == creds.Login && z.Active == true && z.AllowLogin == true).ToListAsync();
var users = await ct.User.Where(z => z.Login == creds.Login && z.Active == true && z.AllowLogin == true && z.Agent == false).ToListAsync();//case 4688: not agents
foreach (User u in users)
{
@@ -692,6 +692,198 @@ namespace Sockeye.Api.Controllers
}
////////////////////////////////////////////////////////////////////////////////
//case 4678: PASSKEYS
//A passkey sign-in counts as two factors (the device checks a PIN or biometric), so it skips the TOTP prompt.
//Bodies carry the browser's WebAuthn JSON as is; the Fido2 library reads it with its own serializer
public sealed class PasskeyParam
{
public string Id { get; set; }//from the matching options request
public string Name { get; set; }//registration only
public Newtonsoft.Json.Linq.JObject Response { get; set; }//the browser's PublicKeyCredential, as JSON
}
//failures are counted per address: a passkey sign-in has no login name, and one shared key would let anyone lock
//everyone out of passkey sign-in
private static string PasskeyThrottleKey(System.Net.IPAddress address) => $"passkey:{address}";
/// <summary>
/// The current user's passkeys
/// </summary>
[HttpGet("passkey")]
public async Task<IActionResult> GetPasskeys()
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
return Ok(ApiOkResponse.Response(await PasskeyListAsync(UserIdFromContext.Id(HttpContext.Items))));
}
/// <summary>
/// A user's passkeys (administrators)
/// </summary>
[HttpGet("passkey/user/{userId}")]
public async Task<IActionResult> GetUserPasskeys([FromRoute] long userId)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (userId != UserIdFromContext.Id(HttpContext.Items) && !Authorized.HasReadFullRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
return Ok(ApiOkResponse.Response(await PasskeyListAsync(userId)));
}
private async Task<object> PasskeyListAsync(long userId) =>
await ct.UserPasskey.AsNoTracking().Where(z => z.UserId == userId).OrderBy(z => z.Created)
.Select(z => new { z.Id, z.Name, z.Created, z.LastUsed }).ToListAsync();
/// <summary>
/// Start adding a passkey to the current user: the options for the browser's navigator.credentials.create()
/// </summary>
[HttpPost("passkey/register-options")]
public async Task<IActionResult> PasskeyRegisterOptions()
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!PasskeyBiz.Configured)
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, PasskeyBiz.NOT_CONFIGURED));
var userId = UserIdFromContext.Id(HttpContext.Items);
if (await ct.UserPasskey.CountAsync(z => z.UserId == userId) >= PasskeyBiz.MAX_PER_USER)
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, $"A user can have at most {PasskeyBiz.MAX_PER_USER} passkeys"));
var user = await ct.User.AsNoTracking().FirstAsync(z => z.Id == userId);
var options = await PasskeyBiz.RegistrationOptionsAsync(ct, user);
var id = PasskeyBiz.Remember(options.ToJson(), userId);
if (id == null)
return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_SERVER_ERROR, null, "Too many passkey requests waiting, try again shortly"));
return Ok(ApiOkResponse.Response(new { id, options = Newtonsoft.Json.Linq.JObject.Parse(options.ToJson()) }));
}
/// <summary>
/// Finish adding a passkey: the browser's answer to the register-options request
/// </summary>
[HttpPost("passkey/register")]
public async Task<IActionResult> PasskeyRegister([FromBody] PasskeyParam param)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
var userId = UserIdFromContext.Id(HttpContext.Items);
var optionsJson = PasskeyBiz.Take(param?.Id, userId);
if (optionsJson == null || param.Response == null)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_FAILED, null, "The passkey request expired or is unknown, please try again"));
try
{
var passkey = await PasskeyBiz.RegisterAsync(ct, userId, optionsJson, param.Response.ToString(Newtonsoft.Json.Formatting.None), param.Name);
await EventLogProcessor.LogEventToDatabaseAsync(new Event(userId, userId, SockType.User, SockEvent.Modified, $"Passkey added: {passkey.Name}"), ct);
return Ok(ApiOkResponse.Response(new { passkey.Id, passkey.Name, passkey.Created, passkey.LastUsed }));
}
catch (Exception ex) when (ex is Fido2NetLib.Fido2VerificationException || ex is System.Text.Json.JsonException || ex is ArgumentException)
{
log.LogWarning($"Passkey registration refused for user {userId}: {ex.Message}");
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_FAILED, null, "The passkey could not be verified"));
}
}
/// <summary>
/// Remove a passkey: the user's own, or anyone's for an administrator
/// </summary>
[HttpDelete("passkey/{id}")]
public async Task<IActionResult> DeletePasskey([FromRoute] long id)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
var passkey = await ct.UserPasskey.FirstOrDefaultAsync(z => z.Id == id);
if (passkey == null)
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
var userId = UserIdFromContext.Id(HttpContext.Items);
if (passkey.UserId != userId && !Authorized.HasModifyRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
ct.UserPasskey.Remove(passkey);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(userId, passkey.UserId, SockType.User, SockEvent.Modified, $"Passkey removed: {passkey.Name}"), ct);
return NoContent();
}
/// <summary>
/// Remove all of a user's passkeys (the user, or an administrator for a user who lost theirs)
/// </summary>
[HttpDelete("passkey/user/{userId}")]
public async Task<IActionResult> DeleteUserPasskeys([FromRoute] long userId)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
var me = UserIdFromContext.Id(HttpContext.Items);
if (userId != me && !Authorized.HasModifyRole(HttpContext.Items, SockType.User))
return StatusCode(403, new ApiNotAuthorizedResponse());
var all = await ct.UserPasskey.Where(z => z.UserId == userId).ToListAsync();
if (all.Count > 0)
{
ct.UserPasskey.RemoveRange(all);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(me, userId, SockType.User, SockEvent.Modified, $"All passkeys removed ({all.Count})"), ct);
}
return NoContent();
}
/// <summary>
/// Start a passkey sign-in: the options for the browser's navigator.credentials.get()
/// </summary>
[HttpPost("passkey/login-options")]
[AllowAnonymous]
public IActionResult PasskeyLoginOptions()
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!PasskeyBiz.Configured)
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, PasskeyBiz.NOT_CONFIGURED));
var address = HttpContext.Connection.RemoteIpAddress;
if (LoginThrottle.IsRefused(PasskeyThrottleKey(address), address))
return StatusCode(429, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, null, "Too many failed login attempts, please try again later"));
var options = PasskeyBiz.SignInOptions();
var id = PasskeyBiz.Remember(options.ToJson(), 0);
if (id == null)
return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_SERVER_ERROR, null, "Too many passkey requests waiting, try again shortly"));
return Ok(ApiOkResponse.Response(new { id, options = Newtonsoft.Json.Linq.JObject.Parse(options.ToJson()) }));
}
/// <summary>
/// Finish a passkey sign-in: the browser's answer to the login-options request; on success the same as a login
/// </summary>
[HttpPost("passkey/login")]
[AllowAnonymous]
public async Task<IActionResult> PasskeyLogin([FromBody] PasskeyParam param)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
var address = HttpContext.Connection.RemoteIpAddress;
var throttleKey = PasskeyThrottleKey(address);
if (LoginThrottle.IsRefused(throttleKey, address))
return StatusCode(429, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, null, "Too many failed login attempts, please try again later"));
User user = null;
var optionsJson = PasskeyBiz.Take(param?.Id, 0);
if (optionsJson != null && param.Response != null)
{
try
{
var passkey = await PasskeyBiz.VerifySignInAsync(ct, optionsJson, param.Response.ToString(Newtonsoft.Json.Formatting.None));
if (passkey != null)
user = await ct.User.FirstOrDefaultAsync(z => z.Id == passkey.UserId && z.Active == true && z.AllowLogin == true && z.Agent == false);
}
catch (Exception ex) when (ex is Fido2NetLib.Fido2VerificationException || ex is System.Text.Json.JsonException || ex is ArgumentException)
{
log.LogWarning($"Passkey sign-in refused from {address}: {ex.Message}");
}
}
if (user == null)
{
RecordLoginFailure(throttleKey, address);
await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
LoginThrottle.RecordSuccess(throttleKey);
return await ReturnUserCredsOnSuccessfulAuthentication(user);
}
//generate an internal JWT key for reporting purposes used by corejobnotify to send reports as manager account
internal static string GenRpt(long overrideLanguageId)
{

View File

@@ -92,6 +92,8 @@ namespace Sockeye.Api.Controllers
ret.Add(new KeyValuePair<string, string>(StringUtil.TrimTypeName(typeof(TrialRequestStatus).ToString()), "Trial license request status"));
ret.Add(new KeyValuePair<string, string>(StringUtil.TrimTypeName(typeof(ProductGroup).ToString()), "Product group"));
ret.Add(new KeyValuePair<string, string>(StringUtil.TrimTypeName(typeof(ServerState).ToString()), "Server state"));
ret.Add(new KeyValuePair<string, string>(StringUtil.TrimTypeName(typeof(GZCaseStatus).ToString()), "Case status"));
ret.Add(new KeyValuePair<string, string>(StringUtil.TrimTypeName(typeof(GZCaseProduct).ToString()), "Case product"));
return Ok(ApiOkResponse.Response(ret));
}
@@ -483,10 +485,10 @@ namespace Sockeye.Api.Controllers
TranslationKeysToFetch.Add("NotifyEventServerStateStatusChange");
TranslationKeysToFetch.Add("NotifyEventServerStateStatusAge");
TranslationKeysToFetch.Add("NotifyEventSubscriptionServerExpiring");
TranslationKeysToFetch.Add("NotifyEventSubscriptionServerLastUpdateAge");
TranslationKeysToFetch.Add("NotifyEventSubscriptionServerRequestReceived");
TranslationKeysToFetch.Add("NotifyEventLicenseTrialRequestReceived");
TranslationKeysToFetch.Add("NotifyEventPurchaseNotificationReceived");
TranslationKeysToFetch.Add("NotifyEventSecurityAdvisoryAlert");
TranslationKeysToFetch.Add("NotifyEventSecurityAdvisoryDigest");
var LT = await TranslationBiz.GetSubsetStaticAsync(TranslationKeysToFetch, translationId);
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventObjectDeleted"], Id = (long)NotifyEventType.ObjectDeleted });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventObjectCreated"], Id = (long)NotifyEventType.ObjectCreated });
@@ -502,10 +504,10 @@ namespace Sockeye.Api.Controllers
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventServerStateStatusChange"], Id = (long)NotifyEventType.ServerStateStatusChange });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventServerStateStatusAge"], Id = (long)NotifyEventType.ServerStateStatusAge });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventSubscriptionServerExpiring"], Id = (long)NotifyEventType.SubscriptionServerExpiring });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventSubscriptionServerLastUpdateAge"], Id = (long)NotifyEventType.SubscriptionServerLastUpdateAge });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventSubscriptionServerRequestReceived"], Id = (long)NotifyEventType.SubscriptionServerRequestReceived });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventLicenseTrialRequestReceived"], Id = (long)NotifyEventType.LicenseTrialRequestReceived });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventPurchaseNotificationReceived"], Id = (long)NotifyEventType.PurchaseNotificationReceived });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventSecurityAdvisoryAlert"], Id = (long)NotifyEventType.SecurityAdvisoryAlert });
ReturnList.Add(new NameIdItem() { Name = LT["NotifyEventSecurityAdvisoryDigest"], Id = (long)NotifyEventType.SecurityAdvisoryDigest });
}
else if (keyNameInLowerCase == StringUtil.TrimTypeName(typeof(NotifyDeliveryMethod).ToString()).ToLowerInvariant())
@@ -600,6 +602,17 @@ namespace Sockeye.Api.Controllers
ReturnList.Add(new NameIdItem() { Name = LT["ServerStateDeActivated"], Id = (long)ServerState.DeActivated });
ReturnList.Add(new NameIdItem() { Name = LT["ServerStateDestroyed"], Id = (long)ServerState.Destroyed });
}
//case 4745: Sockeye only, so plain English
else if (keyNameInLowerCase == StringUtil.TrimTypeName(typeof(GZCaseStatus).ToString()).ToLowerInvariant())
{
foreach (GZCaseStatus s in Enum.GetValues(typeof(GZCaseStatus)))
ReturnList.Add(new NameIdItem() { Name = s.ToString(), Id = (long)s });
}
else if (keyNameInLowerCase == StringUtil.TrimTypeName(typeof(GZCaseProduct).ToString()).ToLowerInvariant())
{
foreach (GZCaseProduct p in Enum.GetValues(typeof(GZCaseProduct)))
ReturnList.Add(new NameIdItem() { Name = p == GZCaseProduct.AyaNova7 ? "AyaNova 7" : p.ToString(), Id = (long)p });
}

View File

@@ -127,7 +127,8 @@ namespace Sockeye.Api.Controllers
else
return BadRequest(new ApiErrorResponse(biz.Errors));
}
return Ok(ApiOkResponse.Response(new { Concurrency = o.Concurrency })); ;
//case 4745: and what the save set from the status (closed) or moved (status), so the form shows and sends back what's stored
return Ok(ApiOkResponse.Response(new { Concurrency = o.Concurrency, o.Status, o.Closed, o.Release }));
}
/// <summary>

View File

@@ -259,9 +259,10 @@ namespace Sockeye.Api.Controllers
/// </summary>
/// <param name="selectedRequest"></param>
/// <param name="open">true reopens, false closes</param>
/// <param name="status">case 4745: the status closing sets, Done (default) or Never; reopening always sets New</param>
/// <returns>Job Id</returns>
[HttpPost("batch-close-cases")]
public async Task<IActionResult> BatchCloseCases([FromBody] DataListSelectedRequest selectedRequest, [FromQuery] bool open = false)
public async Task<IActionResult> BatchCloseCases([FromBody] DataListSelectedRequest selectedRequest, [FromQuery] bool open = false, [FromQuery] GZCaseStatus status = GZCaseStatus.Done)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
@@ -272,6 +273,9 @@ namespace Sockeye.Api.Controllers
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, null, "DataListSelectedRequest is required"));
if (selectedRequest.SockType != SockType.GZCase)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "SockType", "Only cases can be closed or opened"));
//case 4745: a Duplicate names the case that carries on, one at a time on the case form
if (!open && status != GZCaseStatus.Done && status != GZCaseStatus.Never)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "status", "Cases can be closed as Done or Never"));
if (!Authorized.HasModifyRole(HttpContext.Items, selectedRequest.SockType))
return StatusCode(403, new ApiNotAuthorizedResponse());
@@ -289,7 +293,8 @@ namespace Sockeye.Api.Controllers
var JobName = $"LT:{(open ? "Open" : "Close")} - LT:{selectedRequest.SockType} ({selectedRequest.SelectedRowIds.LongLength}) LT:User {UserNameFromContext.Name(HttpContext.Items)}";
JObject o = JObject.FromObject(new
{
idList = selectedRequest.SelectedRowIds
idList = selectedRequest.SelectedRowIds,
status = (int)(open ? GZCaseStatus.New : status)
});
OpsJob j = new OpsJob();

View File

@@ -1,141 +0,0 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Asp.Versioning;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using Sockeye.Models;
using Sockeye.Api.ControllerHelpers;
using Sockeye.Biz;
namespace Sockeye.Api.Controllers
{
[ApiController]
[ApiVersion("8.0")]
[Route("api/v{version:apiVersion}/purchase")]
[Produces("application/json")]
[Authorize]
public class PurchaseController : ControllerBase
{
private readonly AyContext ct;
private readonly ILogger<PurchaseController> log;
private readonly ApiServerState serverState;
/// <summary>
/// ctor
/// </summary>
/// <param name="dbcontext"></param>
/// <param name="logger"></param>
/// <param name="apiServerState"></param>
public PurchaseController(AyContext dbcontext, ILogger<PurchaseController> logger, ApiServerState apiServerState)
{
ct = dbcontext;
log = logger;
serverState = apiServerState;
}
/// <summary>
/// Create Purchase
/// </summary>
/// <param name="newObject"></param>
/// <param name="apiVersion">From route path</param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> PostPurchase([FromBody] Purchase newObject, ApiVersion apiVersion)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
PurchaseBiz biz = PurchaseBiz.GetBiz(ct, HttpContext);
if (!Authorized.HasCreateRole(HttpContext.Items, biz.BizType))
return StatusCode(403, new ApiNotAuthorizedResponse());
if (!ModelState.IsValid)
return BadRequest(new ApiErrorResponse(ModelState));
Purchase o = await biz.CreateAsync(newObject);
if (o == null)
return BadRequest(new ApiErrorResponse(biz.Errors));
else
return CreatedAtAction(nameof(PurchaseController.GetPurchase), new { id = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o));
}
/// <summary>
/// Get Purchase
/// </summary>
/// <param name="id"></param>
/// <returns>Purchase</returns>
[HttpGet("{id}")]
public async Task<IActionResult> GetPurchase([FromRoute] long id)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
PurchaseBiz biz = PurchaseBiz.GetBiz(ct, HttpContext);
if (!Authorized.HasReadFullRole(HttpContext.Items, biz.BizType))
return StatusCode(403, new ApiNotAuthorizedResponse());
if (!ModelState.IsValid)
return BadRequest(new ApiErrorResponse(ModelState));
var o = await biz.GetAsync(id);
if (o == null) return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
return Ok(ApiOkResponse.Response(o));
}
/// <summary>
/// Update Purchase
/// </summary>
/// <param name="updatedObject"></param>
/// <returns></returns>
[HttpPut]
public async Task<IActionResult> PutPurchase([FromBody] Purchase updatedObject)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!ModelState.IsValid)
return BadRequest(new ApiErrorResponse(ModelState));
PurchaseBiz biz = PurchaseBiz.GetBiz(ct, HttpContext);
if (!Authorized.HasModifyRole(HttpContext.Items, biz.BizType))
return StatusCode(403, new ApiNotAuthorizedResponse());
var o = await biz.PutAsync(updatedObject);
if (o == null)
{
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 })); ;
}
/// <summary>
/// Delete Purchase
/// </summary>
/// <param name="id"></param>
/// <returns>NoContent</returns>
[HttpDelete("{id}")]
public async Task<IActionResult> DeletePurchase([FromRoute] long id)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!ModelState.IsValid)
return BadRequest(new ApiErrorResponse(ModelState));
PurchaseBiz biz = PurchaseBiz.GetBiz(ct, HttpContext);
if (!Authorized.HasDeleteRole(HttpContext.Items, biz.BizType))
return StatusCode(403, new ApiNotAuthorizedResponse());
if (!await biz.DeleteAsync(id))
return BadRequest(new ApiErrorResponse(biz.Errors));
return NoContent();
}
//------------
}//eoc
}//eons

View File

@@ -0,0 +1,151 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Sockeye.Api.ControllerHelpers;
using Sockeye.Biz;
using Sockeye.Models;
namespace Sockeye.Api.Controllers
{
/// <summary>
/// case 4738: security advisory monitor: the watched assets, its current state, and a dry run.
/// Operations administrators only (GlobalOps rights)
/// </summary>
[ApiController]
[ApiVersion("8.0")]
[Route("api/v{version:apiVersion}/security-advisory")]
[Produces("application/json")]
[Authorize]
public class SecurityAdvisoryController : ControllerBase
{
private static readonly string[] PLATFORMS = { "ubuntu:noble", "ubuntu:resolute", "postgresql" };
private readonly AyContext ct;
private readonly ILogger<SecurityAdvisoryController> log;
private readonly ApiServerState serverState;
public SecurityAdvisoryController(AyContext dbcontext, ILogger<SecurityAdvisoryController> logger, ApiServerState apiServerState)
{
ct = dbcontext;
log = logger;
serverState = apiServerState;
}
/// <summary>
/// Watched assets
/// </summary>
[HttpGet("asset")]
public async Task<IActionResult> GetAssets()
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasReadFullRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
return Ok(ApiOkResponse.Response(await ct.SecurityAsset.AsNoTracking().OrderBy(z => z.Name).ToListAsync()));
}
/// <summary>
/// Add a watched asset (id 0) or replace one. Components may be pasted dpkg-query output: one name per line
/// </summary>
[HttpPost("asset")]
public async Task<IActionResult> SaveAsset([FromBody] SecurityAsset asset)
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasModifyRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
if (!ModelState.IsValid)
return BadRequest(new ApiErrorResponse(ModelState));
asset.Name = asset.Name?.Trim();
asset.Components = SecurityAdvisoryPolicy.NormalizeComponents(asset.Components);
if (string.IsNullOrWhiteSpace(asset.Name))
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "name"));
if (!PLATFORMS.Contains(asset.Platform))
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "platform", $"Platform must be one of {string.Join(", ", PLATFORMS)}"));
if (asset.Components.Count == 0)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "components"));
if (!Enum.IsDefined(asset.AlertAt) || !Enum.IsDefined(asset.DigestAt) || asset.DigestAt > asset.AlertAt)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "digestAt", "The digest level can't be above the alert level"));
if (asset.Id == 0)
ct.SecurityAsset.Add(asset);
else
{
var existing = await ct.SecurityAsset.FirstOrDefaultAsync(z => z.Id == asset.Id);
if (existing == null)
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
ct.Entry(existing).CurrentValues.SetValues(asset);
}
await ct.SaveChangesAsync();
return Ok(ApiOkResponse.Response(asset));
}
[HttpDelete("asset/{id}")]
public async Task<IActionResult> DeleteAsset([FromRoute] long id)
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasModifyRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
var existing = await ct.SecurityAsset.FirstOrDefaultAsync(z => z.Id == id);
if (existing == null)
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
ct.SecurityAsset.Remove(existing);
await ct.SaveChangesAsync();
return NoContent();
}
/// <summary>
/// Each source's health, and the matched advisories of the last days (default 60)
/// </summary>
[HttpGet("status")]
public async Task<IActionResult> GetStatus([FromQuery] int days = 60)
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasReadFullRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
var after = DateTime.UtcNow.AddDays(-Math.Clamp(days, 1, 365));
var sources = await ct.SecuritySource.AsNoTracking().OrderBy(z => z.Id).ToListAsync();
var advisories = await ct.SecurityAdvisory.AsNoTracking()
.Where(z => z.Processed > after && z.Outcome != AdvisoryOutcome.NoMatch)
.OrderByDescending(z => z.Published ?? z.Processed).Take(500).ToListAsync();
var processed = await ct.SecurityAdvisory.CountAsync(z => z.Processed > after);
return Ok(ApiOkResponse.Response(new { sources, advisories, processed }));
}
/// <summary>
/// Start a dry run: what the advisories of the last days (default 30, at most 90) would have done with the current
/// assets. Sends nothing and records nothing. It runs in the background; GET dry-run returns its progress and report
/// </summary>
[HttpPost("dry-run")]
public IActionResult StartDryRun([FromQuery] int days = 30)
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasModifyRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
if (!SockBotSecurityAdvisories.StartDryRun(Math.Clamp(days, 1, 90)))
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, "A dry run is already running"));
return Ok(ApiOkResponse.Response(SockBotSecurityAdvisories.LastDryRun));
}
/// <summary>
/// The latest dry run: whether it is still running, and its report once finished
/// </summary>
[HttpGet("dry-run")]
public IActionResult GetDryRun()
{
if (serverState.IsClosed)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasReadFullRole(HttpContext.Items, SockType.GlobalOps))
return StatusCode(403, new ApiNotAuthorizedResponse());
return Ok(ApiOkResponse.Response(SockBotSecurityAdvisories.LastDryRun));
}
}//eoc
}//eons

View File

@@ -1,139 +0,0 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Asp.Versioning;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Logging;
using Sockeye.Models;
using Sockeye.Api.ControllerHelpers;
using Sockeye.Biz;
namespace Sockeye.Api.Controllers
{
[ApiController]
[ApiVersion("8.0")]
[Route("api/v{version:apiVersion}/vendor-notification")]
[Produces("application/json")]
[Authorize]
public class VendorNotificationController : ControllerBase
{
private readonly AyContext ct;
private readonly ILogger<VendorNotificationController> log;
private readonly ApiServerState serverState;
/// <summary>
/// ctor
/// </summary>
/// <param name="dbcontext"></param>
/// <param name="logger"></param>
/// <param name="apiServerState"></param>
public VendorNotificationController(AyContext dbcontext, ILogger<VendorNotificationController> logger, ApiServerState apiServerState)
{
ct = dbcontext;
log = logger;
serverState = apiServerState;
}
/// <summary>
/// Create VendorNotification
/// </summary>
/// <param name="newObject"></param>
/// <param name="apiVersion">From route path</param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> PostVendorNotification([FromBody] VendorNotification newObject, ApiVersion apiVersion)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
VendorNotificationBiz biz = VendorNotificationBiz.GetBiz(ct, HttpContext);
if (!Authorized.HasCreateRole(HttpContext.Items, biz.BizType))
return StatusCode(403, new ApiNotAuthorizedResponse());
if (!ModelState.IsValid)
return BadRequest(new ApiErrorResponse(ModelState));
VendorNotification o = await biz.CreateAsync(newObject);
if (o == null)
return BadRequest(new ApiErrorResponse(biz.Errors));
else
return CreatedAtAction(nameof(VendorNotificationController.GetVendorNotification), new { id = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o));
}
/// <summary>
/// Get VendorNotification
/// </summary>
/// <param name="id"></param>
/// <returns>VendorNotification</returns>
[HttpGet("{id}")]
public async Task<IActionResult> GetVendorNotification([FromRoute] long id)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
VendorNotificationBiz biz = VendorNotificationBiz.GetBiz(ct, HttpContext);
if (!Authorized.HasReadFullRole(HttpContext.Items, biz.BizType))
return StatusCode(403, new ApiNotAuthorizedResponse());
if (!ModelState.IsValid)
return BadRequest(new ApiErrorResponse(ModelState));
var o = await biz.GetAsync(id);
if (o == null) return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
return Ok(ApiOkResponse.Response(o));
}
/// <summary>
/// Update VendorNotification
/// </summary>
/// <param name="updatedObject"></param>
/// <returns></returns>
[HttpPut]
public async Task<IActionResult> PutVendorNotification([FromBody] VendorNotification updatedObject)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!ModelState.IsValid)
return BadRequest(new ApiErrorResponse(ModelState));
VendorNotificationBiz biz = VendorNotificationBiz.GetBiz(ct, HttpContext);
if (!Authorized.HasModifyRole(HttpContext.Items, biz.BizType))
return StatusCode(403, new ApiNotAuthorizedResponse());
var o = await biz.PutAsync(updatedObject);
if (o == null)
{
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 })); ;
}
/// <summary>
/// Delete VendorNotification
/// </summary>
/// <param name="id"></param>
/// <returns>NoContent</returns>
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteVendorNotification([FromRoute] long id)
{
if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!ModelState.IsValid)
return BadRequest(new ApiErrorResponse(ModelState));
VendorNotificationBiz biz = VendorNotificationBiz.GetBiz(ct, HttpContext);
if (!Authorized.HasDeleteRole(HttpContext.Items, biz.BizType))
return StatusCode(403, new ApiNotAuthorizedResponse());
if (!await biz.DeleteAsync(id))
return BadRequest(new ApiErrorResponse(biz.Errors));
return NoContent();
}
//------------
}//eoc
}//eons

View File

@@ -14,7 +14,7 @@ namespace Sockeye.DataList
var RoleSet = BizRoles.GetRoleSet(DefaultListAType);
AllowedRoles = RoleSet.ReadFullRecord | RoleSet.Change;
DefaultColumns = new List<string>() { "GZCaseId", "Tags", "GZCaseName", "GZCaseClosed", "Customer" };
DefaultColumns = new List<string>() { "GZCaseId", "GZCaseStatus", "GZCaseProduct", "GZCaseRelease", "Tags", "GZCaseName", "GZCaseClosed", "Customer" };
DefaultSortBy = new Dictionary<string, string>() { { "GZCaseId", "-" } };
FieldDefinitions = new List<DataListFieldDefinition>();
@@ -73,6 +73,33 @@ namespace Sockeye.DataList
SqlValueColumnName = "agzcase.tags"
});
//case 4745: triage
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "GZCaseStatus",
FieldKey = "GZCaseStatus",
UiFieldDataType = (int)UiFieldDataType.Enum,
EnumType = Sockeye.Util.StringUtil.TrimTypeName(typeof(GZCaseStatus).ToString()),
SqlValueColumnName = "agzcase.status"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "GZCaseProduct",
FieldKey = "GZCaseProduct",
UiFieldDataType = (int)UiFieldDataType.Enum,
EnumType = Sockeye.Util.StringUtil.TrimTypeName(typeof(GZCaseProduct).ToString()),
SqlValueColumnName = "agzcase.product"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "GZCaseRelease",
FieldKey = "GZCaseRelease",
UiFieldDataType = (int)UiFieldDataType.Text,
SqlValueColumnName = "agzcase.release"
});
//META column
FieldDefinitions.Add(new DataListFieldDefinition
{

View File

@@ -1,212 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Sockeye.Biz;
using Sockeye.Models;
namespace Sockeye.DataList
{
internal class PurchaseDataList : DataListProcessingBase, IDataListInternalCriteria
{
public PurchaseDataList(long translationId)
{
DefaultListAType = SockType.Purchase;
SQLFrom = @"FROM apurchase LEFT JOIN aproduct on (apurchase.productid = aproduct.id) LEFT JOIN avendor ON (apurchase.vendorid = avendor.id) LEFT JOIN acustomer ON (apurchase.customerid = acustomer.id)";
var RoleSet = BizRoles.GetRoleSet(DefaultListAType);
AllowedRoles = RoleSet.ReadFullRecord | RoleSet.Change;
DefaultColumns = new List<string>() { "PurchaseDate", "PurchaseSalesOrderNumber", "Customer", "Product", "PurchaseProcessed", "PurchaseVendorNotificationId" };
DefaultSortBy = new Dictionary<string, string>() { { "PurchaseDate", "-" } };
FieldDefinitions = new List<DataListFieldDefinition>();
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "PurchaseSalesOrderNumber",
FieldKey = "PurchaseSalesOrderNumber",
SockType = (int)SockType.Purchase,
UiFieldDataType = (int)UiFieldDataType.Text,
SqlIdColumnName = "apurchase.id",
SqlValueColumnName = "apurchase.salesordernumber",
IsRowId = true
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "Customer",
FieldKey = "Customer",
SockType = (int)SockType.Customer,
UiFieldDataType = (int)UiFieldDataType.Text,
SqlIdColumnName = "apurchase.customerid",
SqlValueColumnName = "acustomer.name"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "Vendor",
FieldKey = "Vendor",
SockType = (int)SockType.Vendor,
UiFieldDataType = (int)UiFieldDataType.Text,
SqlIdColumnName = "apurchase.vendorid",
SqlValueColumnName = "avendor.name"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "PurchaseVendorNotificationId",
FieldKey = "PurchaseVendorNotificationId",
SockType = (int)SockType.VendorNotification,
UiFieldDataType = (int)UiFieldDataType.Integer,
SqlIdColumnName = "apurchase.vendornotificationid",
SqlValueColumnName = "apurchase.vendornotificationid"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "Product",
FieldKey = "Product",
SockType = (int)SockType.Product,
UiFieldDataType = (int)UiFieldDataType.Text,
SqlIdColumnName = "apurchase.productid",
SqlValueColumnName = "aproduct.name"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "PurchaseDate",
FieldKey = "PurchaseDate",
UiFieldDataType = (int)UiFieldDataType.DateTime,
SqlValueColumnName = "apurchase.purchasedate",
SockType = (int)SockType.Purchase,
SqlIdColumnName = "apurchase.id"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "PurchaseExpireDate",
FieldKey = "PurchaseExpireDate",
UiFieldDataType = (int)UiFieldDataType.DateTime,
SqlValueColumnName = "apurchase.expiredate"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "PurchaseCancelDate",
FieldKey = "PurchaseCancelDate",
UiFieldDataType = (int)UiFieldDataType.DateTime,
SqlValueColumnName = "apurchase.canceldate"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "PurchaseCouponCode",
FieldKey = "PurchaseCouponCode",
UiFieldDataType = (int)UiFieldDataType.Text,
SqlValueColumnName = "apurchase.couponcode"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "PurchaseNotes",
FieldKey = "PurchaseNotes",
UiFieldDataType = (int)UiFieldDataType.Text,
SqlValueColumnName = "apurchase.notes"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "PurchaseRenewNoticeSent",
FieldKey = "PurchaseRenewNoticeSent",
UiFieldDataType = (int)UiFieldDataType.Bool,
SqlValueColumnName = "apurchase.renewnoticesent"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "PurchaseQuantity",
FieldKey = "PurchaseQuantity",
UiFieldDataType = (int)UiFieldDataType.Integer,
SqlValueColumnName = "apurchase.quantity"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "PurchaseProcessed",
FieldKey = "PurchaseProcessed",
UiFieldDataType = (int)UiFieldDataType.Bool,
SqlValueColumnName = "apurchase.processed"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "DatabaseID",
FieldKey = "DatabaseID",
UiFieldDataType = (int)UiFieldDataType.Text,
SqlValueColumnName = "apurchase.dbid"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "Tags",
FieldKey = "purchasetags",
UiFieldDataType = (int)UiFieldDataType.Tags,
SqlValueColumnName = "apurchase.tags"
});
//META column
FieldDefinitions.Add(new DataListFieldDefinition
{
FieldKey = "metacustomer",
UiFieldDataType = (int)UiFieldDataType.InternalId,
SqlIdColumnName = "apurchase.customerid",
SqlValueColumnName = "apurchase.customerid",
IsMeta = true
});
}
public List<DataListFilterOption> DataListInternalCriteria(long currentUserId, AuthorizationRoles userRoles, string clientCriteria)
{
List<DataListFilterOption> ret = new List<DataListFilterOption>();
//ClientCriteria format for this list is "OBJECTID,AYATYPE"
var crit = (clientCriteria ?? "").Split(',').Select(z => z.Trim()).ToArray();
if (crit.Length > 1)
{
//will be filtered from different types, show all records from Customer and nothing else at this time
int nType = 0;
if (!int.TryParse(crit[1], out nType)) return ret;
SockType forType = (SockType)nType;
if (forType != SockType.Customer) return ret;//only supports customer for now see workorderdatalist for alts
long lId = 0;
if (!long.TryParse(crit[0], out lId)) return ret;
if (lId == 0) return ret;
//Have valid type, have an id, so filter away
switch (forType)
{
case SockType.Customer:
{
DataListFilterOption FilterOption = new DataListFilterOption() { Column = "metacustomer" };
FilterOption.Items.Add(new DataListColumnFilter() { value = crit[0], op = DataListFilterComparisonOperator.Equality });
ret.Add(FilterOption);
}
break;
// case AyaType.Project:
// {
// DataListFilterOption FilterOption = new DataListFilterOption() { Column = "metaproject" };
// FilterOption.Items.Add(new DataListColumnFilter() { value = crit[0], op = DataListFilterComparisonOperator.Equality });
// ret.Add(FilterOption);
// }
// break;
}
}
return ret;
}
}//eoc
}//eons

View File

@@ -1,49 +0,0 @@
using System.Collections.Generic;
using Sockeye.Biz;
namespace Sockeye.DataList
{
internal class VendorNotificationDataList : DataListProcessingBase
{
public VendorNotificationDataList(long translationId)
{
DefaultListAType = SockType.VendorNotification;
SQLFrom = @"FROM avendornotification ";
var RoleSet = BizRoles.GetRoleSet(DefaultListAType);
AllowedRoles = RoleSet.ReadFullRecord | RoleSet.Change;
DefaultColumns = new List<string>() { "VendorNotification", "VendorNotificationCreated", "VendorNotificationProcessed" };
DefaultSortBy = new Dictionary<string, string>() { { "VendorNotification", "-" } };
FieldDefinitions = new List<DataListFieldDefinition>();
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "VendorNotification",
FieldKey = "VendorNotification",
SockType = (int)SockType.VendorNotification,
UiFieldDataType = (int)UiFieldDataType.Integer,
SqlIdColumnName = "avendornotification.id",
SqlValueColumnName = "avendornotification.id",
IsRowId = true
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "VendorNotificationCreated",
FieldKey = "VendorNotificationCreated",
SockType = (int)SockType.VendorNotification,
UiFieldDataType = (int)UiFieldDataType.DateTime,
SqlIdColumnName = "avendornotification.id",
SqlValueColumnName = "avendornotification.created"
});
FieldDefinitions.Add(new DataListFieldDefinition
{
TKey = "VendorNotificationProcessed",
FieldKey = "VendorNotificationProcessed",
UiFieldDataType = (int)UiFieldDataType.DateTime,
SqlValueColumnName = "avendornotification.processed"
});
}
}//eoc
}//eons

View File

@@ -142,6 +142,11 @@ namespace Sockeye
var logRuleFilterOutHttpClient = new LoggingRule("System.Net.Http.HttpClient.Default.*", NLog.LogLevel.Trace, NLog.LogLevel.Info, nullTarget);
logRuleFilterOutHttpClient.Final = true;
//case 4688: the MCP SDK logs several INFO lines per agent call (handler called, completed...); SockeyeMcpTools logs
//the one that matters. Its warnings and errors (a tool that throws) still get through
var logRuleFilterOutMcp = new LoggingRule("ModelContextProtocol.*", NLog.LogLevel.Trace, NLog.LogLevel.Info, nullTarget);
logRuleFilterOutMcp.Final = true;
//filter out all Microsoft EF CORE concurrency exceptions, it's a nuisance unless debugging or something
//This is what I have to filter because it's the top exception: Microsoft.EntityFrameworkCore.Update
@@ -178,6 +183,7 @@ namespace Sockeye
{
//filter OUT microsoft stuff
logConfig.LoggingRules.Add(logRuleFilterOutMicrosoft);
logConfig.LoggingRules.Add(logRuleFilterOutMcp);
logConfig.LoggingRules.Add(logRuleFilterOutMicrosoftEfCoreConcurrencyExceptions);
logConfig.LoggingRules.Add(logRuleFilterOutMicrosoftEfCoreCommandExceptions);
logConfig.LoggingRules.Add(logRuleFilterOutMicrosoftEfCoreDbUpdateExceptions);

View File

@@ -60,6 +60,10 @@ namespace Sockeye
_newLog.LogDebug("Health");
services.AddHealthChecks().AddDbContextCheck<AyContext>(); ;
//case 4688: MCP endpoint for AI agents (stateless: each call stands alone, nothing kept between calls)
services.AddHttpContextAccessor();
services.AddMcpServer().WithHttpTransport(options => options.Stateless = true).WithTools<SockeyeMcpTools>();
//Server state service for shutting people out of api
_newLog.LogDebug("ServerState service");
services.AddSingleton(new Sockeye.Api.ControllerHelpers.ApiServerState());
@@ -504,11 +508,15 @@ namespace Sockeye
//case 4688: /mcp is for agent Users with an agent token only; everything else passes through
app.Use((context, next) => AgentAccess.HandleAsync(context, next));
_newLog.LogDebug("Endpoints pipeline");
app.UseEndpoints(endpoints =>
{
endpoints.MapHealthChecks("/health");
endpoints.MapControllers();
endpoints.MapMcp(AgentAccess.MCP_PATH);//case 4688
});
#region SWAGGER

136
server/biz/AgentAccess.cs Normal file
View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.RateLimiting;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Sockeye.Models;
using Sockeye.Util;
namespace Sockeye.Biz
{
/// <summary>
/// case 4688: AI agent access. An agent is a User marked Agent; it can't sign in on the login page and connects only to
/// the MCP endpoint (/mcp) with an agent token. This is the gate in front of /mcp: it checks the token, applies the
/// per-agent rate limits, and sets the request up as that User so the tools run with its roles.
/// See devdocs/design/4688-agent-access-mcp.md
/// </summary>
internal static class AgentAccess
{
internal const string MCP_PATH = "/mcp";
internal const string TOKEN_PREFIX = "sk_";
internal const int DEFAULT_EXPIRY_DAYS = 90;
internal const string ITEM_TOKEN_ID = "AY_AGENT_TOKEN_ID";
internal const string ITEM_READ_ONLY = "AY_AGENT_READ_ONLY";
private static readonly ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger("AgentAccess");
internal static byte[] Hash(string token) => SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token));
/// <summary>
/// A new token: sk_ and 32 random bytes in base64url. Returned once to show; only its hash is stored
/// </summary>
internal static string NewToken() =>
TOKEN_PREFIX + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
//the four limits of the design note; each setting can be overridden in server configuration
private static PartitionedRateLimiter<long> limiter;
private static PartitionedRateLimiter<long> Limiter => limiter ??= BuildLimiter();
private static PartitionedRateLimiter<long> BuildLimiter()
{
var perAgentRate = PartitionedRateLimiter.Create<long, long>(userId => RateLimitPartition.GetTokenBucketLimiter(userId, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = ServerBootConfig.SOCKEYE_AGENT_RATE_BURST,
TokensPerPeriod = ServerBootConfig.SOCKEYE_AGENT_RATE_PER_SECOND,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 0,
AutoReplenishment = true
}));
//concurrency limits queue a little rather than refuse, so a burst of parallel calls is slowed, not failed
var perAgentConcurrent = PartitionedRateLimiter.Create<long, long>(userId => RateLimitPartition.GetConcurrencyLimiter(userId, _ => new ConcurrencyLimiterOptions
{
PermitLimit = ServerBootConfig.SOCKEYE_AGENT_CONCURRENT,
QueueLimit = ServerBootConfig.SOCKEYE_AGENT_CONCURRENT * 4,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
var allAgentsConcurrent = PartitionedRateLimiter.Create<long, int>(_ => RateLimitPartition.GetConcurrencyLimiter(0, _ => new ConcurrencyLimiterOptions
{
PermitLimit = ServerBootConfig.SOCKEYE_AGENT_CONCURRENT_TOTAL,
QueueLimit = ServerBootConfig.SOCKEYE_AGENT_CONCURRENT_TOTAL * 4,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
return PartitionedRateLimiter.CreateChained(perAgentRate, perAgentConcurrent, allAgentsConcurrent);
}
/// <summary>
/// Middleware: everything except /mcp passes straight through. /mcp needs a valid agent token
/// </summary>
internal static async Task HandleAsync(HttpContext context, Func<Task> next)
{
if (!context.Request.Path.StartsWithSegments(MCP_PATH))
{
await next();
return;
}
string auth = context.Request.Headers.Authorization.ToString();
string token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth.Substring(7).Trim() : null;
if (token == null || !token.StartsWith(TOKEN_PREFIX))
{
await RefuseAsync(context, 401, "An agent token is required (Authorization: Bearer sk_...)");
return;
}
var ct = context.RequestServices.GetService(typeof(AyContext)) as AyContext;
var hash = Hash(token);
var now = DateTime.UtcNow;
var row = await ct.UserApiToken.FirstOrDefaultAsync(z => z.TokenHash == hash && z.Revoked == null && (z.Expires == null || z.Expires > now));
var user = row == null ? null : await ct.User.AsNoTracking().Where(z => z.Id == row.UserId && z.Active && z.Agent)
.Select(z => new { z.Id, z.Name, z.Roles, z.UserType, translationId = z.UserOptions.TranslationId }).FirstOrDefaultAsync();
if (user == null)
{
await Task.Delay(ServerBootConfig.FAILED_AUTH_DELAY);
await RefuseAsync(context, 401, "The agent token is not valid, has expired or was revoked, or its User is not an active agent");
return;
}
using var lease = await Limiter.AcquireAsync(user.Id, 1, context.RequestAborted);
if (!lease.IsAcquired)
{
var retry = lease.TryGetMetadata(MetadataName.RetryAfter, out var after) ? Math.Max(1, (int)Math.Ceiling(after.TotalSeconds)) : 1;
log.LogWarning($"Agent \"{user.Name}\" rate limited, retry after {retry}s");
context.Response.Headers.RetryAfter = retry.ToString();
await RefuseAsync(context, 429, $"Rate limit reached for this agent, retry after {retry} seconds");
return;
}
context.Items["AY_ROLES"] = user.Roles;
context.Items["AY_USERNAME"] = user.Name;
context.Items["AY_USER_ID"] = user.Id;
context.Items["AY_TRANSLATION_ID"] = user.translationId;
context.Items["AY_USER_TYPE"] = user.UserType;
context.Items[ITEM_TOKEN_ID] = row.Id;
context.Items[ITEM_READ_ONLY] = row.ReadOnly;
//last use, at most once a minute so a busy agent doesn't write on every call
if (row.LastUsed == null || now - row.LastUsed > TimeSpan.FromMinutes(1))
{
row.LastUsed = now;
await ct.SaveChangesAsync();
}
await next();
}
private static async Task RefuseAsync(HttpContext context, int status, string message)
{
context.Response.StatusCode = status;
if (status == 401)
context.Response.Headers.WWWAuthenticate = "Bearer";
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(Newtonsoft.Json.JsonConvert.SerializeObject(new { error = message }));
}
}
}

View File

@@ -57,10 +57,6 @@ namespace Sockeye.Biz
return await ct.TrialLicenseRequest.AnyAsync(z => z.Id == id);
case SockType.SubscriptionServer:
return await ct.SubscriptionServer.AnyAsync(z => z.Id == id);
case SockType.Purchase:
return await ct.Purchase.AnyAsync(z => z.Id == id);
case SockType.VendorNotification:
return await ct.VendorNotification.AnyAsync(z => z.Id == id);
case SockType.Product:
return await ct.Product.AnyAsync(z => z.Id == id);
case SockType.GZCase:

View File

@@ -59,10 +59,6 @@ namespace Sockeye.Biz
return new TrialLicenseRequestBiz(ct, userId, translationId, roles);
case SockType.SubscriptionServer:
return new SubscriptionServerBiz(ct, userId, translationId, roles);
case SockType.Purchase:
return new PurchaseBiz(ct, userId, translationId, roles);
case SockType.VendorNotification:
return new VendorNotificationBiz(ct, userId, translationId, roles);
case SockType.Product:
return new ProductBiz(ct, userId, translationId, roles);
case SockType.GZCase:

View File

@@ -581,40 +581,6 @@ namespace Sockeye.Biz
Select = AuthorizationRoles.All
});
////////////////////////////////////////////////////////////
//PURCHASE
//
roles.Add(SockType.Purchase, new BizRoleSet()
{
Change = AuthorizationRoles.BizAdmin
| AuthorizationRoles.Service
| AuthorizationRoles.Sales
| AuthorizationRoles.Accounting,
ReadFullRecord = AuthorizationRoles.BizAdminRestricted
| AuthorizationRoles.ServiceRestricted
| AuthorizationRoles.Tech
| AuthorizationRoles.SalesRestricted
,
Select = AuthorizationRoles.All
});
////////////////////////////////////////////////////////////
//VENDOR NOTIFICATION
//
roles.Add(SockType.VendorNotification, new BizRoleSet()
{
Change = AuthorizationRoles.BizAdmin
| AuthorizationRoles.Service
| AuthorizationRoles.Sales
| AuthorizationRoles.Accounting,
ReadFullRecord = AuthorizationRoles.BizAdminRestricted
| AuthorizationRoles.ServiceRestricted
| AuthorizationRoles.Tech
| AuthorizationRoles.SalesRestricted
,
Select = AuthorizationRoles.All
});
////////////////////////////////////////////////////////////
//PRODUCT
//

View File

@@ -326,9 +326,6 @@ namespace Sockeye.Biz
if (await ct.SubscriptionServer.AnyAsync(m => m.CustomerId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("SubscriptionServer"));
if (await ct.Purchase.AnyAsync(m => m.CustomerId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Purchase"));
if (await ct.GZCase.AnyAsync(m => m.CustomerId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("GZCase"));

View File

@@ -14,8 +14,9 @@ namespace Sockeye.Biz
public static class FormFieldOptionalCustomizableReference
{
private static Dictionary<string, List<FormField>> _formFields;
private static List<string> _formFieldKeys = null;
//case 4776 (AyaNova 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,17 +24,19 @@ namespace Sockeye.Biz
{
if (_formFieldKeys == null)
{
_formFieldKeys = new List<string>();
//case 4776: built in a local and published when complete, as FormFieldReferenceList
var formFieldKeys = new List<string>();
var values = Enum.GetValues(typeof(SockType));
foreach (SockType 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 = formFieldKeys;
}
return _formFieldKeys;
@@ -49,9 +52,11 @@ namespace Sockeye.Biz
public static List<FormField> FormFieldReferenceList(string key)
{
//Initialize the static list here on first retrieval
//case 4776 (AyaNova 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
*/
@@ -81,7 +86,7 @@ namespace Sockeye.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(SockType.User.ToString(), l);
formFields.Add(SockType.User.ToString(), l);
}
#endregion
@@ -110,7 +115,7 @@ namespace Sockeye.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
@@ -166,7 +171,7 @@ namespace Sockeye.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(SockType.Customer.ToString(), l);
formFields.Add(SockType.Customer.ToString(), l);
}
#endregion
@@ -217,7 +222,7 @@ namespace Sockeye.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(SockType.HeadOffice.ToString(), l);
formFields.Add(SockType.HeadOffice.ToString(), l);
}
#endregion
@@ -246,7 +251,7 @@ namespace Sockeye.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(SockType.Memo.ToString(), l);
formFields.Add(SockType.Memo.ToString(), l);
}
#endregion
@@ -274,7 +279,7 @@ namespace Sockeye.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(SockType.Reminder.ToString(), l);
formFields.Add(SockType.Reminder.ToString(), l);
}
#endregion
@@ -300,7 +305,7 @@ namespace Sockeye.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(SockType.Review.ToString(), l);
formFields.Add(SockType.Review.ToString(), l);
}
#endregion
@@ -351,7 +356,7 @@ namespace Sockeye.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(SockType.Vendor.ToString(), l);
formFields.Add(SockType.Vendor.ToString(), l);
}
#endregion
@@ -368,26 +373,7 @@ namespace Sockeye.Biz
l.Add(new FormField { TKey = "Attachments", FieldKey = "Attachments", Requireable = false });
_formFields.Add(SockType.Product.ToString(), l);
}
#endregion
#region Purchase
{
List<FormField> l = new List<FormField>();
l.Add(new FormField { TKey = "PurchaseExpireDate", FieldKey = "PurchaseExpireDate" });
l.Add(new FormField { TKey = "PurchaseCancelDate", FieldKey = "PurchaseCancelDate" });
l.Add(new FormField { TKey = "PurchaseCouponCode", FieldKey = "PurchaseCouponCode" });
l.Add(new FormField { TKey = "PurchaseNotes", FieldKey = "PurchaseNotes" });
l.Add(new FormField { TKey = "PurchaseVendorNotificationId", FieldKey = "PurchaseVendorNotificationId" });
l.Add(new FormField { TKey = "PurchaseProcessed", FieldKey = "PurchaseProcessed" });
l.Add(new FormField { TKey = "Tags", FieldKey = "Tags" });
l.Add(new FormField { TKey = "Wiki", FieldKey = "Wiki" });
l.Add(new FormField { TKey = "Attachments", FieldKey = "Attachments", Requireable = false });
_formFields.Add(SockType.Purchase.ToString(), l);
formFields.Add(SockType.Product.ToString(), l);
}
#endregion
@@ -408,7 +394,7 @@ namespace Sockeye.Biz
l.Add(new FormField { TKey = "Attachments", FieldKey = "Attachments", Requireable = false });
_formFields.Add(SockType.SubscriptionServer.ToString(), l);
formFields.Add(SockType.SubscriptionServer.ToString(), l);
}
#endregion
@@ -422,7 +408,7 @@ namespace Sockeye.Biz
l.Add(new FormField { TKey = "Attachments", FieldKey = "Attachments", Requireable = false });
_formFields.Add(SockType.GZCase.ToString(), l);
formFields.Add(SockType.GZCase.ToString(), l);
}
#endregion
@@ -436,7 +422,7 @@ namespace Sockeye.Biz
l.Add(new FormField { TKey = "Attachments", FieldKey = "Attachments", Requireable = false });
_formFields.Add(SockType.Subscription.ToString(), l);
formFields.Add(SockType.Subscription.ToString(), l);
}
#endregion
@@ -449,7 +435,7 @@ namespace Sockeye.Biz
l.Add(new FormField { TKey = "Tags", FieldKey = "Tags" });
_formFields.Add(SockType.TrialLicenseRequest.ToString(), l);
formFields.Add(SockType.TrialLicenseRequest.ToString(), l);
}
#endregion
@@ -461,11 +447,12 @@ namespace Sockeye.Biz
l.Add(new FormField { TKey = "Tags", FieldKey = "Tags" });
_formFields.Add(SockType.License.ToString(), l);
formFields.Add(SockType.License.ToString(), l);
}
#endregion
//******************************************************
_formFields = formFields;
}
if (!_formFields.ContainsKey(key))

View File

@@ -43,6 +43,7 @@ namespace Sockeye.Biz
//
internal async Task<GZCase> CreateAsync(GZCase newObject)
{
ApplyStatusAndClosed(newObject, null);
await ValidateAsync(newObject, null);
if (HasErrors)
return null;
@@ -93,6 +94,7 @@ namespace Sockeye.Biz
}
putObject.Tags = TagBiz.NormalizeTags(putObject.Tags);
ApplyStatusAndClosed(putObject, dbObject);
await ValidateAsync(putObject, dbObject);
if (HasErrors) return null;
ct.Replace(dbObject, putObject);
@@ -196,6 +198,7 @@ namespace Sockeye.Biz
.AddText(obj.Name)
.AddText(obj.Wiki)
.AddText(obj.Tags)
.AddText(obj.Release)
.AddText(obj.Notes);
}
@@ -203,13 +206,45 @@ namespace Sockeye.Biz
//VALIDATION
//
//case 4745: Closed follows Status. A caller that changes only Closed (the form's Close / Reopen button, the
//batch close job) moves Status with it: closing makes it Done, reopening makes it New
private static void ApplyStatusAndClosed(GZCase proposedObj, GZCase currentObj)
{
proposedObj.Release = GZCaseTriage.NormalizeRelease(proposedObj.Release);
if (currentObj != null && proposedObj.Status == currentObj.Status)
{
bool closing = proposedObj.Closed != null;
if (closing == (currentObj.Closed != null))
return;//neither changed: leave both as they are
if (closing != GZCaseTriage.IsClosing(proposedObj.Status))
proposedObj.Status = closing ? GZCaseStatus.Done : GZCaseStatus.New;
}
if (GZCaseTriage.IsClosing(proposedObj.Status))
proposedObj.Closed ??= DateTime.UtcNow;
else
proposedObj.Closed = null;
}
private async Task ValidateAsync(GZCase proposedObj, GZCase currentObj)
{
await Task.CompletedTask;
// bool isNew = currentObj == null;
bool isNew = currentObj == null;
if (!Enum.IsDefined(proposedObj.Status))
AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "Status");
//case 4745: every new case has a product; cases from before 4745 may have none until triaged, but once set it stays set
if (proposedObj.Product == null)
{
if (isNew || currentObj.Product != null)
AddError(ApiErrorCode.VALIDATION_REQUIRED, "Product");
}
else if (!Enum.IsDefined(proposedObj.Product.Value))
AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "Product");
//case 4745: a Built case names the release it's coded for (Done keeps it; work that ships nothing is Done without one)
if (proposedObj.Status == GZCaseStatus.Built && proposedObj.Release == null)
AddError(ApiErrorCode.VALIDATION_REQUIRED, "Release");
}
@@ -364,17 +399,14 @@ namespace Sockeye.Biz
}
break;
//case 4742: close (now) or reopen; a case already in that state is left as it is
//case 4745: with the status the job names (closing: Done or Never; reopening: New); Closed follows it
case JobSubType.Close:
if (o.Closed == null)
{
o.Closed = DateTime.UtcNow;
SaveIt = true;
}
break;
case JobSubType.Open:
if (o.Closed != null)
if ((o.Closed == null) == (job.SubType == JobSubType.Close))
{
o.Closed = null;
o.Status = jobData.ContainsKey("status") ? (GZCaseStatus)(int)jobData["status"]
: (job.SubType == JobSubType.Close ? GZCaseStatus.Done : GZCaseStatus.New);
o.Closed = null;//PutAsync sets it from the status: now when closing, none when reopening
SaveIt = true;
}
break;

View File

@@ -0,0 +1,46 @@
namespace Sockeye.Biz
{
/// <summary>
/// case 4745: where a case is in the process. One per case; the closing ones set the case's Closed date
/// (design note devdocs/design/4745-case-triage.md)
/// </summary>
public enum GZCaseStatus : int
{
New = 1,
Investigate = 2,
Accepted = 3,
Built = 4,
Done = 5,
Never = 6,
Duplicate = 7,
Reference = 8
}
/// <summary>
/// case 4745: what a case is about, one per case. AyaNova includes the server, client, manual and launcher
/// </summary>
public enum GZCaseProduct : int
{
AyaNova = 1,
Sockeye = 2,
QBI = 3,
AyaNova7 = 4,
Website = 5,
Business = 6
}
internal static class GZCaseTriage
{
internal static bool IsClosing(GZCaseStatus status) =>
status == GZCaseStatus.Done || status == GZCaseStatus.Never || status == GZCaseStatus.Duplicate;
//"v8.3.2" and " 8.3.2 " are both 8.3.2; empty is none
internal static string NormalizeRelease(string release)
{
if (string.IsNullOrWhiteSpace(release)) return null;
release = release.Trim();
if (release.StartsWith("v") || release.StartsWith("V")) release = release.Substring(1).Trim();
return release == "" ? null : release;
}
}
}//eons

View File

@@ -263,15 +263,10 @@ namespace Sockeye.Biz
await SockBotSubscriptionServerHealthChecks.DoWorkAsync();
if (!KeepOnWorking()) return;
//SOCKBOT - PROCESS VENDOR NOTIFICATIONS
await SockBotProcessVendorNotifications.DoWorkAsync();
//SOCKBOT - SECURITY ADVISORY MONITOR (case 4738)
await SockBotSecurityAdvisories.DoWorkAsync();
if (!KeepOnWorking()) return;
// //SOCKBOT - PROCESS PURCHASES INTO LICENSES
//ON HOLD for now
// await SockBotProcessPurchasesIntoLicenses.DoWorkAsync();
// if (!KeepOnWorking()) return;
//JOB SWEEPER
await CoreJobSweeper.DoWorkAsync();
if (!KeepOnWorking()) return;

View File

@@ -949,8 +949,6 @@ MaximumDataGB: 20
AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", "Fetched, not deletable");
//FOREIGN KEY CHECKS
if (await ct.Purchase.AnyAsync(m => m.LicenseId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Purchase"));
if (await ct.TrialLicenseRequest.AnyAsync(m => m.LicenseId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("TrialLicenseRequest"));
}

View File

@@ -34,10 +34,12 @@ Inspiring quotes used to help complete this huge project by myself
ServerStateStatusChange = 36,//on change of status, this includes new records that had no status before
ServerStateStatusAge=37,//Age used for things like if a server is fail state for too long triggers a even higher level notification etc, or reminder about trial request server awaiting activation
SubscriptionServerExpiring = 38,
SubscriptionServerLastUpdateAge = 39,//here this means the operating system, not RAVEN which should always be updated on all servers when released
//39 was SubscriptionServerLastUpdateAge (operating system update age), removed (case 4769): don't reuse it
SubscriptionServerRequestReceived = 40,
LicenseTrialRequestReceived = 41,
PurchaseNotificationReceived = 42
//42 was PurchaseNotificationReceived, removed with purchases (case 4675): don't reuse it
SecurityAdvisoryAlert = 43,//case 4738: a high / critical / known exploited advisory affecting a watched asset
SecurityAdvisoryDigest = 44//case 4738: Wednesday digest of medium advisories, ahead of Thursday's updates
//NEW ITEMS REQUIRE translation KEYS
}

143
server/biz/PasskeyBiz.cs Normal file
View File

@@ -0,0 +1,143 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.EntityFrameworkCore;
using Sockeye.Models;
using Sockeye.Util;
namespace Sockeye.Biz
{
/// <summary>
/// case 4678: passkey sign-in (WebAuthn, Fido2 library). See devdocs/design/4678-passkeys.md.
/// A passkey sign-in counts as two factors (the device checks a PIN or biometric), so it skips the TOTP prompt.
/// The options sent to the browser are kept in memory for a few minutes, keyed by a random id the browser sends back
/// </summary>
internal static class PasskeyBiz
{
internal const int MAX_PER_USER = 20;
private const int MAX_PENDING = 1000;
private static readonly TimeSpan PENDING_FOR = TimeSpan.FromMinutes(5);
private class Pending
{
public string OptionsJson;
public long UserId;//0 for a sign-in, the user for a registration
public DateTime Expires;
}
private static readonly ConcurrentDictionary<string, Pending> pending = new ConcurrentDictionary<string, Pending>();
internal static bool Configured => !string.IsNullOrWhiteSpace(ServerBootConfig.SOCKEYE_PASSKEY_RPID) && ServerBootConfig.SOCKEYE_PASSKEY_ORIGINS?.Count > 0;
internal const string NOT_CONFIGURED = "Passkeys are not set up on this server (SOCKEYE_PASSKEY_RPID and SOCKEYE_PASSKEY_ORIGINS)";
internal static Fido2 NewFido2() => new Fido2(new Fido2Configuration
{
ServerDomain = ServerBootConfig.SOCKEYE_PASSKEY_RPID,
ServerName = "Sockeye",
Origins = new HashSet<string>(ServerBootConfig.SOCKEYE_PASSKEY_ORIGINS)
});
/// <summary>
/// Keep the options until the browser answers; null when too many are waiting (a flood of anonymous requests)
/// </summary>
internal static string Remember(string optionsJson, long userId)
{
var now = DateTime.UtcNow;
foreach (var old in pending.Where(z => z.Value.Expires < now).Select(z => z.Key).ToList())
pending.TryRemove(old, out _);
if (pending.Count >= MAX_PENDING) return null;
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
pending[id] = new Pending { OptionsJson = optionsJson, UserId = userId, Expires = now + PENDING_FOR };
return id;
}
/// <summary>
/// The options for this id, used once; null if unknown, expired or for another user
/// </summary>
internal static string Take(string id, long userId)
{
if (string.IsNullOrWhiteSpace(id) || !pending.TryRemove(id, out var p)) return null;
if (p.Expires < DateTime.UtcNow || p.UserId != userId) return null;
return p.OptionsJson;
}
internal static async Task<CredentialCreateOptions> RegistrationOptionsAsync(AyContext ct, User user)
{
var existing = await ct.UserPasskey.AsNoTracking().Where(z => z.UserId == user.Id).ToListAsync();
var handle = existing.FirstOrDefault()?.UserHandle ?? RandomNumberGenerator.GetBytes(32);
return NewFido2().RequestNewCredential(new RequestNewCredentialParams
{
User = new Fido2User { Id = handle, Name = user.Login, DisplayName = user.Name },
ExcludeCredentials = existing.Select(z => new PublicKeyCredentialDescriptor(z.CredentialId)).ToList(),
AuthenticatorSelection = new AuthenticatorSelection
{
ResidentKey = ResidentKeyRequirement.Required,
UserVerification = UserVerificationRequirement.Required
},
AttestationPreference = AttestationConveyancePreference.None
});
}
internal static AssertionOptions SignInOptions() => NewFido2().GetAssertionOptions(new GetAssertionOptionsParams
{
AllowedCredentials = new List<PublicKeyCredentialDescriptor>(),
UserVerification = UserVerificationRequirement.Required
});
/// <summary>
/// Verify a new passkey and store it; throws Fido2VerificationException when the browser's answer doesn't check out
/// </summary>
internal static async Task<UserPasskey> RegisterAsync(AyContext ct, long userId, string optionsJson, string responseJson, string name)
{
var options = CredentialCreateOptions.FromJson(optionsJson);
var response = System.Text.Json.JsonSerializer.Deserialize<AuthenticatorAttestationRawResponse>(responseJson);
var credential = await NewFido2().MakeNewCredentialAsync(new MakeNewCredentialParams
{
AttestationResponse = response,
OriginalOptions = options,
IsCredentialIdUniqueToUserCallback = async (args, cancel) => !await ct.UserPasskey.AnyAsync(z => z.CredentialId == args.CredentialId, cancel)
});
var passkey = new UserPasskey
{
UserId = userId,
Name = string.IsNullOrWhiteSpace(name) ? "Passkey" : StringUtil.MaxLength(name.Trim(), 100),
CredentialId = credential.Id,
PublicKey = credential.PublicKey,
SignCount = credential.SignCount,
UserHandle = options.User.Id,
Created = DateTime.UtcNow
};
ct.UserPasskey.Add(passkey);
await ct.SaveChangesAsync();
return passkey;
}
/// <summary>
/// Verify a sign-in; the passkey used, or null when it isn't one of ours. Throws Fido2VerificationException when the
/// signature, challenge, origin or counter don't check out
/// </summary>
internal static async Task<UserPasskey> VerifySignInAsync(AyContext ct, string optionsJson, string responseJson)
{
var options = AssertionOptions.FromJson(optionsJson);
var response = System.Text.Json.JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(responseJson);
var passkey = await ct.UserPasskey.FirstOrDefaultAsync(z => z.CredentialId == response.RawId);
if (passkey == null) return null;
var result = await NewFido2().MakeAssertionAsync(new MakeAssertionParams
{
AssertionResponse = response,
OriginalOptions = options,
StoredPublicKey = passkey.PublicKey,
StoredSignatureCounter = (uint)passkey.SignCount,
IsUserHandleOwnerOfCredentialIdCallback = (args, cancel) => Task.FromResult(args.UserHandle.SequenceEqual(passkey.UserHandle))
});
passkey.SignCount = result.SignCount;
passkey.LastUsed = DateTime.UtcNow;
await ct.SaveChangesAsync();
return passkey;
}
}
}

View File

@@ -275,8 +275,6 @@ namespace Sockeye.Biz
private async Task ValidateCanDeleteAsync(Product inObj)
{
if (await ct.Purchase.AnyAsync(m => m.ProductId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Purchase"));
if (await ct.SubscriptionItem.AnyAsync(m => m.ProductId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Subscription"));
}

View File

@@ -1,483 +0,0 @@
using System;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Linq;
using Sockeye.Util;
using Sockeye.Api.ControllerHelpers;
using Sockeye.Models;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Sockeye.Biz
{
internal class PurchaseBiz : BizObject, IJobObject, ISearchAbleObject, IReportAbleObject, IExportAbleObject, INotifiableObject
{
internal PurchaseBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles)
{
ct = dbcontext;
UserId = currentUserId;
UserTranslationId = userTranslationId;
CurrentUserRoles = UserRoles;
BizType = SockType.Purchase;
}
internal static PurchaseBiz GetBiz(AyContext ct, Microsoft.AspNetCore.Http.HttpContext httpContext = null)
{
if (httpContext != null)
return new PurchaseBiz(ct, UserIdFromContext.Id(httpContext.Items), UserTranslationIdFromContext.Id(httpContext.Items), UserRolesFromContext.Roles(httpContext.Items));
else
return new PurchaseBiz(ct, 1, ServerBootConfig.SOCKEYE_DEFAULT_TRANSLATION_ID, AuthorizationRoles.BizAdmin);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//EXISTS
internal async Task<bool> ExistsAsync(long id)
{
return await ct.Purchase.AnyAsync(z => z.Id == id);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//CREATE
//
internal async Task<Purchase> CreateAsync(Purchase newObject)
{
await ValidateAsync(newObject, null);
if (HasErrors)
return null;
else
{
newObject.Tags = TagBiz.NormalizeTags(newObject.Tags);
await ct.Purchase.AddAsync(newObject);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, SockEvent.Created), ct);
await SearchIndexAsync(newObject, true);
await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null);
await HandlePotentialNotificationEvent(SockEvent.Created, newObject);
return newObject;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
//GET
//
internal async Task<Purchase> GetAsync(long id, bool logTheGetEvent = true)
{
var ret = await ct.Purchase.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id);
if (logTheGetEvent && ret != null)
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, SockEvent.Retrieved), ct);
return ret;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//UPDATE
//
internal async Task<Purchase> PutAsync(Purchase putObject)
{
var dbObject = await GetAsync(putObject.Id, false);
if (dbObject == null)
{
AddError(ApiErrorCode.NOT_FOUND, "id");
return null;
}
if (dbObject.Concurrency != putObject.Concurrency)
{
AddError(ApiErrorCode.CONCURRENCY_CONFLICT);
return null;
}
putObject.Tags = TagBiz.NormalizeTags(putObject.Tags);
await ValidateAsync(putObject, dbObject);
if (HasErrors) return null;
ct.Replace(dbObject, putObject);
try
{
await ct.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await ExistsAsync(putObject.Id))
AddError(ApiErrorCode.NOT_FOUND);
else
AddError(ApiErrorCode.CONCURRENCY_CONFLICT);
return null;
}
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, BizType, SockEvent.Modified), ct);
await SearchIndexAsync(putObject, false);
await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags);
await HandlePotentialNotificationEvent(SockEvent.Modified, putObject, dbObject);
return putObject;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//DELETE
//
internal async Task<bool> DeleteAsync(long id)
{
using (var transaction = await ct.Database.BeginTransactionAsync())
{
Purchase dbObject = await GetAsync(id, false);
if (dbObject == null)
{
AddError(ApiErrorCode.NOT_FOUND);
return false;
}
await ValidateCanDeleteAsync(dbObject);
if (HasErrors)
return false;
{
var IDList = await ct.Review.AsNoTracking().Where(x => x.SockType == SockType.Purchase && x.ObjectId == id).Select(x => x.Id).ToListAsync();
if (IDList.Count() > 0)
{
ReviewBiz b = new ReviewBiz(ct, UserId, UserTranslationId, CurrentUserRoles);
foreach (long ItemId in IDList)
if (!await b.DeleteAsync(ItemId, transaction))
{
AddError(ApiErrorCode.CHILD_OBJECT_ERROR, null, $"Review [{ItemId}]: {b.GetErrorsAsString()}");
return false;
}
}
}
ct.Purchase.Remove(dbObject);
await ct.SaveChangesAsync();
//Log event
await EventLogProcessor.DeleteObjectLogAsync(UserId, BizType, dbObject.Id, dbObject.Name, ct);
await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, BizType, ct);
await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags);
await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct);
await transaction.CommitAsync();
await HandlePotentialNotificationEvent(SockEvent.Deleted, dbObject);
return true;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
//SEARCH
//
private async Task SearchIndexAsync(Purchase obj, bool isNew)
{
var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, BizType);
DigestSearchText(obj, SearchParams);
if (isNew)
await Search.ProcessNewObjectKeywordsAsync(SearchParams);
else
await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams);
}
public async Task<Search.SearchIndexProcessObjectParameters> GetSearchResultSummary(long id, SockType specificType)
{
var obj = await GetAsync(id, false);
var SearchParams = new Search.SearchIndexProcessObjectParameters();
DigestSearchText(obj, SearchParams);
return SearchParams;
}
public void DigestSearchText(Purchase obj, Search.SearchIndexProcessObjectParameters searchParams)
{
if (obj != null)
searchParams.AddText(obj.SalesOrderNumber)
.AddText(obj.CouponCode)
.AddText(obj.Wiki)
.AddText(obj.Tags)
.AddText(obj.Notes);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//VALIDATION
//
private async Task ValidateAsync(Purchase proposedObj, Purchase currentObj)
{
await Task.CompletedTask;
}
private async Task ValidateCanDeleteAsync(Purchase inObj)
{
await Task.CompletedTask;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//REPORTING
//
public async Task<JArray> GetReportData(DataListSelectedRequest dataListSelectedRequest, Guid jobId)
{
var idList = dataListSelectedRequest.SelectedRowIds;
JArray ReportData = new JArray();
while (idList.Any())
{
var batch = idList.Take(IReportAbleObject.REPORT_DATA_BATCH_SIZE);
idList = idList.Skip(IReportAbleObject.REPORT_DATA_BATCH_SIZE).ToArray();
//query for this batch, comes back in db natural order unfortunately
var batchResults = await ct.Purchase.AsNoTracking().Where(z => batch.Contains(z.Id)).ToArrayAsync();
//order the results back into original
//What is happening here:
//for performance the query is batching a bunch at once by fetching a block of items from the sql server
//however it's returning in db order which is often not the order the id list is in
//so it needs to be sorted back into the same order as the ide list
//This would not be necessary if just fetching each one at a time individually (like in workorder get report data)
var orderedList = from id in batch join z in batchResults on id equals z.Id select z;
batchResults = null;
foreach (Purchase w in orderedList)
{
if (!ReportRenderManager.KeepGoing(jobId)) return null;
await PopulateVizFields(w);
var jo = JObject.FromObject(w);
if (!JsonUtil.JTokenIsNullOrEmpty(jo["CustomFields"]))
jo["CustomFields"] = JObject.Parse((string)jo["CustomFields"]);
ReportData.Add(jo);
}
orderedList = null;
}
vc.Clear();
return ReportData;
}
private VizCache vc = new VizCache();
//populate viz fields from provided object
private async Task PopulateVizFields(Purchase o)
{
if (!vc.Has("customer", o.CustomerId))
{
vc.Add(await ct.Customer.AsNoTracking().Where(x => x.Id == o.CustomerId).Select(x => x.Name).FirstOrDefaultAsync(), "customer", o.CustomerId);
}
o.CustomerViz = vc.Get("customer", o.CustomerId);
if (!vc.Has("vendor", o.VendorId))
{
vc.Add(await ct.Vendor.AsNoTracking().Where(x => x.Id == o.VendorId).Select(x => x.Name).FirstOrDefaultAsync(), "vendor", o.VendorId);
}
o.VendorViz = vc.Get("vendor", o.VendorId);
if (!vc.Has("product", o.ProductId))
{
vc.Add(await ct.Product.AsNoTracking().Where(x => x.Id == o.ProductId).Select(x => x.Name).FirstOrDefaultAsync(), "product", o.ProductId);
}
o.ProductViz = vc.Get("product", o.ProductId);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// IMPORT EXPORT
//
public async Task<JArray> GetExportData(DataListSelectedRequest dataListSelectedRequest, Guid jobId)
{
return await GetReportData(dataListSelectedRequest, jobId);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//JOB / OPERATIONS
//
public async Task HandleJobAsync(OpsJob job)
{
//Hand off the particular job to the corresponding processing code
//NOTE: If this code throws an exception the caller (JobsBiz::ProcessJobsAsync) will automatically set the job to failed and log the exeption so
//basically any error condition during job processing should throw up an exception if it can't be handled
switch (job.JobType)
{
case JobType.BatchCoreObjectOperation:
await ProcessBatchJobAsync(job);
break;
default:
throw new System.ArgumentOutOfRangeException($"PurchaseBiz.HandleJob-> Invalid job type{job.JobType.ToString()}");
}
}
private async Task ProcessBatchJobAsync(OpsJob job)
{
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Running);
await JobsBiz.LogJobAsync(job.GId, $"LT:StartJob {job.SubType}");
List<long> idList = new List<long>();
long FailedObjectCount = 0;
JObject jobData = JObject.Parse(job.JobInfo);
if (jobData.ContainsKey("idList"))
idList = ((JArray)jobData["idList"]).ToObject<List<long>>();
else
idList = await ct.Purchase.AsNoTracking().Select(z => z.Id).ToListAsync();
bool SaveIt = false;
//---------------------------------
//case 4192
TimeSpan ProgressAndCancelCheckSpan = new TimeSpan(0, 0, ServerBootConfig.JOB_PROGRESS_UPDATE_AND_CANCEL_CHECK_SECONDS);
DateTime LastProgressCheck = DateTime.UtcNow.Subtract(new TimeSpan(1, 1, 1, 1, 1));
var TotalRecords = idList.LongCount();
long CurrentRecord = -1;
//---------------------------------
foreach (long id in idList)
{
try
{
//--------------------------------
//case 4192
//Update progress / cancel requested?
CurrentRecord++;
if (DateUtil.IsAfterDuration(LastProgressCheck, ProgressAndCancelCheckSpan))
{
await JobsBiz.UpdateJobProgressAsync(job.GId, $"{CurrentRecord}/{TotalRecords}");
if (await JobsBiz.GetJobStatusAsync(job.GId) == JobStatus.CancelRequested)
break;
LastProgressCheck = DateTime.UtcNow;
}
//---------------------------------
SaveIt = false;
ClearErrors();
Purchase o = null;
//save a fetch if it's a delete
if (job.SubType != JobSubType.Delete)
o = await GetAsync(id, false);
switch (job.SubType)
{
case JobSubType.TagAddAny:
case JobSubType.TagAdd:
case JobSubType.TagRemoveAny:
case JobSubType.TagRemove:
case JobSubType.TagReplaceAny:
case JobSubType.TagReplace:
SaveIt = TagBiz.ProcessBatchTagOperation(o.Tags, (string)jobData["tag"], jobData.ContainsKey("toTag") ? (string)jobData["toTag"] : null, job.SubType);
break;
case JobSubType.Delete:
if (!await DeleteAsync(id))
{
await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}");
FailedObjectCount++;
}
break;
default:
throw new System.ArgumentOutOfRangeException($"ProcessBatchJobAsync -> Invalid job Subtype{job.SubType}");
}
if (SaveIt)
{
o = await PutAsync(o);
if (o == null)
{
await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}");
FailedObjectCount++;
}
}
//delay so we're not tying up all the resources in a tight loop
await Task.Delay(Sockeye.Util.ServerBootConfig.JOB_OBJECT_HANDLE_BATCH_JOB_LOOP_DELAY);
}
catch (Exception ex)
{
await JobsBiz.LogJobAsync(job.GId, $"LT:Errors id({id})");
await JobsBiz.LogJobAsync(job.GId, ExceptionUtil.ExtractAllExceptionMessages(ex));
}
}
//---------------------------------
//case 4192
await JobsBiz.UpdateJobProgressAsync(job.GId, $"{++CurrentRecord}/{TotalRecords}");
//---------------------------------
await JobsBiz.LogJobAsync(job.GId, $"LT:BatchJob {job.SubType} {idList.Count}{(FailedObjectCount > 0 ? " - LT:Failed " + FailedObjectCount : "")}");
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Completed);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// NOTIFICATION PROCESSING
//
public async Task HandlePotentialNotificationEvent(SockEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null)
{
if (ServerBootConfig.MIGRATING) return;
ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger<PurchaseBiz>();
log.LogDebug($"HandlePotentialNotificationEvent processing: [SockType:{this.BizType}, AyaEvent:{ayaEvent}]");
bool isNew = currentObj == null;
proposedObj.Name = "PURCHASE ID" + proposedObj.Id.ToString();
//STANDARD EVENTS FOR ALL OBJECTS
await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct);
//SPECIFIC EVENTS FOR THIS OBJECT
Purchase o = (Purchase)proposedObj;
//## DELETED EVENTS
//any event added below needs to be removed, so
//just blanket remove any event for this object of eventtype that would be added below here
//do it regardless any time there's an update and then
//let this code below handle the refreshing addition that could have changes
//await NotifyEventHelper.ClearPriorEventsForObject(ct, SockType.Purchase, o.Id, NotifyEventType.ContractExpiring);
//## CREATED / MODIFIED EVENTS
if (ayaEvent == SockEvent.Created || ayaEvent == SockEvent.Modified)
{
//# NEW PURCHASE VENDOR NOTIFICATION
if (ayaEvent == SockEvent.Created)
{
//Get product name and get customer name to notify
var productName = await ct.Product.AsNoTracking().Where(x => x.Id == o.ProductId).Select(x => x.Name).FirstOrDefaultAsync();
var customerName = await ct.Customer.AsNoTracking().Where(x => x.Id == o.CustomerId).Select(x => x.Name).FirstOrDefaultAsync();
{
//Newly created only and immediate delivery so no need to remove
var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.PurchaseNotificationReceived).ToListAsync();
foreach (var sub in subs)
{
//not for inactive users
if (!await UserBiz.UserIsActive(sub.UserId)) continue;
NotifyEvent n = new NotifyEvent()
{
EventType = NotifyEventType.PurchaseNotificationReceived,
UserId = sub.UserId,
SockType = BizType,
ObjectId = o.Id,
NotifySubscriptionId = sub.Id,
Name = $"Purchase - {customerName}, {productName} x {o.Quantity} "
};
await ct.NotifyEvent.AddAsync(n);
log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]");
await ct.SaveChangesAsync();
}
}
}//new purchase notification event
}
}//end of process notifications
/////////////////////////////////////////////////////////////////////
}//eoc
}//eons

View File

@@ -669,7 +669,9 @@ namespace Sockeye.Biz
{
EventType = NotifyEventType.ReviewImminent,
UserId = sub.UserId,
SockType = o.SockType,
//case 4779: the review's own type (o.SockType is the reviewed record's): the clean-up above finds it
//when the review changes or is deleted, and the notice opens the review
SockType = SockType.Review,
ObjectId = o.Id,
NotifySubscriptionId = sub.Id,
Name = o.Name,

View File

@@ -0,0 +1,86 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Sockeye.Models;
namespace Sockeye.Biz
{
/// <summary>
/// case 4738: the rules that decide what an advisory means for us; no I/O, so it's unit tested on saved feed data
/// </summary>
internal static class SecurityAdvisoryPolicy
{
//Ubuntu's own priority for a CVE, the primary filter for Ubuntu notices
internal static AdvisorySeverity FromUbuntuPriority(string priority)
{
switch ((priority ?? "").Trim().ToLowerInvariant())
{
case "critical": return AdvisorySeverity.Critical;
case "high": return AdvisorySeverity.High;
case "medium": return AdvisorySeverity.Medium;
case "low": return AdvisorySeverity.Low;
case "negligible": return AdvisorySeverity.Negligible;
default: return AdvisorySeverity.Unknown;
}
}
//CVSS v3 base score bands, for sources that only give a score (PostgreSQL)
internal static AdvisorySeverity FromCvss(decimal? score)
{
if (score == null) return AdvisorySeverity.Unknown;
if (score >= 9.0m) return AdvisorySeverity.Critical;
if (score >= 7.0m) return AdvisorySeverity.High;
if (score >= 4.0m) return AdvisorySeverity.Medium;
if (score > 0m) return AdvisorySeverity.Low;
return AdvisorySeverity.Negligible;
}
internal static decimal? ParseScore(string s)
{
return decimal.TryParse((s ?? "").Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out decimal d) ? d : null;
}
/// <summary>
/// Match an advisory against the active assets. Outcome is the strongest over the matching assets: known exploited
/// or at/above an asset's alert level alerts; at/above its digest level goes in the digest. An unknown severity
/// (no CVE priority yet) counts as medium so it is seen, not dropped
/// </summary>
internal static (AdvisoryOutcome outcome, string matched) Evaluate(AdvisoryItem item, IEnumerable<SecurityAsset> assets)
{
var outcome = AdvisoryOutcome.NoMatch;
var matched = new List<string>();
var severity = item.Severity == AdvisorySeverity.Unknown ? AdvisorySeverity.Medium : item.Severity;
foreach (var asset in assets.Where(z => z.Active))
{
if (!item.Affected.TryGetValue(asset.Platform, out var affected)) continue;
var hits = affected.Intersect(asset.Components ?? new List<string>()).OrderBy(z => z).ToList();
if (hits.Count == 0) continue;
matched.Add($"{asset.Name}: {string.Join(", ", hits)}");
AdvisoryOutcome forAsset;
if (item.KnownExploited || severity >= asset.AlertAt)
forAsset = AdvisoryOutcome.Alerted;
else if (severity >= asset.DigestAt)
forAsset = AdvisoryOutcome.Digest;
else
forAsset = AdvisoryOutcome.Ignored;
if (forAsset > outcome) outcome = forAsset;
}
return (outcome, matched.Count == 0 ? null : string.Join("; ", matched));
}
/// <summary>
/// Clean up a pasted component list: dpkg-query output, or a comma / space separated list
/// </summary>
internal static List<string> NormalizeComponents(IEnumerable<string> input)
{
if (input == null) return new List<string>();
return input.SelectMany(z => (z ?? "").Split(new[] { '\n', '\r', ',', ' ', '\t' }, System.StringSplitOptions.RemoveEmptyEntries))
.Select(z => z.Trim().ToLowerInvariant())
.Where(z => z.Length > 0)
.Distinct()
.OrderBy(z => z, System.StringComparer.Ordinal)
.ToList();
}
}
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace Sockeye.Biz
{
//case 4738: advisory sources. Each Parse method takes the raw response so it can be tested on saved data
internal static class SecurityAdvisoryHttp
{
internal static string UserAgent => $"Sockeye/{Sockeye.Util.SockeyeVersion.VersionString} (support@ayanova.com)";
internal static async Task<string> GetStringAsync(HttpClient client, string url)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
using var res = await client.SendAsync(req);
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{url} returned {(int)res.StatusCode} {res.ReasonPhrase}");
return await res.Content.ReadAsStringAsync();
}
}
/// <summary>
/// Ubuntu Security Notices for the tracked releases (platforms "ubuntu:codename"); newest first, 20 a page,
/// paged back until older than the date given. A notice for several releases comes back once with each release's packages
/// </summary>
internal class UbuntuUsnSource : IAdvisorySource
{
internal const string NAME = "ubuntu-usn";
private const string BASE = "https://ubuntu.com/security";
private const int PAGE = 20, MAX_PAGES = 50;
public string Name => NAME;
public async Task<List<AdvisoryItem>> FetchAsync(HttpClient client, DateTime? since, IEnumerable<string> platforms)
{
var codenames = platforms.Where(z => z.StartsWith("ubuntu:")).Select(z => z.Substring(7)).Distinct().ToList();
var byId = new Dictionary<string, AdvisoryItem>();
foreach (var codename in codenames)
{
for (int page = 0; page < MAX_PAGES; page++)
{
var json = await SecurityAdvisoryHttp.GetStringAsync(client, $"{BASE}/notices.json?release={codename}&limit={PAGE}&offset={page * PAGE}&order=newest");
var items = ParseNotices(json, out int count);
foreach (var i in items)
{
if (since != null && i.Published < since) continue;
if (byId.TryGetValue(i.SourceId, out var existing))
foreach (var kv in i.Affected) existing.Affected[kv.Key] = kv.Value;
else
byId[i.SourceId] = i;
}
if (count < PAGE || since == null || items.Any(z => z.Published < since)) break;
}
}
return byId.Values.ToList();
}
internal static List<AdvisoryItem> ParseNotices(string json, out int count)
{
var ret = new List<AdvisoryItem>();
var notices = (JArray)JObject.Parse(json)["notices"];
count = notices.Count;
foreach (JObject n in notices)
{
var item = new AdvisoryItem
{
Source = NAME,
SourceId = (string)n["id"],
//published has no zone; Ubuntu's times are UTC
Published = DateTime.SpecifyKind((DateTime)n["published"], DateTimeKind.Utc),
Title = (string)n["title"],
Url = $"https://ubuntu.com/security/notices/{(string)n["id"]}",
CveIds = (n["cves_ids"] as JArray)?.Select(z => (string)z).ToList() ?? new List<string>()
};
if (n["release_packages"] is JObject rp)
foreach (var rel in rp.Properties())
item.Affected["ubuntu:" + rel.Name] = ((JArray)rel.Value)
.Where(p => (bool?)p["is_source"] == true)
.Select(p => ((string)p["name"]).ToLowerInvariant()).Distinct().ToList();
ret.Add(item);
}
return ret;
}
internal static async Task<string> FetchCvePriorityAsync(HttpClient client, string cveId)
{
var json = await SecurityAdvisoryHttp.GetStringAsync(client, $"{BASE}/cves/{cveId}.json");
return (string)JObject.Parse(json)["priority"];
}
}
/// <summary>
/// The PostgreSQL project's security page (platform "postgresql", components = major versions). It has no feed and
/// no dates: every row is returned and the processed store decides what is new. A page with no rows is an error,
/// so a layout change is reported, not taken for a quiet week
/// </summary>
internal class PostgresSecuritySource : IAdvisorySource
{
internal const string NAME = "postgresql";
public string Name => NAME;
public async Task<List<AdvisoryItem>> FetchAsync(HttpClient client, DateTime? since, IEnumerable<string> platforms)
{
var html = await SecurityAdvisoryHttp.GetStringAsync(client, "https://www.postgresql.org/support/security/");
var items = ParsePage(html);
if (items.Count == 0)
throw new FormatException("PostgreSQL security page had no advisories: its layout may have changed");
return items;
}
internal static List<AdvisoryItem> ParsePage(string html)
{
var byCve = new Dictionary<string, AdvisoryItem>();
foreach (Match row in Regex.Matches(html, @"<tr>\s*<td>(.*?)</tr>", RegexOptions.Singleline))
{
var cells = Regex.Matches(row.Value, @"<td>(.*?)</td>", RegexOptions.Singleline).Select(z => z.Groups[1].Value).ToList();
if (cells.Count < 5) continue;
var cve = Regex.Match(cells[0], @"CVE-\d{4}-\d+").Value;
if (cve.Length == 0 || byCve.ContainsKey(cve)) continue;
var score = Regex.Match(cells[3], @">(\d+(\.\d+)?)</a>").Groups[1].Value;
var component = Text(cells[3].Split("<br")[0]);
var item = new AdvisoryItem
{
Source = NAME,
SourceId = cve,
Title = $"{cve} {Text(cells[4].Split("<br")[0])} ({component})",
Url = $"https://www.postgresql.org/support/security/{cve}/",
CveIds = new List<string> { cve },
RawSeverity = score.Length == 0 ? null : $"CVSS {score}",
Severity = SecurityAdvisoryPolicy.FromCvss(SecurityAdvisoryPolicy.ParseScore(score))
};
item.Affected["postgresql"] = Text(cells[1]).Split(',').Select(z => z.Trim()).Where(z => z.Length > 0).ToList();
byCve[cve] = item;
}
return byCve.Values.ToList();
}
private static string Text(string html) => WebUtility.HtmlDecode(Regex.Replace(html, "<[^>]+>", "")).Trim();
}
/// <summary>
/// CISA's Known Exploited Vulnerabilities catalogue: the CVE ids that are being exploited in the wild, with the date added
/// </summary>
internal static class CisaKev
{
internal const string NAME = "cisa-kev";
internal static async Task<Dictionary<string, DateTime>> FetchAsync(HttpClient client)
{
var json = await SecurityAdvisoryHttp.GetStringAsync(client, "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json");
var ret = Parse(json);
if (ret.Count == 0)
throw new FormatException("CISA KEV feed had no entries");
return ret;
}
internal static Dictionary<string, DateTime> Parse(string json)
{
var ret = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
foreach (var v in (JArray)JObject.Parse(json)["vulnerabilities"])
{
var id = (string)v["cveID"];
if (string.IsNullOrWhiteSpace(id)) continue;
ret[id] = DateTime.TryParse((string)v["dateAdded"], System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var d) ? d : DateTime.MinValue;
}
return ret;
}
}
}

View File

@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
namespace Sockeye.Biz
{
//case 4738: security advisory monitor, see devdocs/design/4738-security-advisory-monitor.md
public enum AdvisorySeverity
{
Unknown = 0,
Negligible = 1,
Low = 2,
Medium = 3,
High = 4,
Critical = 5
}
public enum AdvisoryOutcome
{
NoMatch = 0,//affects nothing we watch
Ignored = 1,//matched, below the digest threshold
Digest = 2,//matched, goes in the Wednesday digest
Alerted = 3//matched, alerted at once
}
/// <summary>
/// An advisory from any source in one shape, before it is matched against the watched assets
/// </summary>
internal class AdvisoryItem
{
public string Source { get; set; }
public string SourceId { get; set; }
public DateTime? Published { get; set; }
public string Title { get; set; }
public string Url { get; set; }
public List<string> CveIds { get; set; } = new List<string>();
//affected components per platform, e.g. "ubuntu:resolute" -> source package names, "postgresql" -> major versions
public Dictionary<string, List<string>> Affected { get; set; } = new Dictionary<string, List<string>>();
public AdvisorySeverity Severity { get; set; }
public string RawSeverity { get; set; }
public bool KnownExploited { get; set; }
}
internal interface IAdvisorySource
{
string Name { get; }
/// <summary>
/// Advisories published since the date given (all the source has when null); throws when the source can't be read
/// </summary>
Task<List<AdvisoryItem>> FetchAsync(HttpClient client, DateTime? since, IEnumerable<string> platforms);
}
}

View File

@@ -70,14 +70,12 @@ namespace Sockeye.Biz
TrialLicenseRequest = 94,
[CoreBizObject, ReportableBizObject]
SubscriptionServer = 95,
[CoreBizObject, ReportableBizObject]
Purchase = 96,
Purchase = 96,//case 4675: purchases removed; kept, without the attributes, so old event log entries still show their type
[CoreBizObject, ReportableBizObject]
Product = 97,
[CoreBizObject, ReportableBizObject]
GZCase = 98,
[CoreBizObject, ReportableBizObject]
VendorNotification = 99,
VendorNotification = 99,//case 4675: vendor notifications removed; kept as for Purchase
[CoreBizObject, ReportableBizObject]
Subscription = 100,
[CoreBizObject, ReportableBizObject]

View File

@@ -0,0 +1,362 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Server;
using Sockeye.Api.ControllerHelpers;
using Sockeye.Models;
namespace Sockeye.Biz
{
/// <summary>
/// case 4688: the tools an agent sees at /mcp (phase 1 read-only, phase 2 the write tools for cases at the end). They run as the agent User (AgentAccess sets the request
/// up), check its rights per record type as the UI does, return only the fields an agent needs and at most MAX records.
/// Calls are logged to the server log (not the event log: one event row per call would bloat it, as health checks did, case 4749)
/// </summary>
[McpServerToolType]
public class SockeyeMcpTools
{
private const int MAX = 100;
private readonly AyContext ct;
private readonly HttpContext http;
private readonly ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger("SockeyeMcpTools");
public SockeyeMcpTools(AyContext dbcontext, IHttpContextAccessor accessor)
{
ct = dbcontext;
http = accessor.HttpContext;
}
private AuthorizationRoles Roles => UserRolesFromContext.Roles(http.Items);
private void Require(SockType type, string tool)
{
log.LogInformation($"MCP {tool} by agent \"{http.Items["AY_USERNAME"]}\"");
if (!Authorized.HasReadFullRole(Roles, type))
throw new UnauthorizedAccessException($"This agent's roles don't allow reading {type} records");
}
private static int Cap(int? limit) => Math.Clamp(limit ?? 50, 1, MAX);
[McpServerTool(Name = "search", ReadOnly = true), Description("Search all Sockeye records (cases, customers, licences, subscription servers, trial requests...) for words or a phrase. Returns type, id and name of each match.")]
public async Task<object> SearchAsync(
[Description("Words or phrase to find")] string query,
[Description("Most results to return, 1 to 100 (default 50)")] int? limit = null)
{
log.LogInformation($"MCP search by agent \"{http.Items["AY_USERNAME"]}\": {query}");
var r = await Search.DoSearchAsync(ct, UserTranslationIdFromContext.Id(http.Items), Roles, UserIdFromContext.Id(http.Items),
new Search.SearchRequestParameters { Phrase = query, MaxResults = Cap(limit) });
return new { total = r.TotalResultsFound, results = r.SearchResults.Select(z => new { type = z.Type.ToString(), z.Id, z.Name }) };
}
//case 4688 phase 2: case Status and Product go in and out by name ("Investigate", "AyaNova 7"), never as numbers
private const string STATUS_NAMES = "New, Investigate, Accepted, Built, Done, Never, Duplicate, Reference";
private const string PRODUCT_NAMES = "AyaNova, Sockeye, QBI, AyaNova 7, Website, Business";
internal static string ProductName(GZCaseProduct? p) => p == null ? null : p == GZCaseProduct.AyaNova7 ? "AyaNova 7" : p.ToString();
internal static bool TryStatus(string name, out GZCaseStatus status) =>
Enum.TryParse(name?.Trim(), true, out status) && Enum.IsDefined(status) && !int.TryParse(name, out _);
internal static bool TryProduct(string name, out GZCaseProduct product) =>
Enum.TryParse(name?.Replace(" ", ""), true, out product) && Enum.IsDefined(product) && !int.TryParse(name, out _);
[McpServerTool(Name = "get_case", ReadOnly = true), Description("A case (GZCase) by its case number: name, status, product, release, notes, tags, customer, created and closed dates, and the concurrency value replace_case_notes needs.")]
public async Task<object> GetCaseAsync([Description("The case number, e.g. 4675")] long caseNumber)
{
Require(SockType.GZCase, "get_case");
var c = await ct.GZCase.AsNoTracking().FirstOrDefaultAsync(z => z.CaseId == caseNumber);
if (c == null) return new { error = $"No case {caseNumber}" };
var customer = c.CustomerId == null ? null : await ct.Customer.AsNoTracking().Where(z => z.Id == c.CustomerId).Select(z => z.Name).FirstOrDefaultAsync();
return new
{
caseNumber = c.CaseId, c.Id, c.Name, status = c.Status.ToString(), product = ProductName(c.Product), c.Release,
c.Notes, c.Tags, customer, c.CustomerId, c.Created, c.Closed, c.Concurrency
};
}
[McpServerTool(Name = "list_cases", ReadOnly = true), Description("Cases, open ones only unless includeClosed; newest first, or oldest first for a backlog review. Filter by status (" + STATUS_NAMES + "), product (" + PRODUCT_NAMES + "), release (e.g. 8.3.2) and tag. Returns number, name, status, product, release, tags, created and closed; use get_case for the notes.")]
public async Task<object> ListCasesAsync(
[Description("Only this status: " + STATUS_NAMES)] string status = null,
[Description("Only this product: " + PRODUCT_NAMES)] string product = null,
[Description("Only this release, e.g. 8.3.2")] string release = null,
[Description("Only cases with this tag")] string tag = null,
[Description("Closed cases too (default open only)")] bool includeClosed = false,
[Description("Oldest first (default newest first)")] bool oldestFirst = false,
[Description("Most results to return, 1 to 100 (default 50)")] int? limit = null)
{
Require(SockType.GZCase, "list_cases");
var q = ct.GZCase.AsNoTracking();
if (!includeClosed)
q = q.Where(z => z.Closed == null);
if (!string.IsNullOrWhiteSpace(status))
{
if (!TryStatus(status, out var s)) return new { error = $"Unknown status \"{status}\"; one of: {STATUS_NAMES}" };
q = q.Where(z => z.Status == s);
}
if (!string.IsNullOrWhiteSpace(product))
{
if (!TryProduct(product, out var p)) return new { error = $"Unknown product \"{product}\"; one of: {PRODUCT_NAMES}" };
q = q.Where(z => z.Product == p);
}
var r = GZCaseTriage.NormalizeRelease(release);
if (r != null)
q = q.Where(z => z.Release == r);
if (!string.IsNullOrWhiteSpace(tag))
{
var t = TagBiz.NormalizeTag(tag);
q = q.Where(z => z.Tags.Contains(t));
}
q = oldestFirst ? q.OrderBy(z => z.CaseId) : q.OrderByDescending(z => z.CaseId);
return (await q.Take(Cap(limit)).Select(z => new { z.CaseId, z.Name, z.Status, z.Product, z.Release, z.Tags, z.Created, z.Closed }).ToListAsync())
.Select(z => new { caseNumber = z.CaseId, z.Name, status = z.Status.ToString(), product = ProductName(z.Product), z.Release, z.Tags, z.Created, z.Closed });
}
[McpServerTool(Name = "get_customer", ReadOnly = true), Description("A customer by id, or by name (the closest matches when the name is not exact): contact details, notes, licences, subscription servers and open cases.")]
public async Task<object> GetCustomerAsync(
[Description("Customer id")] long? id = null,
[Description("Customer name or part of it")] string name = null)
{
Require(SockType.Customer, "get_customer");
Customer c = null;
if (id != null)
c = await ct.Customer.AsNoTracking().FirstOrDefaultAsync(z => z.Id == id);
else if (!string.IsNullOrWhiteSpace(name))
{
var matches = await ct.Customer.AsNoTracking().Where(z => EF.Functions.ILike(z.Name, "%" + name.Trim() + "%")).OrderBy(z => z.Name).Take(MAX).ToListAsync();
if (matches.Count > 1 && !matches.Any(z => z.Name.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase)))
return new { matches = matches.Select(z => new { z.Id, z.Name, z.Active }) };
c = matches.FirstOrDefault(z => z.Name.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase)) ?? matches.FirstOrDefault();
}
if (c == null) return new { error = "No such customer" };
var licences = Authorized.HasReadFullRole(Roles, SockType.License)
? await ct.License.AsNoTracking().Where(z => z.CustomerId == c.Id).OrderByDescending(z => z.Created).Take(MAX)
.Select(z => new { z.Id, z.RegTo, product = z.PGroup.ToString(), z.DbId, z.Created, z.Active, z.FetchedOn, z.MaintenanceExpire, z.LicenseExpire, z.Users, z.TrialMode }).ToListAsync()
: null;
var servers = Authorized.HasReadFullRole(Roles, SockType.SubscriptionServer)
? await ct.SubscriptionServer.AsNoTracking().Where(z => z.CustomerId == c.Id).OrderBy(z => z.Name).Take(MAX)
.Select(z => new { z.Id, z.Name, state = z.ServerState.ToString(), z.Trial, z.SubscriptionExpire }).ToListAsync()
: null;
var cases = Authorized.HasReadFullRole(Roles, SockType.GZCase)
? await ct.GZCase.AsNoTracking().Where(z => z.CustomerId == c.Id && z.Closed == null).OrderByDescending(z => z.CaseId).Take(MAX)
.Select(z => new { caseNumber = z.CaseId, z.Name }).ToListAsync()
: null;
return new
{
c.Id, c.Name, c.Active, c.EmailAddress, c.Phone1, c.WebAddress, c.City, c.Region, c.Country, c.Notes, c.TechNotes, c.Tags,
licences, subscriptionServers = servers, openCases = cases
};
}
[McpServerTool(Name = "find_licence", ReadOnly = true), Description("Licences by registered name (or part of it) or by database id, newest first. The licence key text is never returned.")]
public async Task<object> FindLicenceAsync(
[Description("Registered to name, or part of it")] string registeredTo = null,
[Description("Database id the licence is for")] string dbId = null,
[Description("Most results to return, 1 to 100 (default 50)")] int? limit = null)
{
Require(SockType.License, "find_licence");
if (string.IsNullOrWhiteSpace(registeredTo) && string.IsNullOrWhiteSpace(dbId))
return new { error = "Give registeredTo or dbId" };
var q = ct.License.AsNoTracking();
if (!string.IsNullOrWhiteSpace(registeredTo)) q = q.Where(z => EF.Functions.ILike(z.RegTo, "%" + registeredTo.Trim() + "%"));
if (!string.IsNullOrWhiteSpace(dbId)) q = q.Where(z => z.DbId == dbId.Trim());
return await q.OrderByDescending(z => z.Created).Take(Cap(limit))
.Select(z => new { z.Id, z.RegTo, product = z.PGroup.ToString(), z.DbId, z.CustomerId, z.Created, z.Active, z.FetchedOn, z.MaintenanceExpire, z.LicenseExpire, z.Users, z.TrialMode, z.FetchEmail }).ToListAsync();
}
[McpServerTool(Name = "list_subscription_servers", ReadOnly = true), Description("Hosted subscription servers with their state, latest health check and expiry; optionally only trial servers or one state (e.g. ActiveHosted, Requested, DeActivated, Destroyed).")]
public async Task<object> ListSubscriptionServersAsync(
[Description("Only servers in this state")] string state = null,
[Description("Only trial servers")] bool? trialOnly = null,
[Description("Most results to return, 1 to 100 (default 100)")] int? limit = null)
{
Require(SockType.SubscriptionServer, "list_subscription_servers");
var q = ct.SubscriptionServer.AsNoTracking();
if (!string.IsNullOrWhiteSpace(state))
{
if (!Enum.TryParse<ServerState>(state, true, out var s))
return new { error = $"Unknown state; one of {string.Join(", ", Enum.GetNames<ServerState>())}" };
q = q.Where(z => z.ServerState == s);
}
if (trialOnly == true) q = q.Where(z => z.Trial);
var servers = await q.OrderBy(z => z.Name).Take(Math.Clamp(limit ?? MAX, 1, MAX)).ToListAsync();
var ids = servers.Select(z => z.Id).ToList();
var health = await ct.SubscriptionServerHealth.AsNoTracking().Where(z => ids.Contains(z.SubscriptionServerId)).ToDictionaryAsync(z => z.SubscriptionServerId);
return servers.Select(z => new
{
z.Id, z.Name, state = z.ServerState.ToString(), z.Trial, z.SubscriptionExpire, z.CustomerSubDomain, z.CustomerId,
lastHealthStatus = health.TryGetValue(z.Id, out var h) ? h.LastHealthStatus : null,
lastHealthCheck = health.TryGetValue(z.Id, out var h2) ? h2.LastHealthCheck : null
});
}
[McpServerTool(Name = "list_trial_requests", ReadOnly = true), Description("Trial licence requests, newest first; by default only those still waiting for a decision (awaiting email validation or approval).")]
public async Task<object> ListTrialRequestsAsync(
[Description("Include all, not only those waiting")] bool all = false,
[Description("Most results to return, 1 to 100 (default 50)")] int? limit = null)
{
Require(SockType.TrialLicenseRequest, "list_trial_requests");
var q = ct.TrialLicenseRequest.AsNoTracking();
if (!all) q = q.Where(z => z.Status == TrialRequestStatus.AwaitingEmailValidation || z.Status == TrialRequestStatus.AwaitingApproval);
return await q.OrderByDescending(z => z.Requested).Take(Cap(limit))
.Select(z => new { z.Id, z.CompanyName, z.ContactName, z.Email, z.EmailValidated, status = z.Status.ToString(), product = z.PGroup.ToString(), z.Requested, z.Processed, z.LicenseId }).ToListAsync();
}
[McpServerTool(Name = "get_event_log", ReadOnly = true), Description("Recent history of one record: who changed what and when. Type is a Sockeye record type such as GZCase, Customer, License, SubscriptionServer, TrialLicenseRequest; id is the record id (for a case, its id, not its case number).")]
public async Task<object> GetEventLogAsync(
[Description("Record type")] string type,
[Description("Record id")] long id,
[Description("Most events to return, 1 to 100 (default 50)")] int? limit = null)
{
if (!Enum.TryParse<SockType>(type, true, out var sockType))
return new { error = $"Unknown record type {type}" };
Require(sockType, "get_event_log");
var log = await EventLogProcessor.GetLogForObjectAsync(
new Api.Controllers.EventLogController.EventLogOptions { SockType = sockType, AyId = id, Limit = Cap(limit) },
UserTranslationIdFromContext.Id(http.Items), ct);
return new { log.Name, events = log.Events.Select(z => new { z.Date, user = z.Name, @event = z.Event.ToString(), z.Textra }) };
}
////////////////////////////////////////////////////////////////////////////////////////////////
//case 4688 phase 2: write tools for cases. Each goes through GZCaseBiz, as the case form does (validation, Closed
//following Status, search index, tag repository, event log as the agent User). A tool loads the case, changes only
//what it was given and saves the whole record, so nothing it wasn't given is reset
//
//a write needs a token able to change records and a User whose roles allow the change
private void RequireWrite(string tool, long? caseNumber)
{
log.LogInformation($"MCP {tool} by agent \"{http.Items["AY_USERNAME"]}\"{(caseNumber == null ? "" : $": case {caseNumber}")}");
if (http.Items[AgentAccess.ITEM_READ_ONLY] is bool readOnly && readOnly)
throw new McpException("This agent token is read only: changing records needs a token made with \"Can change records\"");
if (!Authorized.HasModifyRole(Roles, SockType.GZCase))
throw new McpException("This agent's roles don't allow changing cases");
}
private GZCaseBiz CaseBiz() => GZCaseBiz.GetBiz(ct, http);
private async Task<GZCase> LoadCaseAsync(long caseNumber)
{
var id = await ct.GZCase.AsNoTracking().Where(z => z.CaseId == caseNumber).Select(z => (long?)z.Id).FirstOrDefaultAsync();
if (id == null) throw new McpException($"No case {caseNumber}");
return await CaseBiz().GetAsync(id.Value, false);
}
private async Task<object> SaveCaseAsync(GZCase c)
{
var biz = CaseBiz();
if (await biz.PutAsync(c) == null)
throw new McpException(biz.Errors.Exists(z => z.Code == ApiErrorCode.CONCURRENCY_CONFLICT)
? $"Case {c.CaseId} changed since it was read: get_case again and redo the change"
: $"Case {c.CaseId} not saved: {biz.GetErrorsAsString()}");
return await GetCaseAsync(c.CaseId);
}
private static GZCaseStatus ParseStatus(string name) =>
TryStatus(name, out var s) ? s : throw new McpException($"Unknown status \"{name}\"; one of: {STATUS_NAMES}");
private static GZCaseProduct ParseProduct(string name) =>
TryProduct(name, out var p) ? p : throw new McpException($"Unknown product \"{name}\"; one of: {PRODUCT_NAMES}");
[McpServerTool(Name = "create_case", ReadOnly = false, Destructive = false, Idempotent = false), Description("Create a case. Name and product (" + PRODUCT_NAMES + ") are required; status (" + STATUS_NAMES + ") defaults to New. Built needs a release. Returns the new case as get_case does.")]
public async Task<object> CreateCaseAsync(
[Description("Short title of the case")] string name,
[Description("One of: " + PRODUCT_NAMES)] string product,
[Description("The case notes (details)")] string notes = null,
[Description("One of: " + STATUS_NAMES + " (default New)")] string status = null,
[Description("Release, e.g. 8.3.2")] string release = null,
[Description("Tags")] string[] tags = null,
[Description("Customer id (see get_customer)")] long? customerId = null)
{
RequireWrite("create_case", null);
var c = new GZCase
{
Name = name,
Product = ParseProduct(product),
Status = status == null ? GZCaseStatus.New : ParseStatus(status),
Notes = notes,
Release = release,
Tags = tags?.ToList() ?? new List<string>(),
CustomerId = customerId
};
var biz = CaseBiz();
var saved = await biz.CreateAsync(c);
if (saved == null) throw new McpException($"Case not created: {biz.GetErrorsAsString()}");
return await GetCaseAsync(saved.CaseId);
}
[McpServerTool(Name = "update_case", ReadOnly = false, Destructive = true, Idempotent = true), Description("Change a case's name, status (" + STATUS_NAMES + "), product, release or customer. Only what is given changes. A closing status (Done, Never, Duplicate) closes the case; an open one reopens it. Release \"\" clears it; customerId 0 clears it. Returns the case as get_case does.")]
public async Task<object> UpdateCaseAsync(
[Description("The case number")] long caseNumber,
[Description("New name")] string name = null,
[Description("One of: " + STATUS_NAMES)] string status = null,
[Description("One of: " + PRODUCT_NAMES)] string product = null,
[Description("Release, e.g. 8.3.2; \"\" clears it")] string release = null,
[Description("Customer id; 0 clears it")] long? customerId = null)
{
RequireWrite("update_case", caseNumber);
var c = await LoadCaseAsync(caseNumber);
if (name != null) c.Name = name;
if (status != null) c.Status = ParseStatus(status);
if (product != null) c.Product = ParseProduct(product);
if (release != null) c.Release = release;
if (customerId != null) c.CustomerId = customerId == 0 ? null : customerId;
return await SaveCaseAsync(c);
}
[McpServerTool(Name = "add_case_note", ReadOnly = false, Destructive = false, Idempotent = false), Description("Add a note at the top of a case's notes, stamped with the date and time and this agent's name, above a -=-=-=-=-=-=- separator. Returns the case as get_case does.")]
public async Task<object> AddCaseNoteAsync(
[Description("The case number")] long caseNumber,
[Description("The note")] string text)
{
RequireWrite("add_case_note", caseNumber);
if (string.IsNullOrWhiteSpace(text)) throw new McpException("The note is empty");
var c = await LoadCaseAsync(caseNumber);
c.Notes = NoteStamp() + text.Trim() + (string.IsNullOrWhiteSpace(c.Notes) ? "" : "\n" + NOTE_SEPARATOR + "\n" + c.Notes);
return await SaveCaseAsync(c);
}
//John's way (2026-11-02): newest at the top, "<date time> - <name>: " then the separator above the notes as they were
internal const string NOTE_SEPARATOR = "-=-=-=-=-=-=-";
private string NoteStamp()
{
var tzId = ct.UserOptions.AsNoTracking().Where(z => z.UserId == UserIdFromContext.Id(http.Items)).Select(z => z.TimeZoneOverride).FirstOrDefault();
var when = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(tzId))
{
try { when = TimeZoneInfo.ConvertTimeFromUtc(when, TimeZoneInfo.FindSystemTimeZoneById(tzId)); }
catch (Exception) { }//an unknown time zone name: UTC
}
return $"{when:yyyy-MM-dd HH:mm} - {http.Items["AY_USERNAME"]}: ";
}
[McpServerTool(Name = "replace_case_notes", ReadOnly = false, Destructive = true, Idempotent = true), Description("Replace a case's whole notes, e.g. to fix typos. Needs the concurrency value from get_case: refused if the case changed since, so nobody's edit is lost. Returns the case as get_case does.")]
public async Task<object> ReplaceCaseNotesAsync(
[Description("The case number")] long caseNumber,
[Description("The whole new notes")] string notes,
[Description("The concurrency value get_case returned")] uint concurrency)
{
RequireWrite("replace_case_notes", caseNumber);
var c = await LoadCaseAsync(caseNumber);
c.Notes = notes;
c.Concurrency = concurrency;//the save refuses it if the case has changed since
return await SaveCaseAsync(c);
}
[McpServerTool(Name = "set_case_tags", ReadOnly = false, Destructive = false, Idempotent = true), Description("Add and/or remove tags on a case. Returns the case as get_case does.")]
public async Task<object> SetCaseTagsAsync(
[Description("The case number")] long caseNumber,
[Description("Tags to add")] string[] add = null,
[Description("Tags to remove")] string[] remove = null)
{
RequireWrite("set_case_tags", caseNumber);
var c = await LoadCaseAsync(caseNumber);
var tags = new List<string>(c.Tags ?? new List<string>());
foreach (var t in TagBiz.NormalizeTags((remove ?? Array.Empty<string>()).ToList()))
tags.Remove(t);
tags.AddRange(TagBiz.NormalizeTags((add ?? Array.Empty<string>()).ToList()).Where(t => !tags.Contains(t)));
c.Tags = tags;
return await SaveCaseAsync(c);
}
}
}

View File

@@ -452,7 +452,6 @@ namespace Sockeye.Biz
//do it regardless any time there's an update and then
//let this code below handle the refreshing addition that could have changes
await NotifyEventHelper.ClearPriorEventsForObject(ct, BizType, ProposedServer.Id, NotifyEventType.SubscriptionServerExpiring);
await NotifyEventHelper.ClearPriorEventsForObject(ct, BizType, ProposedServer.Id, NotifyEventType.SubscriptionServerLastUpdateAge);
//## CREATED / MODIFIED EVENTS
@@ -531,40 +530,6 @@ namespace Sockeye.Biz
}//subscription server expiry event
//# SUBSCRIPTION SERVER LAST UPDATE (O.S.) AGE
{
if (ProposedServer.LastUpdated != null)
{
//notify users about warranty expiry (time delayed)
var subs = await ct.NotifySubscription.Where(z => z.EventType == NotifyEventType.SubscriptionServerLastUpdateAge).ToListAsync();
foreach (var sub in subs)
{
//not for inactive users
if (!await UserBiz.UserIsActive(sub.UserId)) continue;
//Tag match? (will be true if no sub tags so always safe to call this)
if (NotifyEventHelper.ObjectHasAllSubscriptionTags(ProposedServer.Tags, sub.Tags))
{
NotifyEvent n = new NotifyEvent()
{
EventType = NotifyEventType.SubscriptionServerLastUpdateAge,
UserId = sub.UserId,
SockType = BizType,
ObjectId = ProposedServer.Id,
NotifySubscriptionId = sub.Id,
Name = ProposedServer.Name,
EventDate = (DateTime)ProposedServer.LastUpdated
};
await ct.NotifyEvent.AddAsync(n);
log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]");
await ct.SaveChangesAsync();
}
}
}
}//subscription server last updated event
//##################################
//ServerState changes events
//##################################

View File

@@ -72,7 +72,9 @@ namespace Sockeye.Biz
public static async Task ProcessDeleteTagsInRepositoryAsync(AyContext ct, List<string> deleteTags)
{
if (deleteTags.Count == 0) return;
//case 4487: a record saved without a tag list (e.g. a trial request from the rvr route) has null tags, which made
//deleting it fail with a server error
if (deleteTags == null || deleteTags.Count == 0) return;
var existing = await ct.Tag.Where(x => deleteTags.Contains(x.Name)).ToListAsync();
foreach (string s in deleteTags)

View File

@@ -132,6 +132,10 @@ namespace Sockeye.Biz
await ValidateAsync(putObject, dbObject);
if (HasErrors) return null;
//case 4487: approving or rejecting is our decision, so it works while the request still waits for the customer
//to confirm their email too (e.g. when the email was mistyped and sorted out with them directly); it only
//worked from AwaitingApproval, so an approval before validation saved the status but made no licence
bool undecided = dbObject.Status == TrialRequestStatus.AwaitingApproval || dbObject.Status == TrialRequestStatus.AwaitingEmailValidation;
//IF WE HAVE APPROVED A REQUEST THEN GENERATE TRIAL LICENSE, NOTIFY USER
//Notice to future coders who might see this or anything else in Sockeye that seems a bit ugly and spaghetti coded:
@@ -139,7 +143,7 @@ namespace Sockeye.Biz
//I'm fully aware this code is bodged together and just don't care right now, if it works it works.
//I need this up and running so I can spend more time with my wife and less time managing the detailed fuckery of the business
//that can be automated away while still keeping things going for now
if (dbObject.Status == TrialRequestStatus.AwaitingApproval && putObject.Status == TrialRequestStatus.Approved)
if (undecided && putObject.Status == TrialRequestStatus.Approved)
{
//APPROVED, generate and save key to be approved for release by us
putObject.Processed = DateTime.UtcNow;
@@ -205,7 +209,7 @@ namespace Sockeye.Biz
}
}
if (dbObject.Status == TrialRequestStatus.AwaitingApproval && putObject.Status == TrialRequestStatus.Rejected)
if (undecided && putObject.Status == TrialRequestStatus.Rejected)
{
//REJECTED, send email
putObject.Processed = DateTime.UtcNow;

View File

@@ -45,9 +45,14 @@ namespace Sockeye.Biz
{
User dbObject = ct.User.FirstOrDefault(z => z.Id == 1);
dbObject.Password = Hasher.hash(dbObject.Salt, ServerBootConfig.SOCKEYE_SET_SUPERUSER_PW);
//case 4678 (as AyaNova case 4775): the break-glass reset restores plain password sign-in completely, so a
//superuser who lost their authenticator app gets in too; their passkeys stay and can be removed after
dbObject.TwoFactorEnabled = false;
dbObject.TotpSecret = null;
dbObject.TempToken = null;
ct.SaveChanges();
NotifyEventHelper.AddOpsProblemEvent("SOCKEYE_SET_SUPERUSER_PW setting was used at most recent server boot to reset SuperUser account password").Wait();
NotifyEventHelper.AddOpsProblemEvent("SOCKEYE_SET_SUPERUSER_PW setting was used at most recent server boot to reset SuperUser account password and turn off its two-factor sign-in").Wait();
}
}
@@ -605,6 +610,10 @@ namespace Sockeye.Biz
if (proposedObj.AllowLogin != currentObj.AllowLogin)
AddError(ApiErrorCode.NOT_AUTHORIZED, "AllowLogin");
//case 4688
if (proposedObj.Agent)
AddError(ApiErrorCode.NOT_AUTHORIZED, "Agent");
if (proposedObj.Name != currentObj.Name)
AddError(ApiErrorCode.NOT_AUTHORIZED, "Name");

View File

@@ -300,10 +300,6 @@ namespace Sockeye.Biz
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("User"));
if (await ct.Product.AnyAsync(m => m.VendorId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Product"));
if (await ct.Purchase.AnyAsync(m => m.VendorId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Purchase"));
if (await ct.VendorNotification.AnyAsync(m => m.VendorId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("VendorNotification"));
}

View File

@@ -1,417 +0,0 @@
using System;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Linq;
using Sockeye.Util;
using Sockeye.Api.ControllerHelpers;
using Sockeye.Models;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Sockeye.Biz
{
internal class VendorNotificationBiz : BizObject, IJobObject, ISearchAbleObject, IReportAbleObject, IExportAbleObject, INotifiableObject
{
internal VendorNotificationBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles)
{
ct = dbcontext;
UserId = currentUserId;
UserTranslationId = userTranslationId;
CurrentUserRoles = UserRoles;
BizType = SockType.VendorNotification;
}
internal static VendorNotificationBiz GetBiz(AyContext ct, Microsoft.AspNetCore.Http.HttpContext httpContext = null)
{
if (httpContext != null)
return new VendorNotificationBiz(ct, UserIdFromContext.Id(httpContext.Items), UserTranslationIdFromContext.Id(httpContext.Items), UserRolesFromContext.Roles(httpContext.Items));
else
return new VendorNotificationBiz(ct, 1, ServerBootConfig.SOCKEYE_DEFAULT_TRANSLATION_ID, AuthorizationRoles.BizAdmin);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//EXISTS
internal async Task<bool> ExistsAsync(long id)
{
return await ct.VendorNotification.AnyAsync(z => z.Id == id);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//CREATE
//
internal async Task<VendorNotification> CreateAsync(VendorNotification newObject)
{
await ValidateAsync(newObject, null);
if (HasErrors)
return null;
else
{
await ct.VendorNotification.AddAsync(newObject);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, SockEvent.Created), ct);
await SearchIndexAsync(newObject, true);
await HandlePotentialNotificationEvent(SockEvent.Created, newObject);
return newObject;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
//GET
//
internal async Task<VendorNotification> GetAsync(long id, bool logTheGetEvent = true)
{
var ret = await ct.VendorNotification.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id);
if (logTheGetEvent && ret != null)
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, SockEvent.Retrieved), ct);
return ret;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//UPDATE
//
internal async Task<VendorNotification> PutAsync(VendorNotification putObject)
{
var dbObject = await GetAsync(putObject.Id, false);
if (dbObject == null)
{
AddError(ApiErrorCode.NOT_FOUND, "id");
return null;
}
if (dbObject.Concurrency != putObject.Concurrency)
{
AddError(ApiErrorCode.CONCURRENCY_CONFLICT);
return null;
}
await ValidateAsync(putObject, dbObject);
if (HasErrors) return null;
ct.Replace(dbObject, putObject);
try
{
await ct.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await ExistsAsync(putObject.Id))
AddError(ApiErrorCode.NOT_FOUND);
else
AddError(ApiErrorCode.CONCURRENCY_CONFLICT);
return null;
}
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, BizType, SockEvent.Modified), ct);
await SearchIndexAsync(putObject, false);
await HandlePotentialNotificationEvent(SockEvent.Modified, putObject, dbObject);
return putObject;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//DELETE
//
internal async Task<bool> DeleteAsync(long id)
{
using (var transaction = await ct.Database.BeginTransactionAsync())
{
VendorNotification dbObject = await GetAsync(id, false);
if (dbObject == null)
{
AddError(ApiErrorCode.NOT_FOUND);
return false;
}
await ValidateCanDeleteAsync(dbObject);
if (HasErrors)
return false;
{
var IDList = await ct.Review.AsNoTracking().Where(x => x.SockType == SockType.VendorNotification && x.ObjectId == id).Select(x => x.Id).ToListAsync();
if (IDList.Count() > 0)
{
ReviewBiz b = new ReviewBiz(ct, UserId, UserTranslationId, CurrentUserRoles);
foreach (long ItemId in IDList)
if (!await b.DeleteAsync(ItemId, transaction))
{
AddError(ApiErrorCode.CHILD_OBJECT_ERROR, null, $"Review [{ItemId}]: {b.GetErrorsAsString()}");
return false;
}
}
}
ct.VendorNotification.Remove(dbObject);
await ct.SaveChangesAsync();
//Log event
await EventLogProcessor.DeleteObjectLogAsync(UserId, BizType, dbObject.Id, dbObject.Name, ct);
await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, BizType, ct);
await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct);
await transaction.CommitAsync();
await HandlePotentialNotificationEvent(SockEvent.Deleted, dbObject);
return true;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
//SEARCH
//
private async Task SearchIndexAsync(VendorNotification obj, bool isNew)
{
var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, BizType);
DigestSearchText(obj, SearchParams);
if (isNew)
await Search.ProcessNewObjectKeywordsAsync(SearchParams);
else
await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams);
}
public async Task<Search.SearchIndexProcessObjectParameters> GetSearchResultSummary(long id, SockType specificType)
{
var obj = await GetAsync(id, false);
var SearchParams = new Search.SearchIndexProcessObjectParameters();
DigestSearchText(obj, SearchParams);
return SearchParams;
}
public void DigestSearchText(VendorNotification obj, Search.SearchIndexProcessObjectParameters searchParams)
{
if (obj != null)
searchParams.AddText(obj.VendorData);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//VALIDATION
//
private async Task ValidateAsync(VendorNotification proposedObj, VendorNotification currentObj)
{
await Task.CompletedTask;
}
private async Task ValidateCanDeleteAsync(VendorNotification inObj)
{
//FOREIGN KEY CHECKS
if (await ct.Purchase.AnyAsync(m => m.VendorNotificationId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Purchase"));
}
////////////////////////////////////////////////////////////////////////////////////////////////
//REPORTING
//
public async Task<JArray> GetReportData(DataListSelectedRequest dataListSelectedRequest, Guid jobId)
{
var idList = dataListSelectedRequest.SelectedRowIds;
JArray ReportData = new JArray();
while (idList.Any())
{
var batch = idList.Take(IReportAbleObject.REPORT_DATA_BATCH_SIZE);
idList = idList.Skip(IReportAbleObject.REPORT_DATA_BATCH_SIZE).ToArray();
//query for this batch, comes back in db natural order unfortunately
var batchResults = await ct.VendorNotification.AsNoTracking().Where(z => batch.Contains(z.Id)).ToArrayAsync();
//order the results back into original
//What is happening here:
//for performance the query is batching a bunch at once by fetching a block of items from the sql server
//however it's returning in db order which is often not the order the id list is in
//so it needs to be sorted back into the same order as the ide list
//This would not be necessary if just fetching each one at a time individually (like in workorder get report data)
var orderedList = from id in batch join z in batchResults on id equals z.Id select z;
batchResults = null;
foreach (VendorNotification w in orderedList)
{
if (!ReportRenderManager.KeepGoing(jobId)) return null;
await PopulateVizFields(w);
var jo = JObject.FromObject(w);
ReportData.Add(jo);
}
orderedList = null;
}
vc.Clear();
return ReportData;
}
private VizCache vc = new VizCache();
//populate viz fields from provided object
private async Task PopulateVizFields(VendorNotification o)
{
if (!vc.Has("vendor", o.VendorId))
{
vc.Add(await ct.Vendor.AsNoTracking().Where(x => x.Id == o.VendorId).Select(x => x.Name).FirstOrDefaultAsync(), "vendor", o.VendorId);
}
o.VendorViz = vc.Get("vendor", o.VendorId);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// IMPORT EXPORT
//
public async Task<JArray> GetExportData(DataListSelectedRequest dataListSelectedRequest, Guid jobId)
{
return await GetReportData(dataListSelectedRequest, jobId);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//JOB / OPERATIONS
//
public async Task HandleJobAsync(OpsJob job)
{
//Hand off the particular job to the corresponding processing code
//NOTE: If this code throws an exception the caller (JobsBiz::ProcessJobsAsync) will automatically set the job to failed and log the exeption so
//basically any error condition during job processing should throw up an exception if it can't be handled
switch (job.JobType)
{
case JobType.BatchCoreObjectOperation:
await ProcessBatchJobAsync(job);
break;
default:
throw new System.ArgumentOutOfRangeException($"VendorNotificationBiz.HandleJob-> Invalid job type{job.JobType.ToString()}");
}
}
private async Task ProcessBatchJobAsync(OpsJob job)
{
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Running);
await JobsBiz.LogJobAsync(job.GId, $"LT:StartJob {job.SubType}");
List<long> idList = new List<long>();
long FailedObjectCount = 0;
JObject jobData = JObject.Parse(job.JobInfo);
if (jobData.ContainsKey("idList"))
idList = ((JArray)jobData["idList"]).ToObject<List<long>>();
else
idList = await ct.VendorNotification.AsNoTracking().Select(z => z.Id).ToListAsync();
bool SaveIt = false;
//---------------------------------
//case 4192
TimeSpan ProgressAndCancelCheckSpan = new TimeSpan(0, 0, ServerBootConfig.JOB_PROGRESS_UPDATE_AND_CANCEL_CHECK_SECONDS);
DateTime LastProgressCheck = DateTime.UtcNow.Subtract(new TimeSpan(1, 1, 1, 1, 1));
var TotalRecords = idList.LongCount();
long CurrentRecord = -1;
//---------------------------------
foreach (long id in idList)
{
try
{
//--------------------------------
//case 4192
//Update progress / cancel requested?
CurrentRecord++;
if (DateUtil.IsAfterDuration(LastProgressCheck, ProgressAndCancelCheckSpan))
{
await JobsBiz.UpdateJobProgressAsync(job.GId, $"{CurrentRecord}/{TotalRecords}");
if (await JobsBiz.GetJobStatusAsync(job.GId) == JobStatus.CancelRequested)
break;
LastProgressCheck = DateTime.UtcNow;
}
//---------------------------------
SaveIt = false;
ClearErrors();
VendorNotification o = null;
//save a fetch if it's a delete
if (job.SubType != JobSubType.Delete)
o = await GetAsync(id, false);
switch (job.SubType)
{
// case JobSubType.TagAddAny:
// case JobSubType.TagAdd:
// case JobSubType.TagRemoveAny:
// case JobSubType.TagRemove:
// case JobSubType.TagReplaceAny:
// case JobSubType.TagReplace:
// SaveIt = TagBiz.ProcessBatchTagOperation(o.Tags, (string)jobData["tag"], jobData.ContainsKey("toTag") ? (string)jobData["toTag"] : null, job.SubType);
// break;
case JobSubType.Delete:
if (!await DeleteAsync(id))
{
await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}");
FailedObjectCount++;
}
break;
default:
throw new System.ArgumentOutOfRangeException($"ProcessBatchJobAsync -> Invalid job Subtype{job.SubType}");
}
if (SaveIt)
{
o = await PutAsync(o);
if (o == null)
{
await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}");
FailedObjectCount++;
}
}
//delay so we're not tying up all the resources in a tight loop
await Task.Delay(Sockeye.Util.ServerBootConfig.JOB_OBJECT_HANDLE_BATCH_JOB_LOOP_DELAY);
}
catch (Exception ex)
{
await JobsBiz.LogJobAsync(job.GId, $"LT:Errors id({id})");
await JobsBiz.LogJobAsync(job.GId, ExceptionUtil.ExtractAllExceptionMessages(ex));
}
}
//---------------------------------
//case 4192
await JobsBiz.UpdateJobProgressAsync(job.GId, $"{++CurrentRecord}/{TotalRecords}");
//---------------------------------
await JobsBiz.LogJobAsync(job.GId, $"LT:BatchJob {job.SubType} {idList.Count}{(FailedObjectCount > 0 ? " - LT:Failed " + FailedObjectCount : "")}");
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Completed);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// NOTIFICATION PROCESSING
//
public async Task HandlePotentialNotificationEvent(SockEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null)
{
if (ServerBootConfig.MIGRATING) return;
ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger<VendorNotificationBiz>();
log.LogDebug($"HandlePotentialNotificationEvent processing: [SockType:{this.BizType}, AyaEvent:{ayaEvent}]");
bool isNew = currentObj == null;
proposedObj.Name = $"VENDOR NOTIFICATION created:{((VendorNotification)proposedObj).Created} id:{proposedObj.Id.ToString()}";
//STANDARD EVENTS FOR ALL OBJECTS
await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct);
}//end of process notifications
/////////////////////////////////////////////////////////////////////
}//eoc
}//eons

View File

@@ -276,6 +276,12 @@ namespace Sockeye.Biz
//Special notification handling
switch (ne.EventType)
{
//case 4738: the advisory id and title say it all; the default subject would read "AY:-:<title>:<type>"
case NotifyEventType.SecurityAdvisoryAlert:
case NotifyEventType.SecurityAdvisoryDigest:
subject = $"{SubscriptionTypeName}: {name}";
body = ne.Message;
break;
// case NotifyEventType.CustomerServiceImminent:
// subject = SubscriptionTypeName;
// body = LT["NotifyEventCustomerServiceImminentMessage"].Replace("{0}", Sockeye.Util.DateUtil.FormatTimeSpan(advanceNotice, LT["TimeSpanDays"], LT["TimeSpanHours"], LT["TimeSpanMinutes"], LT["TimeSpanSeconds"]));

View File

@@ -1,423 +0,0 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using Sockeye.Models;
using Sockeye.Util;
namespace Sockeye.Biz
{
/// <summary>
/// Process purchases into licenses
/// ############## 2023-02-09 THIS IS ON HOLD FOR NOW - unlikely to work immediately due to all manual fuckery involved instead will find ways to speed up manual processing ################
/// too much other stuff on the go right now and time required to spend processing purchases is a good problem to have can spend more time on it later
/// I'll comment out the job so it's not called and keep the vendor notification processing for now, look at client end improvements (duplicate alone will help)
/// </summary>
internal static class SockBotProcessPurchasesIntoLicenses
{
private static ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger("SockBotProcessPurchasesIntoLicenses");
private static DateTime lastSweep = DateTime.MinValue;
private static TimeSpan PROCESS_V7_AGE = new TimeSpan(0, 5, 0);//Don't process a v7 order until at least 5 minutes old as it doesn't always come in at once
#if (DEBUG)
private static TimeSpan PROCESS_EVERY_INTERVAL = new TimeSpan(0, 0, 30);//every 30 seconds during development
#else
private static TimeSpan PROCESS_EVERY_INTERVAL = new TimeSpan(0, 5, 10);//every 5 minutes
#endif
////////////////////////////////////////////////////////////////////////////////////////////////
// DoSweep
//
public static async Task DoWorkAsync()
{
//This will get triggered roughly every minute, but we don't want to check that frequently
if (DateTime.UtcNow - lastSweep < PROCESS_EVERY_INTERVAL)
return;
if (ServerBootConfig.MIGRATING) return;//don't do this during migration (migration is one time only so can remove after up and running)
log.LogDebug("Process purchases into licenses starting");
await ProcessPurchasesIntoLicenses();
lastSweep = DateTime.UtcNow;
}
private static async Task ProcessPurchasesIntoLicenses()
{
using (AyContext ct = Sockeye.Util.ServiceProviderProvider.DBContext)
{
//get a list of all actionable purchases grouped by customer id
var purchaseList = (await ct.Purchase.AsNoTracking()
.Where(z => z.Processed == false
&& z.LicenseId == null
&& z.CustomerId != null
&& (z.PGroup == ProductGroup.AyaNova7 || z.PGroup == ProductGroup.RavenPerpetual || z.PGroup == ProductGroup.RavenSubscription))
.OrderByDescending(z => z.PurchaseDate)
.ToListAsync()).GroupBy(z => (long)z.CustomerId);
try
{
foreach (var purchaseGroup in purchaseList)
{
//Iterate the group and qualify it
bool isRavenPerpetual = false;
bool isRavenSubscription = false;
bool isV7 = false;
bool isLessThanV7Age = false;
var purchaseGroupCustomer = await ct.Customer.AsNoTracking().FirstAsync(z => z.Id == purchaseGroup.First().CustomerId);
foreach (var purchase in purchaseGroup)
{
if (purchase.PGroup == ProductGroup.AyaNova7)
isV7 = true;
if (purchase.PGroup == ProductGroup.RavenPerpetual)
isRavenPerpetual = true;
if (purchase.PGroup == ProductGroup.RavenSubscription)
isRavenSubscription = true;
if (DateTime.UtcNow - purchase.PurchaseDate < PROCESS_V7_AGE)
isLessThanV7Age = true; ;
}
//sanity checks should never be mixed
if ((isRavenPerpetual && isV7))
{
var err = $"SockBotProcessPurchasesIntoLicenses both raven perpetual and v7 in same customer's purchase group First record order number: {purchaseGroup.First().SalesOrderNumber}";
//serious issue requires immediate notification
await NotifyEventHelper.AddOpsProblemEvent(err);
log.LogError(err);
continue;
}
if ((isRavenSubscription && isV7))
{
var err = $"SockBotProcessPurchasesIntoLicenses both raven subscription and v7 in same customer's purchase group First record order number: {purchaseGroup.First().SalesOrderNumber}";
//serious issue requires immediate notification
await NotifyEventHelper.AddOpsProblemEvent(err);
log.LogError(err);
continue;
}
if ((isRavenPerpetual && isRavenSubscription))
{
var err = $"SockBotProcessPurchasesIntoLicenses both raven perpetual and raven subscription in same customer's purchase group First record order number: {purchaseGroup.First().SalesOrderNumber}";
//serious issue requires immediate notification
await NotifyEventHelper.AddOpsProblemEvent(err);
log.LogError(err);
continue;
}
//if v7 skip for this iteration to ensure multiple add-on's have all arrived from mycommerce
if (isV7 && isLessThanV7Age)
continue;
License newLicense = new License();
//Get last license if any, set up some basic stuff common to all license types
var firstPurchase = purchaseGroup.First();
License lastLicense = null;
newLicense.CustomerId = firstPurchase.CustomerId;
newLicense.Active = false;
newLicense.RegTo = firstPurchase.RegTo;
//if v7 license then lookup last license for same pgroup for same customer, if none then consider it a new license fresh
//if there is one and it's not entirely expired then duplicate and fixup from purchases in this group
if (isV7)
{
lastLicense = await ct.License.AsNoTracking().OrderByDescending(z => z.Id).FirstOrDefaultAsync(z => z.CustomerId == firstPurchase.CustomerId && z.PGroup == firstPurchase.PGroup);
newLicense.FetchEmail = purchaseGroupCustomer.EmailAddress;
newLicense.PGroup = ProductGroup.AyaNova7;
//is there a prior license?
if (lastLicense != null)
{
//copy all the values to the new license
newLicense.ExportToXLS = lastLicense.ExportToXLS;
newLicense.ExportToXLSExpires = lastLicense.ExportToXLSExpires;
newLicense.ImportExportCSVDuplicate = lastLicense.ImportExportCSVDuplicate;
newLicense.ImportExportCSVDuplicateExpires = lastLicense.ImportExportCSVDuplicateExpires;
newLicense.MaintenanceExpire = lastLicense.MaintenanceExpire;
newLicense.MBI = lastLicense.MBI;
newLicense.MBIExpires = lastLicense.MBIExpires;
newLicense.OLI = lastLicense.OLI;
newLicense.OLIExpires = lastLicense.OLIExpires;
newLicense.OutlookSchedule = lastLicense.OutlookSchedule;
newLicense.OutlookScheduleExpires = lastLicense.OutlookScheduleExpires;
newLicense.PTI = lastLicense.PTI;
newLicense.PTIExpires = lastLicense.PTIExpires;
newLicense.QBI = lastLicense.QBI;
newLicense.QBIExpires = lastLicense.QBIExpires;
newLicense.QBOI = lastLicense.QBOI;
newLicense.QBOIExpires = lastLicense.QBOIExpires;
newLicense.QuickNotification = lastLicense.QuickNotification;
newLicense.QuickNotificationExpires = lastLicense.QuickNotificationExpires;
newLicense.Renewal = true;
newLicense.RI = lastLicense.RI;
newLicense.RIExpires = lastLicense.RIExpires;
newLicense.Tags = lastLicense.Tags;
newLicense.Users = lastLicense.Users;
newLicense.WBI = lastLicense.WBI;
newLicense.WBIExpires = lastLicense.WBIExpires;
newLicense.Wiki = lastLicense.Wiki;
}
//iterate the purchases and update / set the license
foreach (var purchase in purchaseGroup)
{
var product = await ct.Product.AsNoTracking().FirstOrDefaultAsync(z => z.Id == purchase.ProductId);//should *always* exist
if (product == null)
{
var err = $"SockBotProcessPurchasesIntoLicenses purchase: {purchase.Id} has un-matchable product id: {purchase.ProductId}";
//serious issue requires immediate notification
await NotifyEventHelper.AddOpsProblemEvent(err);
log.LogError(err);
continue;
}
/*
id name productCode price renewPrice
1 Key administration 300093112 3500 3500
2 Custom work 300151145 0 0
3 AyaNova RI 1 year subscription license 300740314 19900 6965
4 Single AyaNova schedulable resource 1 year subscription license 300740315 15900 5565
5 Single AyaNova Lite 1 year subscription license 300740316 6900 2415
6 Up to 5 AyaNova schedulable resource 1 year subscription license 300740317 69500 24325
7 Up to 10 AyaNova schedulable resource 1 year subscription license 300740318 119000 41650
8 Up to 20 AyaNova schedulable resource 1 year subscription license 300740319 198000 69300
9 AyaNova WBI (web browser interface) 1 year subscription license 300740321 9900 3465
10 AyaNova MBI (minimal browser interface) 1 year subscription license 300740322 9900 3465
11 AyaNova QBI(QuickBooks interface) 1 year subscription license 300740323 9900 3465
12 AyaNova PTI(US Peachtree/Sage 50 interface) 1 year subscription license 300740324 9900 3465
13 AyaNova OLI(Outlook interface) 1 year subscription license 300740325 9900 3465
14 Plug-in Outlook Schedule Export 1 year subscription license 300740326 1900 665
15 Plug-in Export to XLS 1 year subscription license 300740327 1900 665
16 Plug-in Quick Notification 1 year subscription license 300740328 1900 665
17 Plug-in importexport.csv duplicate 1 year subscription license 300740329 1900 665
18 Up to 999 AyaNova schedulable resource 1 year subscription license 300741264 15000 5250
19 Up to 15 AyaNova schedulable resource 1 year subscription license 300807973 165000 57750
20 AyaNova QBOI(QuickBooks Online interface) 1 year subscription license 300784766 12900 4515
21 Single AyaNova service techncian perpetual license 301028314 135 100
22 Single AyaNova service techncian 1 year maintenance plan - new 301028317 135 100
23 Single AyaNova service techncian 1 year maintenance plan - active 301028315 100 100
case 4576
25 Up to 25 AyaNova schedulable resource 1 year subscription license
*/
var dtOneYear = DateTime.UtcNow.AddYears(1);
switch (product.VendorCode)
{
case "300740314":
newLicense.RI = true;
newLicense.RIExpires = dtOneYear;
break;
case "300740321":
newLicense.WBI = true;
newLicense.WBIExpires = dtOneYear;
break;
case "300740322":
newLicense.MBI = true;
newLicense.MBIExpires = dtOneYear;
break;
case "300740323":
newLicense.QBI = true;
newLicense.QBIExpires = dtOneYear;
break;
case "300740324":
newLicense.PTI = true;
newLicense.PTIExpires = dtOneYear;
break;
case "300740325":
newLicense.OLI = true;
newLicense.OLIExpires = dtOneYear;
break;
case "300740326":
newLicense.OutlookSchedule = true;
newLicense.OutlookScheduleExpires = dtOneYear;
break;
case "300740327":
newLicense.ExportToXLS = true;
newLicense.ExportToXLSExpires = dtOneYear;
break;
case "300740328":
newLicense.QuickNotification = true;
newLicense.QuickNotificationExpires = dtOneYear;
break;
case "300740329":
newLicense.ImportExportCSVDuplicate = true;
newLicense.ImportExportCSVDuplicateExpires = dtOneYear;
break;
case "300784766":
newLicense.QBOI = true;
newLicense.QBOIExpires = dtOneYear;
break;
//USERS
case "300740315":
newLicense.Users = 1;
newLicense.MaintenanceExpire = dtOneYear;
break;
case "300740317":
newLicense.Users = 5;
newLicense.MaintenanceExpire = dtOneYear;
break;
case "300740318":
newLicense.Users = 10;
newLicense.MaintenanceExpire = dtOneYear;
break;
case "300740319":
newLicense.Users = 20;
newLicense.MaintenanceExpire = dtOneYear;
break;
case "300741264":
newLicense.Users = 999;
newLicense.MaintenanceExpire = dtOneYear;
break;
case "300807973":
newLicense.Users = 15;
newLicense.MaintenanceExpire = dtOneYear;
break;
case "301091845": //case 4576
newLicense.Users = 25;
newLicense.MaintenanceExpire = dtOneYear;
break;
default:
var err = $"SockBotProcessPurchasesIntoLicenses purchase: {purchase.Id} has product not part of v7 group expected: {product.Name}-{product.VendorCode}";
//serious issue requires immediate notification
await NotifyEventHelper.AddOpsProblemEvent(err);
log.LogError(err);
continue;
}//switch product code
}//each purchase in purchasegroup
//license done ready to save not active so still requires manual intervention but should be substantially ready to send out by here fingers crossed!
}//if v7 block
else
{
//it's a RAVEN license
//get last license for this dbid, if active then take all it's value and update it with this
lastLicense = await ct.License.AsNoTracking().OrderByDescending(z => z.Id).FirstOrDefaultAsync(z => z.CustomerId == firstPurchase.CustomerId && z.PGroup == firstPurchase.PGroup);
//If renewal and there is no last license then it's a problem -> THROW EXCEPTION
//If no last license and not renewal then it's a new purchase -> GENERATE NEW
//If last license and renewal then it's a renewal -> UPDATE LAST LICENSE DATES SAVE NEW
//If last license and NEW then it's an additional add on count
//if dbid matches -> ADD TO COUNT PUT THAT IN NOTES OF LICENSE
//if no dbid match then -> THROW EXCEPTION
//iterate the purchases and update / set the license
foreach (var purchase in purchaseGroup)
{
var product = await ct.Product.AsNoTracking().FirstOrDefaultAsync(z => z.Id == purchase.ProductId);//should *always* exist
if (product == null)
{
var err = $"SockBotProcessPurchasesIntoLicenses purchase: {purchase.Id} has un-matchable product id: {purchase.ProductId}";
//serious issue requires immediate notification
await NotifyEventHelper.AddOpsProblemEvent(err);
log.LogError(err);
continue;
}
/*
301028317 AyaNova perpetual single user license includes one year maintenance plan perpetual Product 12 months HD1 Aug 15, 2022, 9:07 PM
301028467 AyaNova subscription one user monthly subscriptionmonthly Product monthly HD1 Aug 18, 2022, 1:21 AM
301028468 AyaNova subscription one user yearly subscriptionyearly Product 12 months HD1 Aug 18, 2022, 1:25 AM
301033167 AyaNova subscription additional 250 customer users monthly 250customerusersmonthly Product monthly HD1 Oct 6, 2022, 12:56 AM
301033168 AyaNova subscription additional 250 customer users yearly 250customerusersyearly Product 12 months HD1 Oct 6, 2022, 12:59 AM
*/
// - sockeye note: Need to handle multiple raven license purchases, my policy is the freshest date is honoured so for examle tri-star bought in December then in Feb 2 users each time so the feb is the freshest, the trick is when the december renews not to cut it short but still use the feb date. since eventually sockeye will be automated it needs to handle this up front now, not later
//algorithm: check if existing license for db id, if yes, is it for more users than current license?
//if yes then is it unexpired?
//if yes then honour the
//RAVEN licenses have one week padding to be on the safe side
var dtOneYear = DateTime.UtcNow.AddYears(1).AddDays(7);
var dtOneMonth = DateTime.UtcNow.AddMonths(1).AddDays(7);
switch (product.VendorCode)
{
case "301028317"://perpetual
newLicense.Users = purchase.Quantity;
newLicense.MaintenanceExpire = dtOneYear;
newLicense.PGroup = ProductGroup.RavenPerpetual;
break;
case "301028467"://subscription monthly
newLicense.Users = purchase.Quantity;
newLicense.MaintenanceExpire = dtOneMonth;
//FUTURE: for now subscriptions all 20gb if ever add ability to go higher max data with product code etc then remove this
newLicense.MaxDataGB = 20;
newLicense.PGroup = ProductGroup.RavenSubscription;
break;
case "301028468"://subscription yearly
newLicense.Users = purchase.Quantity;
newLicense.MaintenanceExpire = dtOneYear;
//FUTURE: for now subscriptions all 20gb if ever add ability to go higher max data with product code etc then remove this
newLicense.MaxDataGB = 20;
newLicense.PGroup = ProductGroup.RavenSubscription;
break;
case "301033167"://Customer users monthly price
case "301033168"://Customer users yearly price
newLicense.CustomerUsers = purchase.Quantity;
break;
default:
var err = $"SockBotProcessPurchasesIntoLicenses purchase: {purchase.Id} has product not part of RAVEN products expected: {product.Name}-{product.VendorCode}";
//serious issue requires immediate notification
await NotifyEventHelper.AddOpsProblemEvent(err);
log.LogError(err);
continue;
}//switch product code
}//each purchase in purchasegroup
}
//we have a new savable license at this point
if (newLicense != null)
{
LicenseBiz lbiz = LicenseBiz.GetBiz(ct);
newLicense = await lbiz.CreateAsync(newLicense);
if (newLicense == null)
{
//did not save, throw an error
throw new System.ApplicationException($"Error creating license from purchases: {lbiz.GetErrorsAsString()}");
}
}
}
}
catch (Exception ex)
{
var err = "SockBotProcessPurchasesIntoLicenses exepected exception error running job";
//serious issue requires immediate notification
await NotifyEventHelper.AddOpsProblemEvent(err, ex);
log.LogError(ex, err);
}
}
}
/////////////////////////////////////////////////////////////////////
}//eoc
}//eons

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,424 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Sockeye.Models;
using Sockeye.Util;
namespace Sockeye.Biz
{
/// <summary>
/// case 4738: security advisory monitor. Every 3 hours: reads the advisory sources for the watched assets, records
/// every advisory once, alerts at once on high / critical / known exploited, collects medium ones for the Wednesday
/// digest, and reports a source that has failed for more than a day. See devdocs/design/4738-security-advisory-monitor.md
/// </summary>
internal static class SockBotSecurityAdvisories
{
private static ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger("SockBotSecurityAdvisories");
private static DateTime lastSweep = DateTime.MinValue;
#if (DEBUG)
private static readonly TimeSpan RUN_EVERY = new TimeSpan(0, 5, 0);//development: often enough to watch it work
#else
private static readonly TimeSpan RUN_EVERY = new TimeSpan(3, 0, 0);
#endif
private static readonly TimeSpan SOURCE_FAILING_AFTER = new TimeSpan(24, 0, 0);
private static readonly TimeSpan CVE_STALE_AFTER = new TimeSpan(30, 0, 0, 0);//priorities rarely change once set
private const int FIRST_RUN_DAYS = 30, LATE_KEV_DAYS = 90, DIGEST_HOUR = 6, CVE_PARALLEL = 4;
internal const string DIGEST_ROW = "digest";
private static IAdvisorySource[] Sources => new IAdvisorySource[] { new UbuntuUsnSource(), new PostgresSecuritySource() };
private static bool SourceApplies(IAdvisorySource s, IEnumerable<string> platforms) =>
s.Name == UbuntuUsnSource.NAME ? platforms.Any(z => z.StartsWith("ubuntu:")) : platforms.Contains("postgresql");
public static async Task DoWorkAsync()
{
if (DateTime.UtcNow - lastSweep < RUN_EVERY) return;
if (ServerBootConfig.MIGRATING) return;
lastSweep = DateTime.UtcNow;
try
{
await RunAsync();
}
catch (Exception ex)
{
log.LogError(ex, "SockBotSecurityAdvisories error running job");
await NotifyEventHelper.AddOpsProblemEvent("Security advisory monitor: error running job", ex);
}
}
private static HttpClient NewClient()
{
var client = ServiceProviderProvider.HttpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(60);
return client;
}
internal static async Task RunAsync()
{
using AyContext ct = ServiceProviderProvider.DBContext;
var assets = await ct.SecurityAsset.AsNoTracking().Where(z => z.Active).ToListAsync();
if (assets.Count == 0) return;
var platforms = assets.Select(z => z.Platform).Distinct().ToList();
var client = NewClient();
var kev = await TrackAsync(ct, CisaKev.NAME, () => CisaKev.FetchAsync(client));
if (kev != null)
await MarkSuccessAsync(ct, CisaKev.NAME);
foreach (var source in Sources.Where(z => SourceApplies(z, platforms)))
{
var state = await GetStateAsync(ct, source.Name);
bool firstRun = state.LastSuccess == null;
//overlap the last run by two days: Ubuntu sometimes publishes with an earlier date; the store removes repeats
DateTime? since = firstRun ? DateTime.UtcNow.AddDays(-FIRST_RUN_DAYS) : state.LastSuccess.Value.AddDays(-2);
var items = await TrackAsync(ct, source.Name, () => source.FetchAsync(client, since, platforms));
if (items == null) continue;
var ids = items.Select(z => z.SourceId).ToList();
var known = await ct.SecurityAdvisory.AsNoTracking().Where(z => z.Source == source.Name && ids.Contains(z.SourceId)).Select(z => z.SourceId).ToListAsync();
foreach (var item in items.Where(z => !known.Contains(z.SourceId)))
{
//the first run records quietly, so Ubuntu's CVE priorities aren't needed (they take a request each)
var (outcome, matched, cveLines) = await AssessAsync(ct, client, item, assets, kev, lookupPriorities: !firstRun);
//the first run of a source only records what is there, so turning the monitor on doesn't send old news
bool send = !firstRun && outcome == AdvisoryOutcome.Alerted;
ct.SecurityAdvisory.Add(ToRecord(item, outcome, matched, notified: firstRun || send || outcome < AdvisoryOutcome.Digest));
if (send)
await QueueAsync(ct, NotifyEventType.SecurityAdvisoryAlert, $"{item.SourceId} {item.Title}", AlertBody(item, matched, cveLines));
//the record and its notifications are saved together: if queueing fails, neither is, and the next run retries
await ct.SaveChangesAsync();
}
await MarkSuccessAsync(ct, source.Name);
}
if (kev != null)
await AlertNewlyExploitedAsync(ct, kev);
await SendDigestIfDueAsync(ct);
await ReportFailingSourcesAsync(ct);
}
/// <summary>
/// Severity and known-exploited flag for an advisory, then the outcome against the assets. Ubuntu's CVE priorities
/// are fetched only for advisories that match something
/// </summary>
internal static async Task<(AdvisoryOutcome outcome, string matched, List<string> cveLines)> AssessAsync(AyContext ct, HttpClient client,
AdvisoryItem item, List<SecurityAsset> assets, Dictionary<string, DateTime> kev, bool lookupPriorities = true)
{
var cveLines = new List<string>();
if (SecurityAdvisoryPolicy.Evaluate(item, assets).outcome == AdvisoryOutcome.NoMatch)
return (AdvisoryOutcome.NoMatch, null, cveLines);
item.KnownExploited = kev != null && item.CveIds.Any(z => kev.ContainsKey(z));
if (item.Source == UbuntuUsnSource.NAME && lookupPriorities)
{
var priorities = await CvePrioritiesAsync(ct, client, item.CveIds);
var worst = AdvisorySeverity.Unknown;
//worst first, so the CVE that caused the alert heads the list
foreach (var cve in item.CveIds.OrderByDescending(z => kev != null && kev.ContainsKey(z))
.ThenByDescending(z => SecurityAdvisoryPolicy.FromUbuntuPriority(priorities.GetValueOrDefault(z))))
{
priorities.TryGetValue(cve, out var priority);
var sev = SecurityAdvisoryPolicy.FromUbuntuPriority(priority);
if (sev > worst) worst = sev;
cveLines.Add($"{cve} ({priority ?? "priority unknown"}{(kev != null && kev.ContainsKey(cve) ? ", KNOWN EXPLOITED" : "")}) https://ubuntu.com/security/{cve}");
}
item.Severity = worst;
item.RawSeverity = worst == AdvisorySeverity.Unknown ? null : $"Ubuntu priority {worst.ToString().ToLowerInvariant()}";
}
else
cveLines.AddRange(item.CveIds.Select(z => $"{z}{(kev != null && kev.ContainsKey(z) ? " (KNOWN EXPLOITED)" : "")}"));
var (outcome, matched) = SecurityAdvisoryPolicy.Evaluate(item, assets);
return (outcome, matched, cveLines);
}
/// <summary>
/// Ubuntu's priority for each CVE: from the cache, the missing or stale ones fetched a few at a time (one request
/// each, about a second apiece; a kernel notice lists dozens) and saved together. A CVE that can't be read keeps
/// its cached priority or none (counted as medium), so one bad lookup never loses the advisory
/// </summary>
private static async Task<Dictionary<string, string>> CvePrioritiesAsync(AyContext ct, HttpClient client, List<string> cveIds)
{
var cached = await ct.SecurityCve.Where(z => cveIds.Contains(z.Id)).ToListAsync();
var ret = cached.ToDictionary(z => z.Id, z => z.Priority);
var stale = cveIds.Distinct().Where(id => !cached.Any(c => c.Id == id && DateTime.UtcNow - c.Fetched < CVE_STALE_AFTER)).ToList();
if (stale.Count == 0) return ret;
using var gate = new System.Threading.SemaphoreSlim(CVE_PARALLEL);
var fetched = await Task.WhenAll(stale.Select(async id =>
{
await gate.WaitAsync();
try { return (id, priority: await UbuntuUsnSource.FetchCvePriorityAsync(client, id), ok: true); }
catch (Exception ex)
{
log.LogWarning($"Could not read Ubuntu priority for {id}: {ex.Message}");
return (id, priority: (string)null, ok: false);
}
finally { gate.Release(); }
}));
foreach (var f in fetched.Where(z => z.ok))
{
ret[f.id] = f.priority;
var row = cached.FirstOrDefault(z => z.Id == f.id);
if (row == null)
ct.SecurityCve.Add(new SecurityCve { Id = f.id, Priority = f.priority, Fetched = DateTime.UtcNow });
else
{
row.Priority = f.priority;
row.Fetched = DateTime.UtcNow;
}
}
await ct.SaveChangesAsync();
return ret;
}
/// <summary>
/// A CVE is often added to the known exploited list days after its advisory: alert once when that happens
/// </summary>
private static async Task AlertNewlyExploitedAsync(AyContext ct, Dictionary<string, DateTime> kev)
{
var after = DateTime.UtcNow.AddDays(-LATE_KEV_DAYS);
var candidates = await ct.SecurityAdvisory.Where(z => z.Processed > after && !z.KnownExploited
&& (z.Outcome == AdvisoryOutcome.Digest || z.Outcome == AdvisoryOutcome.Ignored)).ToListAsync();
foreach (var a in candidates.Where(z => z.CveIds != null && z.CveIds.Any(c => kev.ContainsKey(c))))
{
a.KnownExploited = true;
a.Outcome = AdvisoryOutcome.Alerted;
a.Notified = true;
var exploited = a.CveIds.Where(c => kev.ContainsKey(c));
await QueueAsync(ct, NotifyEventType.SecurityAdvisoryAlert, $"{a.SourceId} {a.Title}",
$"Now known to be exploited: {string.Join(", ", exploited)} (CISA KEV)\n"
+ $"Affects: {a.Matched}\nSeverity: {a.RawSeverity ?? a.Severity.ToString()}\n{a.Url}\n");
await ct.SaveChangesAsync();
}
}
private static async Task SendDigestIfDueAsync(AyContext ct)
{
var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, TimeZoneInfo.Local);
if (local.DayOfWeek != DayOfWeek.Wednesday || local.Hour < DIGEST_HOUR) return;
var state = await GetStateAsync(ct, DIGEST_ROW);
if (state.LastSuccess != null && TimeZoneInfo.ConvertTimeFromUtc(state.LastSuccess.Value, TimeZoneInfo.Local).Date == local.Date) return;
var pending = await ct.SecurityAdvisory.Where(z => z.Outcome == AdvisoryOutcome.Digest && !z.Notified).OrderBy(z => z.Published).ToListAsync();
state.LastAttempt = state.LastSuccess = DateTime.UtcNow;
if (pending.Count > 0)
{
var sb = new StringBuilder($"{pending.Count} medium security advisories since the last digest, for Thursday's updates:\n\n");
foreach (var a in pending)
sb.Append($"{a.SourceId} {a.Title}\n {a.RawSeverity ?? a.Severity.ToString()}; {a.Matched}\n {a.Url}\n\n");
pending.ForEach(z => z.Notified = true);
await QueueAsync(ct, NotifyEventType.SecurityAdvisoryDigest, $"{pending.Count} advisories", sb.ToString());
}
await ct.SaveChangesAsync();
}
private static async Task ReportFailingSourcesAsync(AyContext ct)
{
var now = DateTime.UtcNow;
foreach (var s in await ct.SecuritySource.Where(z => z.Id != DIGEST_ROW && z.LastError != null).ToListAsync())
{
var since = s.LastSuccess ?? s.FirstAttempt ?? now;
if (now - since < SOURCE_FAILING_AFTER) continue;
if (s.FailureNotified != null && now - s.FailureNotified < SOURCE_FAILING_AFTER) continue;
s.FailureNotified = now;
await ct.SaveChangesAsync();
await NotifyEventHelper.AddOpsProblemEvent($"Security advisory monitor: source {s.Id} has not been read successfully since "
+ $"{(s.LastSuccess == null ? "it was added" : s.LastSuccess.Value.ToString("u"))}. Last error: {s.LastError}");
}
}
/// <summary>
/// Run a fetch and record the source's health; null when it failed (the error is kept on the source row)
/// </summary>
private static async Task<T> TrackAsync<T>(AyContext ct, string sourceName, Func<Task<T>> fetch) where T : class
{
var state = await GetStateAsync(ct, sourceName);
state.LastAttempt = DateTime.UtcNow;
state.FirstAttempt ??= state.LastAttempt;
try
{
var ret = await fetch();
state.LastError = null;
state.FailureNotified = null;
await ct.SaveChangesAsync();
return ret;
}
catch (Exception ex)
{
log.LogWarning($"Security advisory source {sourceName} failed: {ex.Message}");
state.LastError = ex.Message;
await ct.SaveChangesAsync();
return null;
}
}
//LastSuccess is set by the caller once the source's advisories are recorded, so a failure part way is retried next run
private static async Task<SecuritySource> GetStateAsync(AyContext ct, string name)
{
var s = await ct.SecuritySource.FirstOrDefaultAsync(z => z.Id == name);
if (s == null)
{
s = new SecuritySource { Id = name };
ct.SecuritySource.Add(s);
await ct.SaveChangesAsync();
}
return s;
}
internal static async Task MarkSuccessAsync(AyContext ct, string name)
{
var s = await GetStateAsync(ct, name);
s.LastSuccess = DateTime.UtcNow;
await ct.SaveChangesAsync();
}
private static SecurityAdvisory ToRecord(AdvisoryItem item, AdvisoryOutcome outcome, string matched, bool notified) => new SecurityAdvisory
{
Source = item.Source,
SourceId = item.SourceId,
Published = item.Published,
Title = item.Title,
Url = item.Url,
CveIds = item.CveIds,
Matched = matched,
Severity = item.Severity,
RawSeverity = item.RawSeverity,
KnownExploited = item.KnownExploited,
Outcome = outcome,
Notified = notified,
Processed = DateTime.UtcNow
};
internal static string AlertBody(AdvisoryItem item, string matched, List<string> cveLines)
{
var sb = new StringBuilder();
sb.Append($"{item.SourceId}: {item.Title}\n");
sb.Append($"Severity: {item.RawSeverity ?? item.Severity.ToString()}{(item.KnownExploited ? ", KNOWN EXPLOITED (CISA KEV)" : "")}\n");
sb.Append($"Affects: {matched}\n");
if (item.Published != null) sb.Append($"Published: {item.Published.Value:u}\n");
sb.Append($"{item.Url}\n");
if (cveLines.Count > 0)
sb.Append("\nCVEs:\n").Append(string.Join("\n", cveLines)).Append('\n');
return sb.ToString();
}
/// <summary>
/// Queue a notification for everyone subscribed to the event type (email or in-app, per their subscription).
/// Not saved here: the caller saves it together with the change that caused it
/// </summary>
private static async Task QueueAsync(AyContext ct, NotifyEventType eventType, string name, string message)
{
var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == eventType).ToListAsync();
foreach (var sub in subs)
{
if (!await UserBiz.UserIsActive(sub.UserId)) continue;
ct.NotifyEvent.Add(new NotifyEvent
{
EventType = eventType,
UserId = sub.UserId,
NotifySubscriptionId = sub.Id,
Name = name.Length > 250 ? name.Substring(0, 250) : name,
Message = message,
SockType = SockType.NoType,
ObjectId = 0,
EventDate = DateTime.UtcNow
});
}
log.LogInformation($"Security advisory {eventType} queued for {subs.Count} subscription(s): {name}");
}
/// <summary>
/// The latest dry run. It runs in the background because looking up CVE priorities can take minutes, longer
/// than a request may stay open behind nginx; the page polls for the report
/// </summary>
internal class DryRunState
{
public bool Running { get; set; }
public int Days { get; set; }
public DateTime? Started { get; set; }
public DateTime? Finished { get; set; }
public string Error { get; set; }
public List<object> Report { get; set; }
}
private static readonly object dryRunLock = new object();
internal static DryRunState LastDryRun { get; private set; } = new DryRunState();
/// <summary>
/// Start a dry run unless one is already running; false if one is
/// </summary>
internal static bool StartDryRun(int days)
{
lock (dryRunLock)
{
if (LastDryRun.Running) return false;
LastDryRun = new DryRunState { Running = true, Days = days, Started = DateTime.UtcNow };
}
var state = LastDryRun;
_ = Task.Run(async () =>
{
try
{
using AyContext ct = ServiceProviderProvider.DBContext;
state.Report = await DryRunAsync(ct, days);
}
catch (Exception ex)
{
log.LogError(ex, "Security advisory dry run failed");
state.Error = ex.Message;
}
finally
{
state.Finished = DateTime.UtcNow;
state.Running = false;
}
});
return true;
}
/// <summary>
/// What the last days of advisories would have done, sending and recording nothing (only the CVE cache is filled)
/// </summary>
internal static async Task<List<object>> DryRunAsync(AyContext ct, int days)
{
var assets = await ct.SecurityAsset.AsNoTracking().Where(z => z.Active).ToListAsync();
var platforms = assets.Select(z => z.Platform).Distinct().ToList();
var client = NewClient();
var ret = new List<object>();
Dictionary<string, DateTime> kev = null;
try { kev = await CisaKev.FetchAsync(client); }
catch (Exception ex) { ret.Add(new { source = CisaKev.NAME, error = ex.Message }); }
foreach (var source in Sources.Where(z => SourceApplies(z, platforms)))
{
List<AdvisoryItem> items;
try { items = await source.FetchAsync(client, DateTime.UtcNow.AddDays(-days), platforms); }
catch (Exception ex) { ret.Add(new { source = source.Name, error = ex.Message }); continue; }
var ids = items.Select(z => z.SourceId).ToList();
var known = await ct.SecurityAdvisory.AsNoTracking().Where(z => z.Source == source.Name && ids.Contains(z.SourceId)).Select(z => z.SourceId).ToListAsync();
foreach (var item in items.OrderByDescending(z => z.Published))
{
var (outcome, matched, _) = await AssessAsync(ct, client, item, assets, kev);
ret.Add(new
{
source = item.Source,
sourceId = item.SourceId,
published = item.Published,
title = item.Title,
url = item.Url,
severity = item.Severity.ToString(),
rawSeverity = item.RawSeverity,
knownExploited = item.KnownExploited,
outcome = outcome.ToString(),
matched,
alreadyProcessed = known.Contains(item.SourceId)
});
}
}
return ret;
}
}
}

View File

@@ -59,12 +59,16 @@ namespace Sockeye.Models
public virtual DbSet<License> License { get; set; }
public virtual DbSet<Vendor> Vendor { get; set; }
public virtual DbSet<Product> Product { get; set; }
public virtual DbSet<Purchase> Purchase { get; set; }
public virtual DbSet<VendorNotification> VendorNotification { get; set; }
public virtual DbSet<TrialLicenseRequest> TrialLicenseRequest { get; set; }
public virtual DbSet<GZCase> GZCase { get; set; }
public virtual DbSet<SubscriptionServer> SubscriptionServer { get; set; }
public virtual DbSet<SubscriptionServerHealth> SubscriptionServerHealth { get; set; }
public virtual DbSet<SecurityAsset> SecurityAsset { get; set; }
public virtual DbSet<SecurityAdvisory> SecurityAdvisory { get; set; }
public virtual DbSet<SecurityCve> SecurityCve { get; set; }
public virtual DbSet<SecuritySource> SecuritySource { get; set; }
public virtual DbSet<UserPasskey> UserPasskey { get; set; }
public virtual DbSet<UserApiToken> UserApiToken { get; set; }
public virtual DbSet<Subscription> Subscription { get; set; }

View File

@@ -25,10 +25,16 @@ namespace Sockeye.Models
public string Wiki { get; set; }
public List<string> Tags { get; set; }
//case 4745: triage (Closed follows Status, see GZCaseBiz)
public GZCaseStatus Status { get; set; }
public GZCaseProduct? Product { get; set; }
public string Release { get; set; }
public GZCase()
{
Tags = new List<string>();
Created = DateTime.UtcNow;
Status = GZCaseStatus.New;
}
[NotMapped, JsonIgnore]
public SockType SType { get => SockType.GZCase; }

View File

@@ -1,103 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
using Sockeye.Biz;
namespace Sockeye.Models
{
public class Purchase : ICoreBizObjectModel
{
public long Id { get; set; }
public uint Concurrency { get; set; }
public long? CustomerId { get; set; }
public string RegTo { get; set; }
public long? LicenseId { get; set; }//when null and also when pgroup is a licenseable type means it shoudl be processed by SockBotProcessPurchases license gen
[NotMapped]
public string CustomerViz { get; set; }
[Required]
public long VendorId { get; set; }
[NotMapped]
public string VendorViz { get; set; }
public long? ProductId { get; set; }//When null means it's new and should be processed by SockbotProcessPurchases job into proper purchase record
[Required]
public ProductGroup PGroup { get; set; }
[NotMapped]
public string ProductViz { get; set; }
public string SalesOrderNumber { get; set; }
public bool IsRenewal { get; set; }//True if flagged by payment processor as a renewal type order
public string DbId { get; set; }//if a RAVEN order
/*
"accounting": {
"currency": "USD",
"product_net": 577.5,
"discount": 0.0,
"product_vat": 75.08,
"shipping": 0.0,
"shipping_vat": 0.0,
"eu_vat": -75.08,
"margin_net": -29.05,
"your_revenue": 548.45
*/
public string Currency { get; set; }//All values sb US dollars, but just in case
public decimal ProductNet { get; set; } = 0;//total billed for the product before taxes
public decimal Discount { get; set; } = 0;
public decimal VendorFee { get; set; } = 0;//margin_net charged by myCommerce; their fee for processing the order
public decimal Revenue { get; set; } = 0;//amount we are paid for the order (product net - discount - vendorfee)
[Required]
public DateTime PurchaseDate { get; set; }
public DateTime? ExpireDate { get; set; }
public DateTime? CancelDate { get; set; }
public string CouponCode { get; set; }
public string Notes { get; set; }
public bool RenewNoticeSent { get; set; } = false;
public int Quantity { get; set; } = 1;
public long? VendorNotificationId { get; set; }
public bool Processed { get; set; } = false;//indicates it was fully processed and need not be processed by license generating jobs (all imported data set to processed)
public string Wiki { get; set; }
public List<string> Tags { get; set; }
//workaround for notification
[NotMapped, JsonIgnore]
public string Name { get; set; }
public Purchase()
{
Tags = new List<string>();
}
[NotMapped, JsonIgnore]
public SockType SType { get => SockType.Purchase; }
}//eoc
}//eons
/*
## How to make test sales and product links in ShareIt
fake sale of v8 to get license emails to update rockfish to auto read in the deets
FAKE CREDIT CARD
I have generated a test credit card number for your account. Please use the following as the credit card number making sure to use the word test and the 12 digit number with no spaces.
You can use any valid information you would like for the other fields.
test134661442658
Expiration Date: any date in the future
CVS code: 123
Example typical database ID:
tZy9aSHjacFf9a9v3EtzUqM1amq/eMvJa2nXswLle74=
Links to purchase
$50 customization charge https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B300525428%5D=1
Use this for custom report work minimum $50 for small changes
subscription month to month https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B301028467%5D=1
subscription yearly https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B301028468%5D=1
Perpetual single license: https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B301028314%5D=1
*** TESTING THIS ONE FIRST Perpetual single one year maintenance ACTIVE DISCOUNT PRICE: https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B301028315%5D=1
Perpetual single one year maintenance NEW FULL PRICE: https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B301028317%5D=1
v7 single for comparison 300740315 https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B300740315%5D=1
Address to use for testing
05-3610 Christie Parkway Courtenay B.C. Canada V9J 9T6
*/

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using Sockeye.Biz;
namespace Sockeye.Models
{
/// <summary>
/// case 4738: every advisory the monitor has processed, matched or not, so none is ever handled twice
/// (unique on source + source id)
/// </summary>
public class SecurityAdvisory
{
public long Id { get; set; }
public string Source { get; set; }
public string SourceId { get; set; }
public DateTime? Published { get; set; }
public string Title { get; set; }
public string Url { get; set; }
public List<string> CveIds { get; set; }
public string Matched { get; set; }//which assets and components matched, for display
public AdvisorySeverity Severity { get; set; }
public string RawSeverity { get; set; }
public bool KnownExploited { get; set; }
public AdvisoryOutcome Outcome { get; set; }
public bool Notified { get; set; }//false when recorded quietly (first run of a source) or still waiting for the digest
public DateTime Processed { get; set; }
}//eoc
}//eons

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Sockeye.Biz;
namespace Sockeye.Models
{
/// <summary>
/// case 4738: something the security advisory monitor watches, e.g. "Ubuntu 26.04 fleet" (platform ubuntu:resolute,
/// components = every installed source package) or "PostgreSQL" (platform postgresql, components = major versions)
/// </summary>
public class SecurityAsset
{
public long Id { get; set; }
[Required]
public string Name { get; set; }
[Required]
public string Platform { get; set; }
public List<string> Components { get; set; }
public bool Active { get; set; }
public AdvisorySeverity AlertAt { get; set; } = AdvisorySeverity.High;
public AdvisorySeverity DigestAt { get; set; } = AdvisorySeverity.Medium;
}//eoc
}//eons

View File

@@ -0,0 +1,17 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Sockeye.Models
{
/// <summary>
/// case 4738: cache of Ubuntu's priority for a CVE; a kernel notice lists dozens, so each is fetched once
/// and refreshed only when stale
/// </summary>
public class SecurityCve
{
[Key]
public string Id { get; set; }
public string Priority { get; set; }
public DateTime Fetched { get; set; }
}//eoc
}//eons

View File

@@ -0,0 +1,20 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Sockeye.Models
{
/// <summary>
/// case 4738: health of each advisory source (ubuntu-usn, postgresql, cisa-kev) so a source that has silently
/// stopped working is reported instead of looking like a quiet week; the row "digest" records the last digest sent
/// </summary>
public class SecuritySource
{
[Key]
public string Id { get; set; }
public DateTime? FirstAttempt { get; set; }
public DateTime? LastAttempt { get; set; }
public DateTime? LastSuccess { get; set; }
public string LastError { get; set; }
public DateTime? FailureNotified { get; set; }
}//eoc
}//eons

View File

@@ -18,6 +18,7 @@ namespace Sockeye.Models
public uint Concurrency { get; set; }
public bool Active { get; set; }
public bool AllowLogin { get; set; }
public bool Agent { get; set; }//case 4688: an AI agent: no login page sign-in, connects only to /mcp with an agent token
[Required, MaxLength(255)]
public string Name { get; set; }
public AuthorizationRoles Roles { get; set; }
@@ -56,6 +57,7 @@ namespace Sockeye.Models
[Required]
public bool Active { get; set; }
public bool AllowLogin { get; set; }
public bool Agent { get; set; }//case 4688: an AI agent: no login page sign-in, connects only to /mcp with an agent token
[Required, MaxLength(255)]
public string Name { get; set; }
public DateTime? LastLogin { get; set; }

View File

@@ -0,0 +1,21 @@
using System;
namespace Sockeye.Models
{
/// <summary>
/// case 4688: a token an agent User connects to /mcp with. Only its SHA-256 hash is kept; the token itself is shown
/// once when created
/// </summary>
public class UserApiToken
{
public long Id { get; set; }
public long UserId { get; set; }
public string Name { get; set; }
public byte[] TokenHash { get; set; }
public bool ReadOnly { get; set; }
public DateTime Created { get; set; }
public DateTime? Expires { get; set; }//null: never
public DateTime? LastUsed { get; set; }
public DateTime? Revoked { get; set; }
}//eoc
}//eons

View File

@@ -0,0 +1,21 @@
using System;
namespace Sockeye.Models
{
/// <summary>
/// case 4678: a passkey a user can sign in with (any number per user, up to PasskeyBiz.MAX_PER_USER, each named)
/// </summary>
public class UserPasskey
{
public long Id { get; set; }
public long UserId { get; set; }
public string Name { get; set; }
public byte[] CredentialId { get; set; }
public byte[] PublicKey { get; set; }
public long SignCount { get; set; }
//the user's id as the passkey knows it: random, the same for all of one user's passkeys
public byte[] UserHandle { get; set; }
public DateTime Created { get; set; }
public DateTime? LastUsed { get; set; }
}//eoc
}//eons

View File

@@ -1,65 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
using Sockeye.Biz;
namespace Sockeye.Models
{
public class VendorNotification : ICoreBizObjectModel
{
public long Id { get; set; }
public uint Concurrency { get; set; }
[Required]
public DateTime Created { get; set; } = DateTime.UtcNow;
[Required]
public long VendorId { get; set; }
[NotMapped]
public string VendorViz { get; set; }
public string VendorData { get; set; }
public DateTime? Processed { get; set; } = null;//indicates it was fully processed and need not be processed by purchase generating job (all imported data set to processed)
public List<string> Tags { get; set; }
//workaround for notification
[NotMapped, JsonIgnore]
public string Name { get; set; }
public VendorNotification()
{
Tags = new List<string>();
}
[NotMapped, JsonIgnore]
public SockType SType { get => SockType.VendorNotification; }
}//eoc
}//eons
/*
## How to make test sales and product links in ShareIt
fake sale of v8 to get license emails to update rockfish to auto read in the deets
FAKE CREDIT CARD
I have generated a test credit card number for your account. Please use the following as the credit card number making sure to use the word test and the 12 digit number with no spaces.
You can use any valid information you would like for the other fields.
test134661442658
Expiration Date: any date in the future
CVS code: 123
Example typical database ID:
tZy9aSHjacFf9a9v3EtzUqM1amq/eMvJa2nXswLle74=
Links to purchase
$50 customization charge https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B300525428%5D=1
Use this for custom report work minimum $50 for small changes
subscription month to month https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B301028467%5D=1
subscription yearly https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B301028468%5D=1
Perpetual single license: https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B301028314%5D=1
*** TESTING THIS ONE FIRST Perpetual single one year maintenance ACTIVE DISCOUNT PRICE: https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B301028315%5D=1
Perpetual single one year maintenance NEW FULL PRICE: https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B301028317%5D=1
v7 single for comparison 300740315 https://order.mycommerce.com/cart/new?vendorid=14466&PRODUCT%5B300740315%5D=1
Address to use for testing
05-3610 Christie Parkway Courtenay B.C. Canada V9J 9T6
*/

View File

@@ -4,8 +4,8 @@
</PropertyGroup>
<PropertyGroup>
<GenerateFullPaths>true</GenerateFullPaths>
<Version>8.1.3</Version>
<FileVersion>8.1.3.0</FileVersion>
<Version>8.2.1</Version>
<FileVersion>8.2.1.0</FileVersion>
<ApplicationIcon>sockeye.ico</ApplicationIcon>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<noWarn>1591</noWarn>
@@ -18,6 +18,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fido2" Version="4.3.2" /><!-- case 4678: passkeys -->
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.3.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Bogus" Version="35.6.5" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" />

View File

@@ -20,12 +20,12 @@ namespace Sockeye.Util
/////////// CHANGE THIS ON NEW SCHEMA UPDATE ////////////////////
//!!!!WARNING: BE SURE TO UPDATE THE DbUtil::EmptyBizDataFromDatabaseForSeedingOrImportingAsync WHEN NEW TABLES ADDED!!!!
private const int DESIRED_SCHEMA_LEVEL = 25;
private const int DESIRED_SCHEMA_LEVEL = 32;
internal const long EXPECTED_COLUMN_COUNT = 554;
internal const long EXPECTED_INDEX_COUNT = 79;
internal const long EXPECTED_CHECK_CONSTRAINTS = 268;
internal const long EXPECTED_FOREIGN_KEY_CONSTRAINTS = 41;
internal const long EXPECTED_COLUMN_COUNT = 574;
internal const long EXPECTED_INDEX_COUNT = 90;
internal const long EXPECTED_CHECK_CONSTRAINTS = 285;
internal const long EXPECTED_FOREIGN_KEY_CONSTRAINTS = 37;
internal const long EXPECTED_VIEWS = 0;
internal const long EXPECTED_ROUTINES = 2;
@@ -1705,6 +1705,292 @@ $BODY$ LANGUAGE PLPGSQL STABLE");
await SetSchemaLevelAsync(++currentSchema);
}
if (currentSchema < 26)
{
LogUpdateMessage(log);
//case 4675: purchases and vendor notifications are no longer used (nothing new since 2023); products and
//vendors stay, subscriptions use them. Their event log entries stay as history, named by type from now on
await ExecQueryAsync("DELETE FROM asearchkey WHERE socktype IN (96, 99)");
await ExecQueryAsync("DELETE FROM adatalistsavedfilter WHERE listkey IN ('PurchaseDataList', 'VendorNotificationDataList')");
await ExecQueryAsync("DELETE FROM adatalistcolumnview WHERE listkey IN ('PurchaseDataList', 'VendorNotificationDataList')");
await ExecQueryAsync("DELETE FROM aformcustom WHERE formkey IN ('Purchase', 'VendorNotification')");
await ExecQueryAsync("DROP TABLE apurchase");
await ExecQueryAsync("DROP TABLE avendornotification");
await ExecQueryAsync(@"
CREATE OR REPLACE FUNCTION PUBLIC.AYGETNAME(IN AYOBJECTID BIGINT, IN stype INTEGER,TRANSLATIONID integer) RETURNS TEXT AS $BODY$
DECLARE
aytable TEXT DEFAULT '';
aynamecolumn TEXT DEFAULT 'name';
aytkey TEXT DEFAULT 'no';
returnstr TEXT DEFAULT '';
BEGIN
case stype
when 0 then aytkey= 'NoType';
when 1 then aytkey= 'Global';
when 2 then return 'FormUserOptions';
when 3 then aytable = 'auser';
when 4 then aytkey= 'ServerState';
when 6 then aytkey= 'LogFile';
when 7 then aytkey= 'PickListTemplate';
when 8 then aytable = 'acustomer';
when 9 then aytkey= 'ServerJob';
when 12 then aytkey= 'ServerMetrics';
when 13 then aytable = 'atranslation';
when 14 then aytkey= 'UserOptions';
when 15 then aytable = 'aheadoffice';
when 17 then aytable = 'afileattachment'; aynamecolumn ='displayfilename';
when 18 then aytable = 'adatalistsavedfilter';
when 19 then aytable = 'aformcustom'; aynamecolumn = 'formkey';
when 33 then aytable = 'avendor';
when 47 then aytkey= 'GlobalOps';
when 48 then aytkey= 'BizMetrics';
when 49 then aytkey= 'Backup';
when 50 then aytable = 'ainappnotification';
when 51 then aytkey= 'NotifySubscription';
when 52 then aytable = 'areminder';
when 56 then aytkey= 'OpsNotificationSettings';
when 57 then aytable = 'areport';
when 58 then aytkey= 'DashBoardView';
when 59 then aytable = 'acustomernote'; aynamecolumn = 'notedate';
when 60 then aytable = 'amemo';
when 61 then aytable = 'areview';
when 68 then return format('DataListColumnView %L', ayobjectid);
when 84 then aytkey= 'CustomerNotifySubscription';
when 92 then aytable = 'aintegration';
when 93 then aytable = 'alicense'; aynamecolumn = 'regto';
when 94 then aytable = 'atriallicenserequest'; aynamecolumn = 'companyname';
when 95 then aytable = 'asubscriptionserver';
when 96 then aytkey= 'Purchase';
when 97 then aytable = 'aproduct';
when 98 then aytable = 'agzcase';
when 99 then aytkey= 'VendorNotification';
when 100 then aytable = 'asubscription'; aynamecolumn = 'subsite';
else
RETURN returnstr;
end case;
IF aytkey='no' then
EXECUTE format('SELECT %I FROM %I WHERE id = %L', aynamecolumn, aytable, ayobjectid) INTO returnstr;
else
EXECUTE format('select display from atranslationitem where translationid=%L and key=%L', TRANSLATIONID, aytkey) INTO returnstr;
END if;
RETURN returnstr;
END;
$BODY$ LANGUAGE PLPGSQL STABLE");
await SetSchemaLevelAsync(++currentSchema);
}
if (currentSchema < 27)
{
LogUpdateMessage(log);
//case 4738: security advisory monitor: watched assets, every advisory processed (once), Ubuntu CVE priority
//cache, and each source's health
await ExecQueryAsync("CREATE TABLE asecurityasset (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, "
+ "platform TEXT NOT NULL, components TEXT[] NOT NULL DEFAULT '{}', active BOOL NOT NULL DEFAULT true, "
+ "alertat INTEGER NOT NULL DEFAULT 4, digestat INTEGER NOT NULL DEFAULT 3)");
await ExecQueryAsync("CREATE TABLE asecurityadvisory (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, source TEXT NOT NULL, "
+ "sourceid TEXT NOT NULL, published TIMESTAMPTZ, title TEXT, url TEXT, cveids TEXT[], matched TEXT, "
+ "severity INTEGER NOT NULL DEFAULT 0, rawseverity TEXT, knownexploited BOOL NOT NULL DEFAULT false, "
+ "outcome INTEGER NOT NULL DEFAULT 0, notified BOOL NOT NULL DEFAULT false, processed TIMESTAMPTZ NOT NULL, "
+ "CONSTRAINT asecurityadvisory_source_sourceid_unique UNIQUE (source, sourceid))");
await ExecQueryAsync("CREATE INDEX asecurityadvisory_processed_idx ON asecurityadvisory (processed)");
await ExecQueryAsync("CREATE TABLE asecuritycve (id TEXT PRIMARY KEY, priority TEXT, fetched TIMESTAMPTZ NOT NULL)");
await ExecQueryAsync("CREATE TABLE asecuritysource (id TEXT PRIMARY KEY, firstattempt TIMESTAMPTZ, lastattempt TIMESTAMPTZ, "
+ "lastsuccess TIMESTAMPTZ, lasterror TEXT, failurenotified TIMESTAMPTZ)");
foreach (var lang in new[] { "en", "es", "fr", "de" })
{
await ExecQueryAsync($"INSERT INTO atranslationitem(translationid,key,display) SELECT t.id, 'NotifyEventSecurityAdvisoryAlert', 'Security advisory alert' FROM atranslation t where t.baselanguage = '{lang}'");
await ExecQueryAsync($"INSERT INTO atranslationitem(translationid,key,display) SELECT t.id, 'NotifyEventSecurityAdvisoryDigest', 'Security advisory digest' FROM atranslation t where t.baselanguage = '{lang}'");
}
await SetSchemaLevelAsync(++currentSchema);
}
if (currentSchema < 28)
{
LogUpdateMessage(log);
//case 4678: passkeys, any number per user (a cap in code), removed with the user
await ExecQueryAsync("CREATE TABLE auserpasskey (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "
+ "userid BIGINT NOT NULL REFERENCES auser (id) ON DELETE CASCADE, name TEXT NOT NULL, credentialid BYTEA NOT NULL UNIQUE, "
+ "publickey BYTEA NOT NULL, signcount BIGINT NOT NULL DEFAULT 0, userhandle BYTEA NOT NULL, "
+ "created TIMESTAMPTZ NOT NULL, lastused TIMESTAMPTZ)");
await ExecQueryAsync("CREATE INDEX auserpasskey_userid_idx ON auserpasskey (userid)");
//UI text (AyaNova-bound feature, so translation keys; English in every language until translated)
var keys = new System.Collections.Generic.Dictionary<string, string>
{
{ "Passkeys", "Passkeys" },
{ "PasskeyIntro", "Sign in with your device's fingerprint, face or PIN instead of a password. Your password still works." },
{ "PasskeyAdd", "Add a passkey" },
{ "PasskeyName", "Name for this passkey (e.g. Office PC, Phone)" },
{ "PasskeyCreated", "Added" },
{ "PasskeyLastUsed", "Last used" },
{ "PasskeyNone", "No passkeys yet" },
{ "PasskeyRemove", "Remove passkey" },
{ "PasskeyRemoveAll", "Remove all passkeys" },
{ "PasskeyNotSupported", "Passkeys can't be used here: they need HTTPS and the server's domain name, and a browser that supports them" }
};
foreach (var lang in new[] { "en", "es", "fr", "de" })
foreach (var k in keys)
await ExecQueryAsync($"INSERT INTO atranslationitem(translationid,key,display) SELECT t.id, '{k.Key}', '{k.Value.Replace("'", "''")}' FROM atranslation t where t.baselanguage = '{lang}'");
await SetSchemaLevelAsync(++currentSchema);
}
if (currentSchema < 29)
{
LogUpdateMessage(log);
//case 4688: agent Users (no login page sign-in; connect only to /mcp) and their tokens (only the hash is kept)
await ExecQueryAsync("ALTER TABLE auser ADD COLUMN agent BOOL NOT NULL DEFAULT false");
await ExecQueryAsync("CREATE TABLE auserapitoken (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "
+ "userid BIGINT NOT NULL REFERENCES auser (id) ON DELETE CASCADE, name TEXT NOT NULL, tokenhash BYTEA NOT NULL UNIQUE, "
+ "readonly BOOL NOT NULL DEFAULT true, created TIMESTAMPTZ NOT NULL, expires TIMESTAMPTZ, lastused TIMESTAMPTZ, revoked TIMESTAMPTZ)");
await ExecQueryAsync("CREATE INDEX auserapitoken_userid_idx ON auserapitoken (userid)");
//UI text (AyaNova-bound feature, so translation keys; English in every language until translated)
var agentKeys = new System.Collections.Generic.Dictionary<string, string>
{
{ "UserAgent", "AI agent" },
{ "UserAgentHint", "An AI agent can't sign in here; it connects with an agent token to the MCP endpoint" },
{ "AgentTokens", "Agent tokens" },
{ "AgentTokenCreate", "Create agent token" },
{ "AgentTokenName", "Name for this token (e.g. John's workstation)" },
{ "AgentTokenExpiresDays", "Expires after days (0 = never)" },
{ "AgentTokenShowOnce", "Copy this token now. It won't be shown again." },
{ "AgentTokenExpires", "Expires" },
{ "AgentTokenNever", "Never" },
{ "AgentTokenLastUsed", "Last used" },
{ "AgentTokenRevoke", "Revoke" },
{ "AgentTokenRevoked", "Revoked" },
{ "AgentTokenNone", "No agent tokens" }
};
foreach (var lang in new[] { "en", "es", "fr", "de" })
foreach (var k in agentKeys)
await ExecQueryAsync($"INSERT INTO atranslationitem(translationid,key,display) SELECT t.id, '{k.Key}', '{k.Value.Replace("'", "''")}' FROM atranslation t where t.baselanguage = '{lang}'");
await SetSchemaLevelAsync(++currentSchema);
}
if (currentSchema < 30)
{
LogUpdateMessage(log);
//case 4745: case triage fields. Existing cases start as New with no product; their conversion from the old tags is a separate step
await ExecQueryAsync("ALTER TABLE agzcase ADD COLUMN status INTEGER NOT NULL DEFAULT 1, ADD COLUMN product INTEGER, ADD COLUMN release TEXT");
await ExecQueryAsync("CREATE INDEX agzcase_product_status_idx ON agzcase (product, status)");
//data list column headings need translation keys (a missing key shows as ??key); the rest of the case triage UI is plain English
var caseKeys = new System.Collections.Generic.Dictionary<string, string>
{
{ "GZCaseStatus", "Status" },
{ "GZCaseProduct", "Product" },
{ "GZCaseRelease", "Release" }
};
foreach (var lang in new[] { "en", "es", "fr", "de" })
foreach (var k in caseKeys)
await ExecQueryAsync($"INSERT INTO atranslationitem(translationid,key,display) SELECT t.id, '{k.Key}', '{k.Value}' FROM atranslation t where t.baselanguage = '{lang}'");
await SetSchemaLevelAsync(++currentSchema);
}
if (currentSchema < 31)
{
LogUpdateMessage(log);
//case 4745: convert existing cases from the old tags to Status, Product and Release, then retire those tags.
//Mapping agreed with John 2026-11-02 against the production counts (design note devdocs/design/4745-case-triage.md).
//The case numbers are production's own decisions, one time only; on any other database they match nothing or are harmless.
//One DO block, so it all happens or none of it does
await ExecQueryAsync(@"DO $$
DECLARE
retired TEXT[] := ARRAY['00-priority','1-priority','2-priority','3-priority','4-priority','5-priority','investigate','triage','backburner','any-time',
'next-release','idea','request','bug-report','info','biz-ref-docs','how-to''s','rejected','duplicate','completed','released',
'ayanova','raven','sockeye','rockfish','qbi','ayanova-7.x','v7','website','biz'];
versiontag TEXT := '^v\d+(\.\d+)+$';
added BIGINT;
BEGIN
--rules, first match wins. Status: 1 New, 2 Investigate, 3 Accepted, 4 Built, 5 Done, 6 Never, 7 Duplicate, 8 Reference.
--Product: 1 AyaNova, 2 Sockeye, 3 QBI, 4 AyaNova 7, 5 Website, 6 Business; with several product tags the first in this order wins
UPDATE agzcase c SET
release = (SELECT substring(t from '^v(\d+(?:\.\d+)+)$') FROM unnest(c.tags::text[]) t WHERE t ~ versiontag ORDER BY t DESC LIMIT 1),
status = CASE
WHEN c.closed IS NOT NULL AND c.tags::text[] && ARRAY['rejected','nff'] THEN 6
WHEN c.closed IS NOT NULL AND c.tags::text[] && ARRAY['duplicate'] THEN 7
WHEN c.closed IS NOT NULL THEN 5
WHEN c.tags::text[] && ARRAY['info','biz-ref-docs','how-to''s','rockfish-biz-guide'] THEN 8
WHEN EXISTS (SELECT 1 FROM unnest(c.tags::text[]) t WHERE t ~ versiontag) THEN 4
WHEN c.tags::text[] && ARRAY['next-release'] THEN 3
WHEN c.tags::text[] && ARRAY['00-priority','1-priority','2-priority','3-priority','4-priority','5-priority','investigate','triage','backburner','any-time'] THEN 2
ELSE 1 END,
product = CASE
WHEN c.tags::text[] && ARRAY['ayanova','raven','legacy-ayanova-8.x'] THEN 1
WHEN c.tags::text[] && ARRAY['sockeye','rockfish'] THEN 2
WHEN c.tags::text[] && ARRAY['qbi'] THEN 3
WHEN c.tags::text[] && ARRAY['ayanova-7.x','v7','wbi-7.x','ri-7.x','qbi-7.x','qboi-7.x','pti-7.x']
OR EXISTS (SELECT 1 FROM unnest(c.tags::text[]) t WHERE t LIKE 'legacy-%') THEN 4
WHEN c.tags::text[] && ARRAY['website'] THEN 5
WHEN c.tags::text[] && ARRAY['biz','marketing','stripe','payments'] THEN 6
END;
--John's decisions on single cases
UPDATE agzcase SET product = 6 WHERE caseid IN (1827, 3645, 4469, 4479, 4507, 4508, 4509, 4660, 4666, 4667, 3812, 4734, 4611);
UPDATE agzcase SET product = 1 WHERE caseid IN (746, 3415, 4066, 4521, 4490, 4514, 4515, 4544, 4574);
UPDATE agzcase SET product = 5 WHERE caseid = 4605;
UPDATE agzcase SET status = 5 WHERE caseid IN (4487, 4675, 4769);--released in Sockeye 8.1.4
UPDATE agzcase SET status = 5, product = 2, release = '8.2.0' WHERE caseid IN (4678, 4738);--Sockeye 8.2.0; 4678's AyaNova port is case 4777
UPDATE agzcase SET status = 3, release = '8.2.1' WHERE caseid = 4688;
UPDATE agzcase SET status = 3, product = 1, release = '8.4.0' WHERE caseid IN (4766, 4775);
UPDATE agzcase SET status = 6 WHERE caseid IN (4547, 4611);--a test case and a note that belongs elsewhere
--Closed follows Status
UPDATE agzcase SET closed = now() WHERE status IN (5, 6, 7) AND closed IS NULL;
--old tags that have a new one: bug-report is bug, request (a customer asked) is customer
--(the tag repository counts each use)
INSERT INTO atag (name, refcount) VALUES ('bug', 0), ('customer', 0) ON CONFLICT (name) DO NOTHING;
UPDATE agzcase SET tags = array_append(tags, 'bug') WHERE tags::text[] && ARRAY['bug-report'] AND NOT tags::text[] && ARRAY['bug'];
GET DIAGNOSTICS added = ROW_COUNT;
UPDATE atag SET refcount = refcount + added WHERE name = 'bug';
UPDATE agzcase SET tags = array_append(tags, 'customer') WHERE tags::text[] && ARRAY['request'] AND NOT tags::text[] && ARRAY['customer'];
GET DIAGNOSTICS added = ROW_COUNT;
UPDATE atag SET refcount = refcount + added WHERE name = 'customer';
--the tag repository counts uses across all record types: take off the case uses being removed, then the retired tags themselves
UPDATE atag a SET refcount = a.refcount - x.n
FROM (SELECT t, count(*) AS n FROM agzcase, unnest(tags::text[]) t WHERE t = ANY(retired) OR t ~ versiontag GROUP BY t) x
WHERE a.name = x.t;
UPDATE agzcase SET tags = ARRAY(SELECT t FROM unnest(tags) t WHERE NOT (t::text = ANY(retired) OR t::text ~ versiontag))
WHERE tags::text[] && retired OR EXISTS (SELECT 1 FROM unnest(tags::text[]) t WHERE t ~ versiontag);
DELETE FROM atag WHERE refcount <= 0;
END $$;");
await SetSchemaLevelAsync(++currentSchema);
}
if (currentSchema < 32)
{
LogUpdateMessage(log);
//case 4688 phase 2: tokens that can change records (AyaNova-bound feature, so translation keys; English in every language until translated)
var tokenKeys = new System.Collections.Generic.Dictionary<string, string>
{
{ "AgentTokenCanChange", "Can change records" },
{ "AgentTokenReadOnly", "Read only" }
};
foreach (var lang in new[] { "en", "es", "fr", "de" })
foreach (var k in tokenKeys)
await ExecQueryAsync($"INSERT INTO atranslationitem(translationid,key,display) SELECT t.id, '{k.Key}', '{k.Value}' FROM atranslation t where t.baselanguage = '{lang}'");
await SetSchemaLevelAsync(++currentSchema);
}
//#########################################

View File

@@ -467,7 +467,6 @@ namespace Sockeye.Util
await TruncateTableAsync("asubscriptionserverhealth", conn, true);
await TruncateTableAsync("asubscriptionserver", conn);
await TruncateTableAsync("apurchase", conn);
await TruncateTableAsync("aproduct", conn);
//await TruncateTableAsync("avendor", conn);
await TruncateTableAsync("acustomernote", conn);

View File

@@ -22,6 +22,12 @@ namespace Sockeye.Util
public async Task SendEmailAsync(string email, string subject, string body, Sockeye.Models.GlobalOpsNotificationSettings smtpSettings, string attachPDFPath = null, string forceFileName = null, string htmlBody = null)
{
#if (DEBUG)
//integration tests send to example.com (reserved, never a real mailbox): everything up to the send runs, the send doesn't
if (email != null && email.EndsWith("@example.com", StringComparison.OrdinalIgnoreCase))
{
System.Diagnostics.Debug.WriteLine($"DEBUG build: email to {email} not sent (\"{subject}\")");
return;
}
//SAFETY BREAK FOR TESTING IN HOUSE
switch (email)
{

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Configuration;
@@ -82,6 +83,16 @@ namespace Sockeye.Util
//SECURITY
internal static string SOCKEYE_SET_SUPERUSER_PW { get; set; }
//case 4678: passkeys belong to a domain (the relying party id, e.g. sockeye.ayanova.com) and are accepted only from
//the listed origins (e.g. https://sockeye.ayanova.com); passkey sign-in is off when not set (debug builds default to localhost)
internal static string SOCKEYE_PASSKEY_RPID { get; set; }
internal static List<string> SOCKEYE_PASSKEY_ORIGINS { get; set; }
//case 4688: limits per agent User at /mcp (requests: a burst, then a steady rate; calls at once) and for all agents
//together, so agents are fast but can't overload the server. Defaults built in; each can be set in configuration
internal static int SOCKEYE_AGENT_RATE_BURST { get; set; }
internal static int SOCKEYE_AGENT_RATE_PER_SECOND { get; set; }
internal static int SOCKEYE_AGENT_CONCURRENT { get; set; }
internal static int SOCKEYE_AGENT_CONCURRENT_TOTAL { get; set; }
//HELPFUL INFORMATION FOR DIAGNOSTICS
internal static Dictionary<string, string> BOOT_DIAGNOSTIC_INFO { get; set; } = new Dictionary<string, string>();
@@ -156,6 +167,25 @@ namespace Sockeye.Util
//backdoor back door password superuser reset
SOCKEYE_SET_SUPERUSER_PW = config.GetValue<string>("SOCKEYE_SET_SUPERUSER_PW");
//case 4688: agent limits
SOCKEYE_AGENT_RATE_BURST = Math.Max(1, config.GetValue<int?>("SOCKEYE_AGENT_RATE_BURST") ?? 30);
SOCKEYE_AGENT_RATE_PER_SECOND = Math.Max(1, config.GetValue<int?>("SOCKEYE_AGENT_RATE_PER_SECOND") ?? 5);
SOCKEYE_AGENT_CONCURRENT = Math.Max(1, config.GetValue<int?>("SOCKEYE_AGENT_CONCURRENT") ?? 4);
SOCKEYE_AGENT_CONCURRENT_TOTAL = Math.Max(1, config.GetValue<int?>("SOCKEYE_AGENT_CONCURRENT_TOTAL") ?? 8);
//case 4678: passkeys
SOCKEYE_PASSKEY_RPID = config.GetValue<string>("SOCKEYE_PASSKEY_RPID")?.Trim();
SOCKEYE_PASSKEY_ORIGINS = (config.GetValue<string>("SOCKEYE_PASSKEY_ORIGINS") ?? "")
.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries).Select(z => z.TrimEnd('/')).ToList();
#if (DEBUG)
if (string.IsNullOrWhiteSpace(SOCKEYE_PASSKEY_RPID))
{
SOCKEYE_PASSKEY_RPID = "localhost";
if (SOCKEYE_PASSKEY_ORIGINS.Count == 0)
SOCKEYE_PASSKEY_ORIGINS = new List<string> { "http://localhost:8080", "http://localhost:7676" };
}
#endif
//REPORT RENDERING
//RENDER ENGINE PATH

View File

@@ -5,7 +5,7 @@ namespace Sockeye.Util
/// </summary>
internal static class SockeyeVersion
{
public const string VersionString = "8.1.3";
public const string VersionString = "8.2.1";
public const string FullNameAndVersion = "Sockeye server " + VersionString;
public const string CurrentApiVersion="v8";
}//eoc

View File

@@ -0,0 +1,421 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Xunit;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using ModelContextProtocol.Client;
namespace sockeye_integration
{
/// <summary>
/// case 4688: AI agents. An agent is a User marked Agent: it can't sign in on the login page, connects only to /mcp with
/// an agent token, runs with its own roles, and is rate limited. Each test makes its own agent User and removes it
/// </summary>
public class Agents
{
private const string MCP_URL = "http://localhost:7676/mcp";
private const int BIZ_ADMIN_RESTRICTED = 1;//reads business records, changes nothing
private const int ACCOUNTING = 64;//can change cases (as John's agent account has)
[Fact]
public async Task AgentUsesTheToolsWithItsToken()
{
string adminToken = await TestUser.GetTokenAsync();
long agentId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED);
long caseId = 0;
try
{
//a case to look up
string caseName = Util.Uniquify("case 4688 agent test");
ApiResponse a = await Util.PostAsync("gzcase", adminToken, new JObject { ["name"] = caseName, ["notes"] = "agent test notes", ["tags"] = new JArray(), ["product"] = 2 }.ToString());
Util.ValidateDataReturnResponseOk(a);
caseId = a.ObjectResponse["data"]["id"].Value<long>();
long caseNumber = a.ObjectResponse["data"]["caseId"].Value<long>();
var (tokenId, token) = await CreateTokenAsync(adminToken, agentId);
await using (var client = await ConnectAsync(token))
{
var tools = (await client.ListToolsAsync()).Select(z => z.Name).ToList();
tools.Should().BeEquivalentTo("search", "get_case", "list_cases", "get_customer", "find_licence",
"list_subscription_servers", "list_trial_requests", "get_event_log",
"create_case", "update_case", "add_case_note", "replace_case_notes", "set_case_tags");
var result = await client.CallToolAsync("get_case", new Dictionary<string, object> { ["caseNumber"] = caseNumber });
(result.IsError ?? false).Should().BeFalse(Text(result));
Text(result).Should().Contain(caseName).And.Contain("agent test notes");
//results are capped at 100 whatever is asked for
result = await client.CallToolAsync("list_cases", new Dictionary<string, object> { ["limit"] = 500 });
JArray.Parse(Text(result)).Count.Should().BeLessOrEqualTo(100);
//every other tool runs (its queries work against the real database)
foreach (var (tool, args) in new (string, Dictionary<string, object>)[]
{
("search", new() { ["query"] = "test" }),
("list_cases", new() { ["status"] = "Investigate", ["product"] = "AyaNova 7", ["oldestFirst"] = true, ["limit"] = 5 }),
("get_customer", new() { ["name"] = "a" }),
("find_licence", new() { ["registeredTo"] = "a", ["limit"] = 5 }),
("list_subscription_servers", new() { ["trialOnly"] = true, ["limit"] = 5 }),
("list_trial_requests", new() { ["all"] = true, ["limit"] = 5 }),
("get_event_log", new() { ["type"] = "GZCase", ["id"] = caseId })
})
{
result = await client.CallToolAsync(tool, args);
(result.IsError ?? false).Should().BeFalse($"{tool}: {Text(result)}");
}
}
//the token stops working once revoked
Util.ValidateHTTPStatusCode(await Util.DeleteAsync($"agent-token/{tokenId}", adminToken), 204);
(await RawMcpAsync(token)).StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
finally
{
if (caseId != 0) await Util.DeleteAsync($"gzcase/{caseId}", adminToken);
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
/// <summary>
/// case 4688 phase 2: get_case shows the triage fields (by name) and the concurrency value; list_cases filters by
/// status, product and release, open only unless asked, and refuses an unknown name
/// </summary>
[Fact]
public async Task AgentSeesAndFiltersCaseTriage()
{
string adminToken = await TestUser.GetTokenAsync();
long agentId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED);
long caseId = 0;
try
{
string release = "99.0." + DateTime.UtcNow.Ticks % 100000;//a release no real case has
ApiResponse a = await Util.PostAsync("gzcase", adminToken, new JObject
{
["name"] = Util.Uniquify("case 4688 triage test"), ["tags"] = new JArray(), ["product"] = 4,//AyaNova 7
["status"] = 2, ["release"] = release//Investigate
}.ToString());
Util.ValidateDataReturnResponseOk(a);
caseId = a.ObjectResponse["data"]["id"].Value<long>();
long caseNumber = a.ObjectResponse["data"]["caseId"].Value<long>();
var (_, token) = await CreateTokenAsync(adminToken, agentId);
await using var client = await ConnectAsync(token);
var c = JObject.Parse(Text(await client.CallToolAsync("get_case", new Dictionary<string, object> { ["caseNumber"] = caseNumber })));
c["status"].Value<string>().Should().Be("Investigate");
c["product"].Value<string>().Should().Be("AyaNova 7");
c["release"].Value<string>().Should().Be(release);
c["concurrency"].Type.Should().Be(JTokenType.Integer);
async Task<JArray> ListAsync(Dictionary<string, object> args) => JArray.Parse(Text(await client.CallToolAsync("list_cases", args)));
(await ListAsync(new() { ["status"] = "investigate", ["product"] = "ayanova 7", ["release"] = "v" + release }))
.Select(z => z["caseNumber"].Value<long>()).Should().Equal(caseNumber);
(await ListAsync(new() { ["status"] = "New", ["release"] = release })).Should().BeEmpty();
//closed cases only when asked
var saved = (JObject)(await Util.GetAsync($"gzcase/{caseId}", adminToken)).ObjectResponse["data"];
saved["status"] = 6;//Never
Util.ValidateDataReturnResponseOk(await Util.PutAsync("gzcase", adminToken, saved.ToString()));
(await ListAsync(new() { ["release"] = release })).Should().BeEmpty();
var closed = await ListAsync(new() { ["release"] = release, ["includeClosed"] = true });
closed.Single()["status"].Value<string>().Should().Be("Never");
closed.Single()["closed"].Type.Should().NotBe(JTokenType.Null);
Text(await client.CallToolAsync("list_cases", new Dictionary<string, object> { ["status"] = "Pending" }))
.Should().Contain("Unknown status").And.Contain("Investigate");
Text(await client.CallToolAsync("list_cases", new Dictionary<string, object> { ["product"] = "4" }))
.Should().Contain("Unknown product", "numbers aren't names");
}
finally
{
if (caseId != 0) await Util.DeleteAsync($"gzcase/{caseId}", adminToken);
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
/// <summary>
/// case 4688 phase 2: writing needs a token that can change records and roles that allow it, and says which is missing
/// </summary>
[Fact]
public async Task AgentWriteNeedsTokenAndRoles()
{
string adminToken = await TestUser.GetTokenAsync();
long readerId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED);
try
{
var args = new Dictionary<string, object> { ["name"] = Util.Uniquify("case 4688 refused"), ["product"] = "Sockeye" };
var (_, readOnlyToken) = await CreateTokenAsync(adminToken, readerId);
await using (var client = await ConnectAsync(readOnlyToken))
{
var result = await client.CallToolAsync("create_case", args);
(result.IsError ?? false).Should().BeTrue();
Text(result).Should().Contain("read only");
}
var (_, writingToken) = await CreateTokenAsync(adminToken, readerId, canChange: true);
await using (var client = await ConnectAsync(writingToken))
{
var result = await client.CallToolAsync("create_case", args);
(result.IsError ?? false).Should().BeTrue();
Text(result).Should().Contain("roles");
}
}
finally
{
await Util.DeleteAsync($"user/{readerId}", adminToken);
}
}
/// <summary>
/// case 4688 phase 2: an agent creates and changes a case: only what it gives changes, notes are added at the top in
/// John's format, a stale replace is refused, tags come and go, a closing status closes it
/// </summary>
[Fact]
public async Task AgentWritesCases()
{
string adminToken = await TestUser.GetTokenAsync();
string login = Util.Uniquify("agent4688w").Replace(" ", "");
long agentId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED | ACCOUNTING, login: login);
var caseIds = new List<long>();
try
{
var (_, token) = await CreateTokenAsync(adminToken, agentId, canChange: true);
await using var client = await ConnectAsync(token);
async Task<JObject> CallAsync(string tool, Dictionary<string, object> args)
{
var r = await client.CallToolAsync(tool, args);
(r.IsError ?? false).Should().BeFalse($"{tool}: {Text(r)}");
var c = JObject.Parse(Text(r));
if (!caseIds.Contains(c["id"].Value<long>())) caseIds.Add(c["id"].Value<long>());
return c;
}
string name = Util.Uniquify("case 4688 agent writes");
var c = await CallAsync("create_case", new() { ["name"] = name, ["product"] = "Sockeye", ["notes"] = "first note" });
long number = c["caseNumber"].Value<long>();
c["status"].Value<string>().Should().Be("New");
//only what's given changes
c = await CallAsync("update_case", new() { ["caseNumber"] = number, ["status"] = "Investigate", ["release"] = "v8.9.9" });
c["status"].Value<string>().Should().Be("Investigate");
c["release"].Value<string>().Should().Be("8.9.9");
c["name"].Value<string>().Should().Be(name);
c["product"].Value<string>().Should().Be("Sockeye");
c["notes"].Value<string>().Should().Be("first note");
//a note goes on top: "<date time> - <agent>: note", then the separator, then the notes as they were
c = await CallAsync("add_case_note", new() { ["caseNumber"] = number, ["text"] = "second note" });
c["notes"].Value<string>().Should().MatchRegex($@"^\d{{4}}-\d{{2}}-\d{{2}} \d{{2}}:\d{{2}} - {login}: second note\n-=-=-=-=-=-=-\nfirst note$");
//a case without notes gets no separator
var empty = await CallAsync("create_case", new() { ["name"] = Util.Uniquify("case 4688 no notes"), ["product"] = "Business" });
empty = await CallAsync("add_case_note", new() { ["caseNumber"] = empty["caseNumber"].Value<long>(), ["text"] = "only note" });
empty["notes"].Value<string>().Should().MatchRegex($@"^\d{{4}}-\d{{2}}-\d{{2}} \d{{2}}:\d{{2}} - {login}: only note$");
//replacing the notes needs the case as last read
var stale = await client.CallToolAsync("replace_case_notes", new Dictionary<string, object> { ["caseNumber"] = number, ["notes"] = "x", ["concurrency"] = 1 });
(stale.IsError ?? false).Should().BeTrue();
Text(stale).Should().Contain("changed since");
c = await CallAsync("replace_case_notes", new() { ["caseNumber"] = number, ["notes"] = "fixed notes", ["concurrency"] = c["concurrency"].Value<uint>() });
c["notes"].Value<string>().Should().Be("fixed notes");
//tags come and go
c = await CallAsync("set_case_tags", new() { ["caseNumber"] = number, ["add"] = new[] { "Agent-Test-4688", "customer" } });
c["tags"].Select(z => z.Value<string>()).Should().BeEquivalentTo("agent-test-4688", "customer");
c = await CallAsync("set_case_tags", new() { ["caseNumber"] = number, ["remove"] = new[] { "agent-test-4688" } });
c["tags"].Select(z => z.Value<string>()).Should().BeEquivalentTo("customer");
//a closing status closes it
c = await CallAsync("update_case", new() { ["caseNumber"] = number, ["status"] = "Never" });
c["closed"].Type.Should().NotBe(JTokenType.Null);
var bad = await client.CallToolAsync("update_case", new Dictionary<string, object> { ["caseNumber"] = number, ["status"] = "Pending" });
(bad.IsError ?? false).Should().BeTrue();
Text(bad).Should().Contain("Unknown status");
//the changes are the agent's in the event log
var events = JObject.Parse(Text(await client.CallToolAsync("get_event_log", new Dictionary<string, object> { ["type"] = "GZCase", ["id"] = caseIds[0] })))["events"];
events.Should().Contain(z => z["user"].Value<string>() == login && z["event"].Value<string>() == "Created");
events.Should().Contain(z => z["user"].Value<string>() == login && z["event"].Value<string>() == "Modified");
}
finally
{
foreach (var id in caseIds)
await Util.DeleteAsync($"gzcase/{id}", adminToken);
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
/// <summary>
/// case 4688 phase 2: an agent's tool call is one line in the server log (SockeyeMcpTools' own), without the MCP
/// SDK's INFO lines around it
/// </summary>
[Fact]
public async Task AgentCallIsLoggedOnce()
{
string adminToken = await TestUser.GetTokenAsync();
string login = Util.Uniquify("agent4688log").Replace(" ", "");
long agentId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED, login: login);
//the log's time stamps are the server's local time; the test runs against a server on this machine
string since = DateTime.Now.AddSeconds(-1).ToString("yyyy-MM-dd HH:mm:ss");
try
{
var (_, token) = await CreateTokenAsync(adminToken, agentId);
await using (var client = await ConnectAsync(token))
{
var result = await client.CallToolAsync("search", new Dictionary<string, object> { ["query"] = "test" });
(result.IsError ?? false).Should().BeFalse(Text(result));
}
var req = new HttpRequestMessage(HttpMethod.Get, Util.API_BASE_URL + "log-file/log-sockeye.txt");
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + adminToken);
var lines = (await (await raw.SendAsync(req)).Content.ReadAsStringAsync()).Split('\n')
.Where(z => z.Length > since.Length && char.IsDigit(z[0]) && string.CompareOrdinal(z, since) >= 0).ToList();//entries, not their continuation lines
lines.Count(z => z.Contains($"MCP search by agent \"{login}\"")).Should().Be(1, "the tool call is logged once");
lines.Should().NotContain(z => z.Contains("|INFO|ModelContextProtocol."), "the MCP SDK's INFO lines are filtered out");
}
finally
{
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
/// <summary>
/// case 4688 phase 2: a token is read only unless it is made able to change records, and the list says which
/// </summary>
[Fact]
public async Task TokenCanChangeOnlyWhenAsked()
{
string adminToken = await TestUser.GetTokenAsync();
long agentId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED);
try
{
var (readOnlyId, _) = await CreateTokenAsync(adminToken, agentId);
var (canChangeId, _) = await CreateTokenAsync(adminToken, agentId, canChange: true);
ApiResponse a = await Util.GetAsync($"agent-token/{agentId}", adminToken);
Util.ValidateDataReturnResponseOk(a);
var tokens = (JArray)a.ObjectResponse["data"];
tokens.Single(z => z["id"].Value<long>() == readOnlyId)["readOnly"].Value<bool>().Should().BeTrue("a token is read only by default");
tokens.Single(z => z["id"].Value<long>() == canChangeId)["readOnly"].Value<bool>().Should().BeFalse();
}
finally
{
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
[Fact]
public async Task AgentRolesApply()
{
string adminToken = await TestUser.GetTokenAsync();
long agentId = await CreateUserAsync(adminToken, agent: true, roles: 0);
try
{
var (_, token) = await CreateTokenAsync(adminToken, agentId);
await using var client = await ConnectAsync(token);
var result = await client.CallToolAsync("list_cases", new Dictionary<string, object>());
(result.IsError ?? false).Should().BeTrue("an agent with no roles can't read cases");
}
finally
{
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
[Fact]
public async Task AgentCantSignInOnTheLoginPageAndPeopleCantUseMcp()
{
string adminToken = await TestUser.GetTokenAsync();
string login = Util.Uniquify("agent4688").Replace(" ", "");
string password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18));
long agentId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED, login: login, password: password);
try
{
Util.ValidateHTTPStatusCode(await Util.PostAsync("auth", null, new JObject { ["login"] = login, ["password"] = password }.ToString()), 401);
//a person's sign-in token isn't accepted at /mcp, and a person can't be given an agent token
(await RawMcpAsync(adminToken)).StatusCode.Should().Be(HttpStatusCode.Unauthorized);
long me = await TestUser.GetUserIdAsync();
Util.ValidateHTTPStatusCode(await Util.PostAsync("agent-token", adminToken, new JObject { ["userId"] = me }.ToString()), 400);
}
finally
{
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
[Fact]
public async Task AgentIsRateLimited()
{
string adminToken = await TestUser.GetTokenAsync();
long agentId = await CreateUserAsync(adminToken, agent: true, roles: BIZ_ADMIN_RESTRICTED);
try
{
var (_, token) = await CreateTokenAsync(adminToken, agentId);
//well past the default burst of 30 at once: some are refused with a time to retry after
var responses = await Task.WhenAll(Enumerable.Range(0, 60).Select(_ => RawMcpAsync(token)));
var limited = responses.Where(z => (int)z.StatusCode == 429).ToList();
limited.Should().NotBeEmpty();
limited[0].Headers.RetryAfter.Should().NotBeNull();
responses.Count(z => z.IsSuccessStatusCode).Should().BeGreaterThan(0);
}
finally
{
await Util.DeleteAsync($"user/{agentId}", adminToken);
}
}
private static async Task<McpClient> ConnectAsync(string token)
{
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(MCP_URL),
AdditionalHeaders = new Dictionary<string, string> { ["Authorization"] = "Bearer " + token }
});
return await McpClient.CreateAsync(transport);
}
private static string Text(ModelContextProtocol.Protocol.CallToolResult result) =>
string.Concat(result.Content.OfType<ModelContextProtocol.Protocol.TextContentBlock>().Select(z => z.Text));
//one MCP request (tools/list) without the client library, to see the HTTP status
private static readonly HttpClient raw = new HttpClient();
private static async Task<HttpResponseMessage> RawMcpAsync(string token)
{
var req = new HttpRequestMessage(HttpMethod.Post, MCP_URL)
{
Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}", Encoding.UTF8, "application/json")
};
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token);
req.Headers.TryAddWithoutValidation("Accept", "application/json, text/event-stream");
return await raw.SendAsync(req);
}
private static async Task<long> CreateUserAsync(string adminToken, bool agent, int roles, string login = null, string password = null)
{
login ??= Util.Uniquify("agent4688").Replace(" ", "");
ApiResponse a = await Util.PostAsync("user", adminToken, new JObject
{
["name"] = login,
["login"] = login,
["password"] = password ?? Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)),
["active"] = true,
["allowLogin"] = true,
["agent"] = agent,
["roles"] = roles,
["userType"] = 2,//NotService
["tags"] = new JArray()
}.ToString());
Util.ValidateDataReturnResponseOk(a);
return a.ObjectResponse["data"]["id"].Value<long>();
}
private static async Task<(long id, string token)> CreateTokenAsync(string adminToken, long userId, bool canChange = false)
{
ApiResponse a = await Util.PostAsync("agent-token", adminToken, new JObject { ["userId"] = userId, ["name"] = "integration test", ["canChange"] = canChange }.ToString());
Util.ValidateDataReturnResponseOk(a);
((string)a.ObjectResponse["data"]["token"]).Should().StartWith("sk_");
return (a.ObjectResponse["data"]["id"].Value<long>(), (string)a.ObjectResponse["data"]["token"]);
}
}//eoc
}//eons

View File

@@ -10,15 +10,120 @@ namespace sockeye_integration
private static async Task<long> CreateCaseAsync(string token, string name)
{
ApiResponse a = await Util.PostAsync("gzcase", token, new JObject { ["name"] = Util.Uniquify(name), ["notes"] = "integration test", ["tags"] = new JArray() }.ToString());
ApiResponse a = await Util.PostAsync("gzcase", token, new JObject { ["name"] = Util.Uniquify(name), ["notes"] = "integration test", ["tags"] = new JArray(), ["product"] = PRODUCT_SOCKEYE }.ToString());
Util.ValidateDataReturnResponseOk(a);
return a.ObjectResponse["data"]["id"].Value<long>();
}
private static async Task RunBatchCloseAsync(string token, long[] ids, bool open)
//case 4745: GZCaseStatus and GZCaseProduct values
private const int STATUS_NEW = 1, STATUS_INVESTIGATE = 2, STATUS_BUILT = 4, STATUS_DONE = 5, STATUS_NEVER = 6;
private const int PRODUCT_SOCKEYE = 2;
private static async Task<JObject> GetCaseAsync(string token, long id)
{
ApiResponse a = await Util.GetAsync($"gzcase/{id}", token);
Util.ValidateDataReturnResponseOk(a);
return (JObject)a.ObjectResponse["data"];
}
private static void ValidateFieldError(ApiResponse a, string field, int apiErrorCode)
{
Util.ValidateErrorCodeResponse(a, 2200, 400);
((JArray)a.ObjectResponse["error"]["details"]).Should().Contain(d => d["target"].Value<string>() == field && d["error"].Value<string>() == apiErrorCode.ToString(),
$"there should be a {apiErrorCode} error on {field}: " + a.ObjectResponse.ToString(Newtonsoft.Json.Formatting.None));
}
/// <summary>
/// case 4745: a new case needs a product, and starts as New
/// </summary>
[Fact]
public async Task NewCaseNeedsProductAndStartsNew()
{
string token = await TestUser.GetTokenAsync();
ApiResponse a = await Util.PostAsync("gzcase", token, new JObject { ["name"] = Util.Uniquify("case 4745 test no product"), ["tags"] = new JArray() }.ToString());
ValidateFieldError(a, "Product", 2201);
long id = await CreateCaseAsync(token, "case 4745 test new");
try
{
var c = await GetCaseAsync(token, id);
c["status"].Value<int>().Should().Be(STATUS_NEW);
c["product"].Value<int>().Should().Be(PRODUCT_SOCKEYE);
c["closed"].Type.Should().Be(JTokenType.Null);
}
finally
{
await Util.DeleteAsync($"gzcase/{id}", token);
}
}
/// <summary>
/// case 4745: Closed follows Status; changing only Closed (older callers) moves Status; Built needs a release,
/// written without a leading v; the product can't be removed
/// </summary>
[Fact]
public async Task StatusClosedAndReleaseRules()
{
string token = await TestUser.GetTokenAsync();
long id = await CreateCaseAsync(token, "case 4745 test rules");
try
{
//a closing status closes it, an open one reopens it
var c = await GetCaseAsync(token, id);
c["status"] = STATUS_NEVER;
ApiResponse a = await Util.PutAsync("gzcase", token, c.ToString());
Util.ValidateDataReturnResponseOk(a);
//the save answers with what it stored, for the form to show and send back
a.ObjectResponse["data"]["status"].Value<int>().Should().Be(STATUS_NEVER);
a.ObjectResponse["data"]["closed"].Type.Should().NotBe(JTokenType.Null, "the save answer carries the Closed it set");
c = await GetCaseAsync(token, id);
c["closed"].Type.Should().NotBe(JTokenType.Null, "Never closes the case");
c["status"] = STATUS_INVESTIGATE;
a = await Util.PutAsync("gzcase", token, c.ToString());
Util.ValidateDataReturnResponseOk(a);
c = await GetCaseAsync(token, id);
c["closed"].Type.Should().Be(JTokenType.Null, "Investigate is open");
//what the form's Close button sends: only Closed changes
c["closed"] = DateTime.UtcNow;
a = await Util.PutAsync("gzcase", token, c.ToString());
Util.ValidateDataReturnResponseOk(a);
c = await GetCaseAsync(token, id);
c["status"].Value<int>().Should().Be(STATUS_DONE, "closing makes it Done");
c["closed"] = null;
a = await Util.PutAsync("gzcase", token, c.ToString());
Util.ValidateDataReturnResponseOk(a);
c = await GetCaseAsync(token, id);
c["status"].Value<int>().Should().Be(STATUS_NEW, "reopening makes it New");
//Built needs a release
c["status"] = STATUS_BUILT;
a = await Util.PutAsync("gzcase", token, c.ToString());
ValidateFieldError(a, "Release", 2201);
c["release"] = " v8.2.1 ";
a = await Util.PutAsync("gzcase", token, c.ToString());
Util.ValidateDataReturnResponseOk(a);
c = await GetCaseAsync(token, id);
c["release"].Value<string>().Should().Be("8.2.1");
//a product, once set, stays set
c["product"] = null;
a = await Util.PutAsync("gzcase", token, c.ToString());
ValidateFieldError(a, "Product", 2201);
}
finally
{
await Util.DeleteAsync($"gzcase/{id}", token);
}
}
private static async Task RunBatchCloseAsync(string token, long[] ids, bool open, int? closeAs = null)
{
var selection = new JObject { ["SockType"] = GZCASE_SOCKTYPE, ["selectedRowIds"] = new JArray(ids), ["dataListKey"] = "GZCaseDataList" };
ApiResponse a = await Util.PostAsync($"job-operations/batch-close-cases?open={open.ToString().ToLowerInvariant()}", token, selection.ToString());
ApiResponse a = await Util.PostAsync($"job-operations/batch-close-cases?open={open.ToString().ToLowerInvariant()}{(closeAs == null ? "" : $"&status={closeAs}")}", token, selection.ToString());
Util.ValidateHTTPStatusCode(a, 202);
string jobId = a.ObjectResponse["jobId"].Value<string>();
for (int i = 0; i < 60; i++)
@@ -49,6 +154,7 @@ namespace sockeye_integration
ApiResponse a = await Util.GetAsync($"gzcase/{id}", token);
a.ObjectResponse["data"]["closed"].Type.Should().NotBe(JTokenType.Null, $"case {id} should be closed");
a.ObjectResponse["data"]["closed"].Value<DateTime>().Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(2));
a.ObjectResponse["data"]["status"].Value<int>().Should().Be(STATUS_DONE, "case 4745: closing makes it Done");
}
await RunBatchCloseAsync(token, ids, open: true);
@@ -56,6 +162,16 @@ namespace sockeye_integration
{
ApiResponse a = await Util.GetAsync($"gzcase/{id}", token);
a.ObjectResponse["data"]["closed"].Type.Should().Be(JTokenType.Null, $"case {id} should be open again");
a.ObjectResponse["data"]["status"].Value<int>().Should().Be(STATUS_NEW, "case 4745: reopening makes it New");
}
//case 4745: closing with a chosen status
await RunBatchCloseAsync(token, ids, open: false, closeAs: STATUS_NEVER);
foreach (var id in ids)
{
var c = await GetCaseAsync(token, id);
c["status"].Value<int>().Should().Be(STATUS_NEVER);
c["closed"].Type.Should().NotBe(JTokenType.Null, $"case {id} should be closed");
}
}
finally
@@ -124,6 +240,45 @@ namespace sockeye_integration
}
}
/// <summary>
/// case 4745: the case list shows Status, Product and Release, and the two lists of values are offered by name
/// </summary>
[Fact]
public async Task CaseListShowsTriageFields()
{
string token = await TestUser.GetTokenAsync();
ApiResponse a = await Util.GetAsync("enum-list/list/GZCaseStatus", token);
Util.ValidateDataReturnResponseOk(a);
((JArray)a.ObjectResponse["data"]).Should().Contain(z => z["name"].Value<string>() == "Investigate" && z["id"].Value<int>() == STATUS_INVESTIGATE);
a = await Util.GetAsync("enum-list/list/GZCaseProduct", token);
Util.ValidateDataReturnResponseOk(a);
((JArray)a.ObjectResponse["data"]).Should().Contain(z => z["name"].Value<string>() == "Sockeye" && z["id"].Value<int>() == PRODUCT_SOCKEYE);
long id = await CreateCaseAsync(token, "case 4745 test list");
try
{
var c = await GetCaseAsync(token, id);
c["status"] = STATUS_INVESTIGATE;
c["release"] = "8.2.1";
Util.ValidateDataReturnResponseOk(await Util.PutAsync("gzcase", token, c.ToString()));
//newest first by default, so the new case is on the first page
a = await Util.PostAsync("data-list", token, "{\"dataListKey\":\"GZCaseDataList\",\"limit\":10,\"offset\":0}");
Util.ValidateDataReturnResponseOk(a);
var columns = ((JArray)a.ObjectResponse["columns"]).Select(z => z["cm"].Value<string>()).ToList();
columns.Should().Contain(new[] { "GZCaseStatus", "GZCaseProduct", "GZCaseRelease" }, "the test user's case list should show the default columns");
var row = ((JArray)a.ObjectResponse["data"]).FirstOrDefault(r => r[0]["i"].Value<long>() == id);
row.Should().NotBeNull("the new case should be on the first page");
row[columns.IndexOf("GZCaseStatus")]["v"].Value<int>().Should().Be(STATUS_INVESTIGATE);
row[columns.IndexOf("GZCaseProduct")]["v"].Value<int>().Should().Be(PRODUCT_SOCKEYE);
row[columns.IndexOf("GZCaseRelease")]["v"].Value<string>().Should().Be("8.2.1");
}
finally
{
await Util.DeleteAsync($"gzcase/{id}", token);
}
}
/// <summary>
/// case 4742: only cases can be closed or opened by the batch route
/// </summary>
@@ -134,6 +289,11 @@ namespace sockeye_integration
var selection = new JObject { ["SockType"] = 95, ["selectedRowIds"] = new JArray(1), ["dataListKey"] = "SubscriptionServerDataList" };
ApiResponse a = await Util.PostAsync("job-operations/batch-close-cases", token, selection.ToString());
Util.ValidateErrorCodeResponse(a, 2203, 400);
//case 4745: and only as Done or Never (a Duplicate names its other case, one at a time)
selection = new JObject { ["SockType"] = GZCASE_SOCKTYPE, ["selectedRowIds"] = new JArray(1), ["dataListKey"] = "GZCaseDataList" };
a = await Util.PostAsync("job-operations/batch-close-cases?status=7", token, selection.ToString());
Util.ValidateErrorCodeResponse(a, 2203, 400);
}
}//eoc
}//eons

View File

@@ -0,0 +1,26 @@
using Xunit;
using FluentAssertions;
using Newtonsoft.Json.Linq;
namespace sockeye_integration
{
public class NotifyEventTypes
{
private const int SUBSCRIPTION_SERVER_LAST_UPDATE_AGE = 39, SUBSCRIPTION_SERVER_EXPIRING = 38;
/// <summary>
/// case 4769: the subscription server "last update age" (operating system updates) notification is removed: servers are
/// updated weekly on a schedule, so it can no longer be chosen in a notification subscription
/// </summary>
[Fact]
public async Task LastUpdateAgeIsNotOffered()
{
string token = await TestUser.GetTokenAsync();
ApiResponse a = await Util.GetAsync("enum-list/list/NotifyEventType", token);
Util.ValidateDataReturnResponseOk(a);
var ids = ((JArray)a.ObjectResponse["data"]).Select(z => z["id"].Value<int>()).ToList();
ids.Should().Contain(SUBSCRIPTION_SERVER_EXPIRING);
ids.Should().NotContain(SUBSCRIPTION_SERVER_LAST_UPDATE_AGE);
}
}//eoc
}//eons

View File

@@ -0,0 +1,243 @@
using System.Security.Cryptography;
using System.Text;
using Xunit;
using FluentAssertions;
using Newtonsoft.Json.Linq;
namespace sockeye_integration
{
/// <summary>
/// case 4678: passkey registration and sign-in, end to end, with a software authenticator (below) instead of a device.
/// Uses its own temporary user: a sign-in replaces that user's session, which would break other tests if it were the
/// shared test user. Needs the server's passkey settings for localhost (debug builds default to them)
/// </summary>
public class Passkeys
{
private const string ORIGIN = "http://localhost:7676";
[Fact]
public async Task RegisterSignInAndRemove()
{
string adminToken = await TestUser.GetTokenAsync();
var (userId, login, password) = await CreateUserAsync(adminToken);
try
{
string token = await PasswordLoginAsync(login, password);
var device = new SoftAuthenticator();
//register
ApiResponse a = await Util.PostAsync("auth/passkey/register-options", token, "{}");
Util.ValidateDataReturnResponseOk(a);
var create = (JObject)a.ObjectResponse["data"];
((string)create["options"]["rp"]["id"]).Should().Be("localhost");
a = await Util.PostAsync("auth/passkey/register", token, new JObject
{
["id"] = create["id"],
["name"] = "Test device",
["response"] = device.Create(create["options"], ORIGIN)
}.ToString());
Util.ValidateDataReturnResponseOk(a);
long passkeyId = a.ObjectResponse["data"]["id"].Value<long>();
//sign in: the token is for this user
a = await PasskeyLoginAsync(device, ORIGIN);
Util.ValidateDataReturnResponseOk(a);
((string)a.ObjectResponse["data"]["name"]).Should().Be(login);
((string)a.ObjectResponse["data"]["token"]).Should().NotBeNullOrEmpty();
//a signature counter that doesn't go up (a cloned device) is refused
device.Counter--;
Util.ValidateHTTPStatusCode(await PasskeyLoginAsync(device, ORIGIN), 401);
device.Counter++;
//a wrong origin (another site) is refused
Util.ValidateHTTPStatusCode(await PasskeyLoginAsync(device, "https://evil.example.com"), 401);
//an options id is good once only
a = await Util.PostAsync("auth/passkey/login-options", null, "{}");
var get = (JObject)a.ObjectResponse["data"];
var answer = device.Get(get["options"], ORIGIN);
Util.ValidateDataReturnResponseOk(await Util.PostAsync("auth/passkey/login", null, new JObject { ["id"] = get["id"], ["response"] = answer }.ToString()));
Util.ValidateHTTPStatusCode(await Util.PostAsync("auth/passkey/login", null, new JObject { ["id"] = get["id"], ["response"] = answer }.ToString()), 401);
//listed, with its last use; the user's session token from the last sign-in
token = (string)(await PasskeyLoginAsync(device, ORIGIN)).ObjectResponse["data"]["token"];
a = await Util.GetAsync("auth/passkey", token);
var list = (JArray)a.ObjectResponse["data"];
list.Should().HaveCount(1);
list[0]["lastUsed"].Type.Should().NotBe(JTokenType.Null);
//removed: can't sign in with it any more
Util.ValidateHTTPStatusCode(await Util.DeleteAsync($"auth/passkey/{passkeyId}", token), 204);
Util.ValidateHTTPStatusCode(await PasskeyLoginAsync(device, ORIGIN), 401);
}
finally
{
await Util.DeleteAsync($"user/{userId}", adminToken);
}
}
[Fact]
public async Task RegistrationFromAnotherOriginIsRefused()
{
string adminToken = await TestUser.GetTokenAsync();
var (userId, login, password) = await CreateUserAsync(adminToken);
try
{
string token = await PasswordLoginAsync(login, password);
ApiResponse a = await Util.PostAsync("auth/passkey/register-options", token, "{}");
var create = (JObject)a.ObjectResponse["data"];
a = await Util.PostAsync("auth/passkey/register", token, new JObject
{
["id"] = create["id"],
["response"] = new SoftAuthenticator().Create(create["options"], "https://evil.example.com")
}.ToString());
Util.ValidateHTTPStatusCode(a, 400);
a = await Util.GetAsync("auth/passkey", token);
((JArray)a.ObjectResponse["data"]).Should().BeEmpty();
}
finally
{
await Util.DeleteAsync($"user/{userId}", adminToken);
}
}
private static async Task<ApiResponse> PasskeyLoginAsync(SoftAuthenticator device, string origin)
{
ApiResponse a = await Util.PostAsync("auth/passkey/login-options", null, "{}");
Util.ValidateDataReturnResponseOk(a);
var get = (JObject)a.ObjectResponse["data"];
return await Util.PostAsync("auth/passkey/login", null, new JObject { ["id"] = get["id"], ["response"] = device.Get(get["options"], origin) }.ToString());
}
private static async Task<(long id, string login, string password)> CreateUserAsync(string adminToken)
{
string login = Util.Uniquify("pk4678").Replace(" ", "");
string password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18));
ApiResponse a = await Util.PostAsync("user", adminToken, new JObject
{
["name"] = login,
["login"] = login,
["password"] = password,
["active"] = true,
["allowLogin"] = true,
["roles"] = 0,
["userType"] = 2,//NotService
["tags"] = new JArray()
}.ToString());
Util.ValidateDataReturnResponseOk(a);
return (a.ObjectResponse["data"]["id"].Value<long>(), login, password);
}
private static async Task<string> PasswordLoginAsync(string login, string password)
{
ApiResponse a = await Util.PostAsync("auth", null, new JObject { ["login"] = login, ["password"] = password }.ToString());
Util.ValidateDataReturnResponseOk(a);
return (string)a.ObjectResponse["data"]["token"];
}
/// <summary>
/// A passkey device in software: a P-256 key, "none" attestation, user present and verified, a signature counter
/// </summary>
private class SoftAuthenticator
{
private readonly ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private readonly byte[] credentialId = RandomNumberGenerator.GetBytes(32);
private byte[] userHandle;
public uint Counter = 0;
public JObject Create(JToken options, string origin)
{
userHandle = B64UrlDecode((string)options["user"]["id"]);
var clientData = ClientData("webauthn.create", (string)options["challenge"], origin);
var p = key.ExportParameters(false);
var cose = Cbor.Map(new (object, object)[] { (1, 2), (3, -7), (-1, 1), (-2, p.Q.X), (-3, p.Q.Y) });
var authData = AuthData((string)options["rp"]["id"], 0x45, Counter,
new byte[16].Concat(new[] { (byte)(credentialId.Length >> 8), (byte)credentialId.Length }).Concat(credentialId).Concat(cose).ToArray());
var attestation = Cbor.Map(new (object, object)[] { ("fmt", "none"), ("attStmt", Cbor.Map(Array.Empty<(object, object)>())), ("authData", authData) });
return Credential(new JObject
{
["clientDataJSON"] = B64Url(clientData),
["attestationObject"] = B64Url(attestation),
["transports"] = new JArray("internal")
});
}
public JObject Get(JToken options, string origin)
{
Counter++;
var clientData = ClientData("webauthn.get", (string)options["challenge"], origin);
var authData = AuthData((string)options["rpId"], 0x05, Counter, Array.Empty<byte>());
var signature = key.SignData(authData.Concat(SHA256.HashData(clientData)).ToArray(), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
return Credential(new JObject
{
["clientDataJSON"] = B64Url(clientData),
["authenticatorData"] = B64Url(authData),
["signature"] = B64Url(signature),
["userHandle"] = B64Url(userHandle)
});
}
private JObject Credential(JObject response) => new JObject
{
["id"] = B64Url(credentialId),
["rawId"] = B64Url(credentialId),
["type"] = "public-key",
["response"] = response,
["clientExtensionResults"] = new JObject()
};
private static byte[] ClientData(string type, string challenge, string origin) =>
Encoding.UTF8.GetBytes(new JObject { ["type"] = type, ["challenge"] = challenge, ["origin"] = origin, ["crossOrigin"] = false }.ToString(Newtonsoft.Json.Formatting.None));
//rpIdHash, flags (UP 0x01, UV 0x04, AT 0x40), counter (big-endian), then attested credential data when registering
private static byte[] AuthData(string rpId, byte flags, uint counter, byte[] attested) =>
SHA256.HashData(Encoding.UTF8.GetBytes(rpId))
.Concat(new[] { flags, (byte)(counter >> 24), (byte)(counter >> 16), (byte)(counter >> 8), (byte)counter })
.Concat(attested).ToArray();
}
private static string B64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] B64UrlDecode(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
/// <summary>
/// The little CBOR the software authenticator needs: maps, small integers, text and byte strings
/// </summary>
private static class Cbor
{
public static byte[] Map((object key, object value)[] items)
{
var ret = Head(5, (ulong)items.Length).ToList();
foreach (var (k, v) in items) { ret.AddRange(Item(k)); ret.AddRange(Item(v)); }
return Mark(ret.ToArray());
}
private static byte[] Item(object o) => o switch
{
int i when i >= 0 => Head(0, (ulong)i),
int i => Head(1, (ulong)(-1 - i)),
string s => Head(3, (ulong)Encoding.UTF8.GetByteCount(s)).Concat(Encoding.UTF8.GetBytes(s)).ToArray(),
byte[] b when IsEncodedMap(b) => b,
byte[] b => Head(2, (ulong)b.Length).Concat(b).ToArray(),
_ => throw new ArgumentException("unsupported CBOR item")
};
//maps built by Map() are already encoded; marked by reference so they are embedded, not wrapped as bytes
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<byte[], object> encodedMaps = new();
private static bool IsEncodedMap(byte[] b) => encodedMaps.TryGetValue(b, out _);
public static byte[] Mark(byte[] map) { encodedMaps.Add(map, null); return map; }
private static byte[] Head(int major, ulong n)
{
byte m = (byte)(major << 5);
if (n < 24) return new[] { (byte)(m | n) };
if (n < 256) return new[] { (byte)(m | 24), (byte)n };
return new[] { (byte)(m | 25), (byte)(n >> 8), (byte)n };
}
}
}//eoc
}//eons

View File

@@ -0,0 +1,34 @@
using Xunit;
using FluentAssertions;
namespace sockeye_integration
{
/// <summary>
/// case 4675: purchases and vendor notifications are removed; products and vendors stay because subscriptions use them
/// </summary>
public class RemovedPurchases
{
[Fact]
public async Task PurchasesAndVendorNotificationsAreGone()
{
string token = await TestUser.GetTokenAsync();
ApiResponse a = await Util.GetAsync("data-list/listkeys", token);
Util.ValidateDataReturnResponseOk(a);
var keys = a.ObjectResponse["data"].ToObject<List<string>>();
keys.Should().Contain("ProductDataList").And.Contain("VendorDataList");
keys.Should().NotContain("PurchaseDataList").And.NotContain("VendorNotificationDataList");
Util.ValidateHTTPStatusCode(await Util.GetAsync("purchase/1", token), 404);
Util.ValidateHTTPStatusCode(await Util.GetAsync("vendor-notification/1", token), 404);
}
[Fact]
public async Task ProductsStillWork()
{
string token = await TestUser.GetTokenAsync();
ApiResponse a = await Util.PostAsync("data-list", token, "{\"dataListKey\":\"ProductDataList\",\"limit\":5,\"offset\":0}");
Util.ValidateDataReturnResponseOk(a);
a.ObjectResponse["data"].Should().NotBeNull();
}
}//eoc
}//eons

View File

@@ -17,7 +17,7 @@ namespace sockeye_integration
{
string token = await TestUser.GetTokenAsync();
long userId = await TestUser.GetUserIdAsync();
ApiResponse a = await Util.PostAsync("gzcase", token, new JObject { ["name"] = Util.Uniquify("case 4468 test"), ["tags"] = new JArray() }.ToString());
ApiResponse a = await Util.PostAsync("gzcase", token, new JObject { ["name"] = Util.Uniquify("case 4468 test"), ["tags"] = new JArray(), ["product"] = 2 }.ToString());
Util.ValidateDataReturnResponseOk(a);
long caseId = a.ObjectResponse["data"]["id"].Value<long>();
long reviewId = 0;
@@ -30,7 +30,8 @@ namespace sockeye_integration
["assignedByUserId"] = userId,
["objectId"] = caseId,
["sockType"] = GZCASE_SOCKTYPE,
["reviewDate"] = DateTime.UtcNow.AddDays(1).ToString("o"),
//in the past, so no "review imminent" notice is scheduled: every such subscription would be emailed
["reviewDate"] = DateTime.UtcNow.AddDays(-1).ToString("o"),
["tags"] = new JArray()
};
a = await Util.PostAsync("review", token, review.ToString());
@@ -54,5 +55,72 @@ namespace sockeye_integration
await Util.DeleteAsync($"gzcase/{caseId}", token);
}
}
/// <summary>
/// case 4779: a review's "review imminent" notice is the review's own (type Review), so changing the review replaces
/// it rather than adding a second, and deleting the review removes it. Stored under the reviewed record's type, the
/// old notices were never found to clear: each save added one, and a deleted review's still went out.
/// Nothing is sent: the subscription delivers in the app, and the review is due far beyond any advance notice
/// </summary>
[Fact]
public async Task ReviewNoticeGoesWithTheReview()
{
string token = await TestUser.GetTokenAsync();
long userId = await TestUser.GetUserIdAsync();
ApiResponse a = await Util.PostAsync("gzcase", token, new JObject { ["name"] = Util.Uniquify("case 4779 test"), ["tags"] = new JArray(), ["product"] = 2 }.ToString());
Util.ValidateDataReturnResponseOk(a);
long caseId = a.ObjectResponse["data"]["id"].Value<long>();
a = await Util.PostAsync("notify-subscription", token, new JObject
{
["userId"] = userId, ["eventType"] = REVIEW_IMMINENT, ["deliveryMethod"] = DELIVER_IN_APP,
["advanceNotice"] = "1.00:00:00", ["tags"] = new JArray()
}.ToString());
Util.ValidateDataReturnResponseOk(a);
long subscriptionId = a.ObjectResponse["data"]["id"].Value<long>();
long reviewId = 0;
try
{
string name = Util.Uniquify("case 4779 review");
a = await Util.PostAsync("review", token, new JObject
{
["name"] = name, ["userId"] = userId, ["assignedByUserId"] = userId, ["objectId"] = caseId, ["sockType"] = GZCASE_SOCKTYPE,
["reviewDate"] = DateTime.UtcNow.AddDays(400).ToString("o"), ["tags"] = new JArray()
}.ToString());
Util.ValidateDataReturnResponseOk(a);
reviewId = a.ObjectResponse["data"]["id"].Value<long>();
async Task<List<JToken>> NoticesAsync()
{
var q = await Util.GetAsync("notify/queue", token);
Util.ValidateDataReturnResponseOk(q);
return ((JArray)q.ObjectResponse["data"]).Where(z => z["userId"].Value<long>() == userId && z["eventType"].Value<int>() == REVIEW_IMMINENT
&& z["name"].Value<string>() == name).ToList();
}
var notices = await NoticesAsync();
notices.Should().ContainSingle("a new review gets one notice");
notices[0]["sockType"].Value<int>().Should().Be(REVIEW_SOCKTYPE, "the notice is the review's, so it opens the review");
//a change replaces the notice
a = await Util.GetAsync($"review/{reviewId}", token);
var review = (JObject)a.ObjectResponse["data"];
review["notes"] = "changed";
Util.ValidateDataReturnResponseOk(await Util.PutAsync("review", token, review.ToString()));
(await NoticesAsync()).Should().ContainSingle("a changed review still has one notice");
//deleting the review removes it
Util.ValidateHTTPStatusCode(await Util.DeleteAsync($"review/{reviewId}", token), 204);
reviewId = 0;
(await NoticesAsync()).Should().BeEmpty("a deleted review's notice must not go out");
}
finally
{
if (reviewId != 0)
await Util.DeleteAsync($"review/{reviewId}", token);
await Util.DeleteAsync($"notify-subscription/{subscriptionId}", token);
await Util.DeleteAsync($"gzcase/{caseId}", token);
}
}
private const int REVIEW_SOCKTYPE = 61, REVIEW_IMMINENT = 34, DELIVER_IN_APP = 1;
}//eoc
}//eons

View File

@@ -0,0 +1,79 @@
using Xunit;
using FluentAssertions;
using Newtonsoft.Json.Linq;
namespace sockeye_integration
{
/// <summary>
/// case 4738: the security advisory monitor's routes. The test's own asset is inactive, so it never changes what the
/// monitor watches; the matching rules themselves are unit tested in sockeye.tests
/// </summary>
public class SecurityAdvisories
{
[Fact]
public async Task AssetIsSavedWithPastedComponentsCleanedUpAndDeleted()
{
string token = await TestUser.GetTokenAsync();
var asset = new JObject
{
["id"] = 0,
["name"] = Util.Uniquify("case 4738 test asset"),
["platform"] = "ubuntu:noble",
["components"] = new JArray("openssl\r\nLinux\nopenssl\n\nsystemd"),
["active"] = false,
["alertAt"] = 4,
["digestAt"] = 3
};
ApiResponse a = await Util.PostAsync("security-advisory/asset", token, asset.ToString());
Util.ValidateDataReturnResponseOk(a);
long id = a.ObjectResponse["data"]["id"].Value<long>();
try
{
a = await Util.GetAsync("security-advisory/asset", token);
var saved = ((JArray)a.ObjectResponse["data"]).Single(z => z["id"].Value<long>() == id);
saved["components"].ToObject<List<string>>().Should().Equal("linux", "openssl", "systemd");
saved["active"].Value<bool>().Should().BeFalse();
}
finally
{
Util.ValidateHTTPStatusCode(await Util.DeleteAsync($"security-advisory/asset/{id}", token), 204);
}
}
[Fact]
public async Task InvalidAssetIsRefused()
{
string token = await TestUser.GetTokenAsync();
var asset = new JObject { ["id"] = 0, ["name"] = "x", ["platform"] = "windows", ["components"] = new JArray("a"), ["alertAt"] = 4, ["digestAt"] = 3 };
Util.ValidateHTTPStatusCode(await Util.PostAsync("security-advisory/asset", token, asset.ToString()), 400);
asset["platform"] = "postgresql";
asset["components"] = new JArray();
Util.ValidateHTTPStatusCode(await Util.PostAsync("security-advisory/asset", token, asset.ToString()), 400);
asset["components"] = new JArray("15");
asset["digestAt"] = 5;//digest above alert
Util.ValidateHTTPStatusCode(await Util.PostAsync("security-advisory/asset", token, asset.ToString()), 400);
}
[Fact]
public async Task StatusAndDryRunStateAreReadable()
{
string token = await TestUser.GetTokenAsync();
ApiResponse a = await Util.GetAsync("security-advisory/status", token);
Util.ValidateDataReturnResponseOk(a);
a.ObjectResponse["data"]["sources"].Type.Should().Be(JTokenType.Array);
a.ObjectResponse["data"]["advisories"].Type.Should().Be(JTokenType.Array);
a = await Util.GetAsync("security-advisory/dry-run", token);
Util.ValidateDataReturnResponseOk(a);
a.ObjectResponse["data"]["running"].Type.Should().Be(JTokenType.Boolean);
}
[Fact]
public async Task NewEventTypesAreOffered()
{
string token = await TestUser.GetTokenAsync();
ApiResponse a = await Util.GetAsync("enum-list/list/NotifyEventType", token);
var ids = ((JArray)a.ObjectResponse["data"]).Select(z => z["id"].Value<int>()).ToList();
ids.Should().Contain(new[] { 43, 44 });
}
}//eoc
}//eons

View File

@@ -0,0 +1,57 @@
using Xunit;
using FluentAssertions;
using Newtonsoft.Json.Linq;
namespace sockeye_integration
{
/// <summary>
/// case 4487: a trial request still waiting for the customer to confirm their email can be approved (making the trial
/// licence) or deleted. The request's confirm-your-email message and the approval notice go to example.com, which a
/// DEBUG build doesn't send (John, 2026-11-02: the two emails on every run were a nuisance; the send itself is shared code)
/// </summary>
public class TrialRequests
{
private const int AWAITING_EMAIL_VALIDATION = 1, APPROVED = 3;
[Fact]
public async Task RequestAwaitingEmailValidationCanBeApprovedAndDeleted()
{
string token = await TestUser.GetTokenAsync();
var request = new JObject
{
["dbId"] = Util.Uniquify("TEST4487").Replace(" ", "-"),
["companyName"] = "case 4487 integration test",
["contactName"] = "Integration test",
["email"] = "integration-test@example.com",
["emailConfirmCode"] = "set by the server",
["pGroup"] = 2,//RavenPerpetual
["tags"] = null//as the rvr route saves a request: no tag list, which made deleting it fail
};
ApiResponse a = await Util.PostAsync("trial-license-request", token, request.ToString());
Util.ValidateDataReturnResponseOk(a);
long requestId = a.ObjectResponse["data"]["id"].Value<long>();
long? licenseId = null;
try
{
a = await Util.GetAsync($"trial-license-request/{requestId}", token);
var saved = (JObject)a.ObjectResponse["data"];
saved["status"].Value<int>().Should().Be(AWAITING_EMAIL_VALIDATION);
//approve without the email being confirmed
saved["status"] = APPROVED;
a = await Util.PutAsync("trial-license-request", token, saved.ToString());
Util.ValidateDataReturnResponseOk(a);
a = await Util.GetAsync($"trial-license-request/{requestId}", token);
a.ObjectResponse["data"]["status"].Value<int>().Should().Be(APPROVED);
a.ObjectResponse["data"]["licenseId"].Type.Should().NotBe(JTokenType.Null, "approving makes the trial licence");
licenseId = a.ObjectResponse["data"]["licenseId"].Value<long>();
}
finally
{
Util.ValidateHTTPStatusCode(await Util.DeleteAsync($"trial-license-request/{requestId}", token), 204);
if (licenseId != null)
await Util.DeleteAsync($"license/{licenseId}", token);
}
}
}//eoc
}//eons

View File

@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol.Core" Version="2.3.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />

View File

@@ -0,0 +1,58 @@
{
"title": "CISA Catalog of Known Exploited Vulnerabilities",
"catalogVersion": "2026.10.30",
"dateReleased": "2026-10-30T17:02:45.3665Z",
"count": 3,
"vulnerabilities": [
{
"cveID": "CVE-2026-118527",
"vendorProject": "Ivanti",
"product": "Connect Secure",
"vulnerabilityName": "Ivanti Connect Secure Generation of Predictable Numbers or Identifiers Vulnerability",
"dateAdded": "2026-10-30",
"shortDescription": "Ivanti Connect Secure contains a generation of predictable numbers or identifiers vulnerability that allows a remote authenticated attacker to upload files with attacker-controlled content to a known location on disk. This vulnerability can be chained with CVE-2026-104935 and CVE-2026-111357.",
"requiredAction": "Apply mitigations in accordance with vendor instructions, ensuring compliance with CISA\u2019s BOD 26-04 Prioritizing Security Updates Based on Risk (see URL in Notes) guidance and CISA\u2019s \u201cForensics Triage Requirements\u201d (see URL in Notes). Follow applicable BOD 26-04 guidance for cloud services or discontinue use of the product if mitigations are unavailable. Stakeholders are responsible for evaluating each asset's internet exposure and ensuring adherence to BOD 26-04 patching guidelines.",
"dueDate": "2026-11-13",
"knownRansomwareCampaignUse": "Unknown",
"forensicTriage": "No",
"notes": "https://hub.ivanti.com/s/article/Security-Advisory-Ivanti-Connect-Secure-CVE-2026-104935?language=en_US ; ; BOD 26-04: https://www.cisa.gov/news-events/directives/bod-26-04-prioritizing-security-updates-based-risk ; Forensics Triage Requirements: https://www.cisa.gov/news-events/directives/bod-26-04-implementation-guidance-prioritizing-security-updates-based-risk ; https://nvd.nist.gov/vuln/detail/CVE-2026-118527",
"cwes": [
"CWE-340"
]
},
{
"cveID": "CVE-2026-111357",
"vendorProject": "Ivanti",
"product": "Connect Secure",
"vulnerabilityName": "Ivanti Connect Secure Missing Authentication for Critical Function Vulnerability",
"dateAdded": "2026-10-30",
"shortDescription": "Ivanti Connect Secure contains a missing authentication for critical function vulnerability that could allow a local authenticated attacker to escalate their privileges. This vulnerability can be chained with CVE-2026-104935 and CVE-2026-118527.",
"requiredAction": "Apply mitigations in accordance with vendor instructions, ensuring compliance with CISA\u2019s BOD 26-04 Prioritizing Security Updates Based on Risk (see URL in Notes) guidance and CISA\u2019s \u201cForensics Triage Requirements\u201d (see URL in Notes). Follow applicable BOD 26-04 guidance for cloud services or discontinue use of the product if mitigations are unavailable. Stakeholders are responsible for evaluating each asset's internet exposure and ensuring adherence to BOD 26-04 patching guidelines.",
"dueDate": "2026-11-02",
"knownRansomwareCampaignUse": "Unknown",
"forensicTriage": "Yes",
"notes": "https://hub.ivanti.com/s/article/Security-Advisory-Ivanti-Connect-Secure-CVE-2026-104935?language=en_US ; https://www.cisa.gov/news-events/alerts/2026/10/13/cisa-urges-patching-newly-exploited-vulnerability-ivanti-connect-secure ; BOD 26-04: https://www.cisa.gov/news-events/directives/bod-26-04-prioritizing-security-updates-based-risk ; Forensics Triage Requirements: https://www.cisa.gov/news-events/directives/bod-26-04-implementation-guidance-prioritizing-security-updates-based-risk ; https://nvd.nist.gov/vuln/detail/CVE-2026-111357",
"cwes": [
"CWE-306",
"CWE-862",
"CWE-345"
]
},
{
"cveID": "CVE-2026-97229",
"vendorProject": "GitLab",
"product": "Community Edition and Enterprise Edition",
"vulnerabilityName": "GitLab Community Edition and Enterprise Edition Integer Overflow or Wraparound Vulnerability",
"dateAdded": "2026-10-29",
"shortDescription": "GitLab Community Edition and Enterprise Edition contain a integer overflow or wraparound vulnerability that could allow an unauthenticated user to execute arbitrary code on the GitLab server via a specially crafted regular expression in a CI/CD configuration, due to insufficient regular expression validation.",
"requiredAction": "Apply mitigations in accordance with vendor instructions, ensuring compliance with CISA\u2019s BOD 26-04 Prioritizing Security Updates Based on Risk (see URL in Notes) guidance and CISA\u2019s \u201cForensics Triage Requirements\u201d (see URL in Notes). Follow applicable BOD 26-04 guidance for cloud services or discontinue use of the product if mitigations are unavailable. Stakeholders are responsible for evaluating each asset's internet exposure and ensuring adherence to BOD 26-04 patching guidelines.",
"dueDate": "2026-11-01",
"knownRansomwareCampaignUse": "Unknown",
"forensicTriage": "Yes",
"notes": "Other products can potentially be affected by the same underlying Ruby/Onigmo Integer Overflow issue in CI/CD Regular Expression Compiler. For more information please see: https://www.ruby-lang.org/en/news/2026/10/23/regexp-fixes-for-gitlab-cves ; https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-4-2-released/ ; BOD 26-04: https://www.cisa.gov/news-events/directives/bod-26-04-prioritizing-security-updates-based-risk ; Forensics Triage Requirements: https://www.cisa.gov/news-events/directives/bod-26-04-implementation-guidance-prioritizing-security-updates-based-risk ; https://nvd.nist.gov/vuln/detail/CVE-2026-97229",
"cwes": [
"CWE-190"
]
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,142 @@
using Sockeye.Biz;
using Sockeye.Models;
namespace sockeye.tests;
/// <summary>
/// case 4738: the security advisory monitor's parsing and rules, on saved copies of the real feeds (Fixtures,
/// fetched 2026-11-01: Ubuntu notices for resolute, the PostgreSQL security page, three CISA KEV entries)
/// </summary>
public class SecurityAdvisoryTests
{
private static string Fixture(string name) => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", name));
private static SecurityAsset Asset(string platform, params string[] components) => new SecurityAsset
{
Name = platform,
Platform = platform,
Components = components.ToList(),
Active = true,
AlertAt = AdvisorySeverity.High,
DigestAt = AdvisorySeverity.Medium
};
private static AdvisoryItem Item(AdvisorySeverity severity, string platform = "ubuntu:resolute", params string[] packages)
{
var i = new AdvisoryItem { Source = "test", SourceId = "T-1", Severity = severity };
i.Affected[platform] = packages.Length == 0 ? new List<string> { "openssl" } : packages.ToList();
return i;
}
[Fact]
public void UbuntuNoticesParseWithSourcePackagesPerRelease()
{
var items = UbuntuUsnSource.ParseNotices(Fixture("ubuntu-notices.json"), out int count);
Assert.Equal(8, count);
var dotnet = items.Single(z => z.SourceId == "USN-8958-1");
Assert.Equal(DateTimeKind.Utc, dotnet.Published.Value.Kind);
Assert.Equal(new[] { "dotnet10" }, dotnet.Affected["ubuntu:resolute"]);
Assert.Equal(new[] { "dotnet10", "dotnet8" }, dotnet.Affected["ubuntu:noble"].OrderBy(z => z));
Assert.Equal(3, dotnet.CveIds.Count);
Assert.Equal("https://ubuntu.com/security/notices/USN-8958-1", dotnet.Url);
Assert.Empty(items.Single(z => z.SourceId == "USN-8947-1").CveIds);
}
[Fact]
public void PostgresSecurityPageParses()
{
var items = PostgresSecuritySource.ParsePage(Fixture("postgresql-security.html"));
Assert.True(items.Count > 20, $"only {items.Count} rows parsed");
Assert.Equal(items.Count, items.Select(z => z.SourceId).Distinct().Count());
var dump = items.Single(z => z.SourceId == "CVE-2026-19385");
Assert.Contains("15", dump.Affected["postgresql"]);
Assert.Equal(AdvisorySeverity.High, dump.Severity);
Assert.Equal("CVSS 8.8", dump.RawSeverity);
Assert.Contains("pg_dump", dump.Title);
Assert.EndsWith("(client)", dump.Title);
Assert.Equal(AdvisorySeverity.Medium, items.Single(z => z.SourceId == "CVE-2026-18024").Severity);
}
[Fact]
public void KevParses()
{
var kev = CisaKev.Parse(Fixture("cisa-kev.json"));
Assert.Equal(3, kev.Count);
Assert.Equal(new DateTime(2026, 10, 30), kev["CVE-2026-118527"].Date);
Assert.True(kev.ContainsKey("cve-2026-97229"), "lookup ignores case");
}
[Theory]
[InlineData(AdvisorySeverity.Critical, AdvisoryOutcome.Alerted)]
[InlineData(AdvisorySeverity.High, AdvisoryOutcome.Alerted)]
[InlineData(AdvisorySeverity.Medium, AdvisoryOutcome.Digest)]
[InlineData(AdvisorySeverity.Unknown, AdvisoryOutcome.Digest)]
[InlineData(AdvisorySeverity.Low, AdvisoryOutcome.Ignored)]
[InlineData(AdvisorySeverity.Negligible, AdvisoryOutcome.Ignored)]
public void SeverityDecidesOutcome(AdvisorySeverity severity, AdvisoryOutcome expected)
{
var (outcome, matched) = SecurityAdvisoryPolicy.Evaluate(Item(severity), new[] { Asset("ubuntu:resolute", "openssl", "linux") });
Assert.Equal(expected, outcome);
Assert.Equal("ubuntu:resolute: openssl", matched);
}
[Fact]
public void KnownExploitedAlertsWhateverTheSeverity()
{
var item = Item(AdvisorySeverity.Low);
item.KnownExploited = true;
Assert.Equal(AdvisoryOutcome.Alerted, SecurityAdvisoryPolicy.Evaluate(item, new[] { Asset("ubuntu:resolute", "openssl") }).outcome);
}
[Fact]
public void UnwatchedPackageOrReleaseOrInactiveAssetDoesNotMatch()
{
var assets = new[] { Asset("ubuntu:resolute", "linux") };
Assert.Equal(AdvisoryOutcome.NoMatch, SecurityAdvisoryPolicy.Evaluate(Item(AdvisorySeverity.Critical), assets).outcome);
Assert.Equal(AdvisoryOutcome.NoMatch, SecurityAdvisoryPolicy.Evaluate(Item(AdvisorySeverity.Critical, "ubuntu:noble", "linux"), assets).outcome);
var inactive = Asset("ubuntu:resolute", "openssl");
inactive.Active = false;
Assert.Equal(AdvisoryOutcome.NoMatch, SecurityAdvisoryPolicy.Evaluate(Item(AdvisorySeverity.Critical), new[] { inactive }).outcome);
}
[Fact]
public void StrongestOutcomeOverAssetsAndAllMatchesListed()
{
var item = Item(AdvisorySeverity.Medium);
item.Affected["ubuntu:noble"] = new List<string> { "openssl" };
var strict = Asset("ubuntu:noble", "openssl");
strict.Name = "24.04 fleet";
strict.AlertAt = AdvisorySeverity.Medium;
var normal = Asset("ubuntu:resolute", "openssl");
normal.Name = "26.04 fleet";
var (outcome, matched) = SecurityAdvisoryPolicy.Evaluate(item, new[] { normal, strict });
Assert.Equal(AdvisoryOutcome.Alerted, outcome);
Assert.Equal("26.04 fleet: openssl; 24.04 fleet: openssl", matched);
}
[Fact]
public void PostgresMatchesOnMajorVersion()
{
var item = PostgresSecuritySource.ParsePage(Fixture("postgresql-security.html")).Single(z => z.SourceId == "CVE-2026-19385");
Assert.Equal(AdvisoryOutcome.Alerted, SecurityAdvisoryPolicy.Evaluate(item, new[] { Asset("postgresql", "15") }).outcome);
Assert.Equal(AdvisoryOutcome.NoMatch, SecurityAdvisoryPolicy.Evaluate(item, new[] { Asset("postgresql", "13") }).outcome);
}
[Theory]
[InlineData("9.8", AdvisorySeverity.Critical)]
[InlineData("7.0", AdvisorySeverity.High)]
[InlineData("6.9", AdvisorySeverity.Medium)]
[InlineData("3.1", AdvisorySeverity.Low)]
[InlineData("", AdvisorySeverity.Unknown)]
public void CvssBands(string score, AdvisorySeverity expected)
{
Assert.Equal(expected, SecurityAdvisoryPolicy.FromCvss(SecurityAdvisoryPolicy.ParseScore(score)));
}
[Fact]
public void PastedDpkgOutputIsCleanedUp()
{
var pasted = new[] { "openssl\r\nLinux\nsystemd\n\nopenssl\n", "nginx, glibc" };
Assert.Equal(new[] { "glibc", "linux", "nginx", "openssl", "systemd" }, SecurityAdvisoryPolicy.NormalizeComponents(pasted));
}
}

View File

@@ -14,6 +14,10 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>