draft: database layer refactor (#1054)

* feat(sshid) - sshid.io equivalent for termix (#919)

* feat(ssh-id): database schema, migrations and field encryption

Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys
stored plaintext for the unauthenticated resolver; CA private key registered
for per-user field encryption), with UNIQUE(user_id), an index on
ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations.

* feat(ssh-id): backend API — resolver, key management, CA and certificates

Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver
(+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store +
noindex headers on every resolver response including early 404s. Authenticated
management: claim/rename/delete handle, add/import/generate/enable/delete keys,
and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate
issuance. Audit logging on all mutations; UNIQUE races map to a precise 409.
Unit tests for key parsing and certificate signing (ssh-keygen-validated).

* feat(ssh-id): frontend panel, API client and i18n

SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL +
curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with
server trust command, and per-key certificate issuance. API client re-exported
through main-axios.ts; all strings i18n'd.

* style(ssh-id): align panel and resolver page with Termix theme

- Rebuild the SSH ID sidebar panel with the theme's square components
  (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards;
  use accent-brand and destructive tokens rather than raw red/green.
- Fix panel scrolling: move overflow to a block scroll container so the
  cards keep their natural height instead of being clipped.
- Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix
  dark theme: square corners, #18181b/#303032 palette, #f59145 accent,
  uppercase section labels.
- Tidy copy: 'Save To Credentials' label, drop the redundant generate intro,
  and correct the generate tooltip (the key is stored when saving to vault).

* feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922)

Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923)

Bumps the dev-patch-updates group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` |
| [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |


Updates `@codemirror/search` from 6.7.0 to 6.7.1
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.0 to 6.43.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite)

Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui)

Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3)

Updates `lint-staged` from 17.0.7 to 17.0.8
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `sharp` from 0.35.1 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump nanoid in the prod-patch-updates group (#925)

Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 5.1.11 to 5.1.15
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 5.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the major-updates group with 5 updates (#926)

Bumps the major-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` |
| [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` |


Updates `js-yaml` from 4.2.0 to 5.0.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 25.9.2 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

Updates `eslint` from 9.39.4 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(ssh): add HashiCorp Vault SSH signer authentication

* fix: small fixes to vault feature to align with Termix codebase

* chore: add view docs links for vault/termix id

* fix: file upload fails with 400 and missing schema migrations on upgrade (#929)

Two bugs introduced in v2.4.1:

1. uploadFileStream uses fileManagerApi.post() which triggers axios's
   transformRequest to JSON-serialize the FormData because the instance
   default Content-Type is application/json. Change to postForm() which
   sets Content-Type: multipart/form-data so the browser XHR sends the
   correct multipart body with boundary.

2. Two schema items added to schema.ts were not included in migrateSchema()
   in db/index.ts, causing 500 errors on existing installations upgrading
   from v2.4.0:
   - user_preferences.status_color_scheme (no such column)
   - dashboard_service_links table (no such table)

Fixes #928

Co-authored-by: sash <sash@fominykh.io>

* fix: support PuTTY PPK ssh keys (#930)

* fix: chunk large file manager uploads (#932)

* fix: route dashboard hosts by protocol (#934)

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

* Sync role credential shares for OIDC users (#938)

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

* Preserve file path case in file manager UI (#941)

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

* feat: open all sessions in a folder and terminal custom theme color support

* feat: cross host file manager clipboard and several small bug fixes

* feat: tailscale/wireguard support and added a new status state for when backend is checking status

* feat: grafana like server stats history, new alert system, ntfy/webhook support

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

* fix: alert ui incorrectly using termix css and fixed issue with alert system not loading

* chore: start database layer refactor

* docs: plan database layer refactor

* docs: audit database layer refactor phase zero

* chore: add database runtime adapter skeleton

* chore: add settings repository skeleton

* chore: add user session repository skeleton

* chore: add host credential repository skeleton

* chore: add field encryption boundary

* chore: migrate settings route slice

* chore: migrate user settings routes

* chore: migrate host metrics settings routes

* chore: migrate acme settings route

* chore: migrate terminal settings route

* chore: migrate tailscale settings read

* chore: migrate guacamole settings reads

* chore: migrate session timeout settings reads

* chore: migrate auth route settings reads

* chore: migrate host metrics settings reads

* chore: migrate startup settings reads

* chore: migrate user settings cleanup

* chore: migrate password reset settings

* chore: migrate oidc legacy settings read

* chore: migrate user route settings slice

* chore: migrate oidc state settings

* chore: migrate user login settings reads

* chore: migrate user crypto settings

* chore: consolidate startup settings defaults

* chore: consolidate database settings import export

* chore: migrate core session auth paths

* chore: migrate remaining session auth paths

* chore: migrate admin user routes

* chore: migrate user route admin checks

* chore: migrate user lifecycle routes

* chore: migrate auth user lookups

* chore: migrate oidc user routes

* chore: migrate api key repository paths

* docs: add database gray rollout guide

* chore: migrate trusted device paths

* chore: migrate user session route user lookups

* chore: add database repository rollout guard

* chore: expose repository rollout status

* chore: warn on repository rollout misconfiguration

* chore: migrate remaining user lookup helpers

* chore: migrate ssh user lookups

* chore: migrate user settings admin lookups

* chore: migrate acme ssl user lookups

* chore: migrate audit log admin checks

* chore: migrate oidc account user updates

* chore: migrate password reset user updates

* chore: migrate user deletion core records

* chore: migrate snippet audit user lookups

* chore: migrate ldap user sync paths

* chore: migrate totp user updates

* chore: migrate rbac user checks

* chore: migrate rbac role paths

* chore: migrate permission role lookups

* chore: migrate rbac access list reads

* chore: migrate shared rbac reads

* chore: migrate rbac access writes

* chore: migrate permission host access

* chore: migrate role host access lookup

* chore: migrate snippet access lookup

* chore: migrate shared credential access lookups

* chore: migrate host access cleanup writes

* chore: migrate host list access checks

* chore: migrate host access cleanup routes

* chore: migrate shared credential role lookups

* chore: migrate user role cleanup

* chore: migrate admin role sync

* chore: migrate ldap role sync

* chore: migrate user role assignment

* chore: migrate sso provider access

* chore: migrate audit log access

* chore: migrate user preference access

* chore: migrate open tab access

* chore: migrate dismissed alert access

* chore: migrate homepage layout access

* chore: migrate network topology access

* chore: migrate dashboard service link access

* chore: migrate command history access

* chore: migrate recent activity cleanup

* chore: migrate ssh credential usage access

* chore: migrate transfer recent access

* chore: migrate file manager bookmark access

* chore: migrate c2s tunnel preset access

* chore: migrate homepage item access

* chore: migrate session recording access

* chore: migrate tmux session tag access

* chore: migrate opkssh token access

* chore: migrate vault token access

* chore: migrate vault profile access

* chore: migrate host metrics preference access

* chore: migrate host health access

* chore: migrate host metrics history access

* chore: migrate alert persistence access

* chore: route alert host lookup through repository

* chore: migrate user data export reads

* chore: route host metrics stats sync through repository

* chore: migrate host folder persistence

* chore: migrate host resolution reads

* chore: route jump host resolution reads

* chore: route docker console jump host reads

* chore: route docker ssh resolution reads

* chore: route proxmox discovery resolution reads

* chore: route file manager activity host reads

* chore: route host metrics resolution reads

* chore: route ssh auth credential reads

* chore: route tunnel endpoint credential reads

* chore: route credential deployment resolution reads

* chore: route command history host flag reads

* chore: route snippet execution resolution reads

* chore: route terminal host resolution reads

* chore: route vault oidc host resolution reads

* chore: route wake on lan host reads

* chore: route internal host list reads

* chore: route host key verification persistence

* chore: route credential read paths

* chore: route credential host usage reads

* chore: route credential folder rename

* chore: route host owner access checks

* chore: route shared credential source reads

* chore: route user host credential cleanup

* chore: route credential delete reads

* chore: route credential update reads

* chore: route host credential reads

* chore: route host read paths

* chore: route host projection reads

* chore: route host list reads

* chore: route snippet read paths

* chore: route snippet folder writes

* chore: route snippet crud paths

* chore: route snippet bulk import

* chore: route rbac ownership reads

* chore: route user count reads

* chore: route cleanup snippets folders

* chore: route shared credential persistence

* chore: route dashboard activity

* chore: route guacamole host reads

* chore: route host bulk lookups

* chore: remove unlock-only simple db ops

* chore: route host autostart persistence

* chore: route ldap provisioning through users

* chore: route credential encrypted writes

* chore: route host encrypted writes

* chore: route bulk host encrypted writes

* chore: route termix id credentials

* chore: route termix id ca persistence

* chore: route termix identity persistence

* chore: route credential system migration

* chore: isolate user encryption migration storage

* chore: remove legacy simple db ops

* chore: isolate legacy sqlite migration copy

* chore: route database settings import export

* chore: route database host credential export

* chore: route database host credential import

* chore: route database file-manager import export

* chore: route database alert usage import export

* chore: route database user checks

* chore: isolate auth lazy migration storage

* chore: route explicit database saves

* chore: initialize database save boundary

* chore: route migration snapshot saves

* chore: isolate sqlite import constraints

* chore: route import sqlite boundary

* chore: route user encryption migration store

* chore: centralize current repository runtime

* chore: route more current repositories

* chore: route activity repository runtimes

* chore: route token repository runtimes

* chore: route health repository runtimes

* chore: route identity repository runtimes

* chore: route rbac repository runtime

* chore: centralize current sqlite runtime access

* chore: route user deletion key cleanup

* chore: route user deletion vault cleanup

* chore: route user deletion homepage cleanup

* chore: route user deletion health cleanup

* chore: route user deletion alert cleanup

* chore: route user deletion identity cleanup

* chore: add database layer preupgrade backup

* Fix database repository type errors

* fix: complete post-merge compile fixes for database refactor

Restore missing DatabaseSaveTrigger/getDb imports, session log format
fallback, OIDC provider resolution, guacamole recording insert, and
passwordFallbackOnly typing after merging current dev.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
This commit is contained in:
ZacharyZcR
2026-07-16 12:28:10 +08:00
committed by GitHub
parent ed6cda4ea4
commit 1e7c9ea53c
229 changed files with 24327 additions and 8382 deletions
+10 -57
View File
@@ -1,37 +1,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../database/db/index.js", () => ({
db: {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockResolvedValue(undefined),
}),
$client: {
prepare: vi.fn().mockReturnValue({
get: vi.fn().mockReturnValue({ count: 0 }),
run: vi.fn(),
}),
},
},
const createMock = vi.fn().mockResolvedValue(undefined);
vi.mock("../database/repositories/current-audit-log-repository.js", () => ({
createCurrentAuditLogRepository: vi.fn(() => ({
create: createMock,
})),
}));
import { logAudit, getRequestMeta } from "./audit-logger.js";
import { db } from "../database/db/index.js";
const mockDb = db as {
insert: ReturnType<typeof vi.fn>;
$client: { prepare: ReturnType<typeof vi.fn> };
};
describe("logAudit", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.insert.mockReturnValue({
values: vi.fn().mockResolvedValue(undefined),
});
mockDb.$client.prepare.mockReturnValue({
get: vi.fn().mockReturnValue({ count: 0 }),
run: vi.fn(),
});
createMock.mockResolvedValue(undefined);
});
it("inserts an audit log entry with all required fields", async () => {
@@ -49,9 +31,8 @@ describe("logAudit", () => {
await logAudit(params);
expect(mockDb.insert).toHaveBeenCalledOnce();
const valuesFn = mockDb.insert.mock.results[0].value.values;
expect(valuesFn).toHaveBeenCalledWith(
expect(createMock).toHaveBeenCalledOnce();
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
username: "alice",
@@ -65,9 +46,7 @@ describe("logAudit", () => {
});
it("does not throw when insert fails", async () => {
mockDb.insert.mockReturnValue({
values: vi.fn().mockRejectedValue(new Error("db error")),
});
createMock.mockRejectedValue(new Error("db error"));
await expect(
logAudit({
@@ -79,32 +58,6 @@ describe("logAudit", () => {
}),
).resolves.toBeUndefined();
});
it("triggers pruning when row count exceeds threshold", async () => {
const prepareMock = vi.fn();
const runMock = vi.fn();
prepareMock.mockImplementation((sql: string) => {
if (sql.includes("COUNT(*)")) {
return { get: () => ({ count: 10001 }) };
}
return { run: runMock };
});
mockDb.$client.prepare = prepareMock;
await logAudit({
userId: "u",
username: "u",
action: "x",
resourceType: "y",
success: true,
});
const deleteCalled = prepareMock.mock.calls.some((args: unknown[]) =>
String(args[0]).includes("DELETE"),
);
expect(deleteCalled).toBe(true);
expect(runMock).toHaveBeenCalledWith(1001);
});
});
describe("getRequestMeta", () => {
+2 -21
View File
@@ -1,9 +1,5 @@
import type { Request } from "express";
import { db } from "../database/db/index.js";
import { auditLogs } from "../database/db/schema.js";
const PRUNE_MAX = 10000;
const PRUNE_TARGET = 9000;
import { createCurrentAuditLogRepository } from "../database/repositories/current-audit-log-repository.js";
export interface AuditLogParams {
userId: string;
@@ -21,7 +17,7 @@ export interface AuditLogParams {
export async function logAudit(params: AuditLogParams): Promise<void> {
try {
await db.insert(auditLogs).values({
await createCurrentAuditLogRepository().create({
userId: params.userId,
username: params.username,
action: params.action,
@@ -34,21 +30,6 @@ export async function logAudit(params: AuditLogParams): Promise<void> {
success: params.success,
errorMessage: params.errorMessage ?? null,
});
const countResult = db.$client
.prepare("SELECT COUNT(*) as count FROM audit_logs")
.get() as { count: number };
if (countResult.count >= PRUNE_MAX) {
const deleteCount = countResult.count - PRUNE_TARGET;
db.$client
.prepare(
`DELETE FROM audit_logs WHERE id IN (
SELECT id FROM audit_logs ORDER BY timestamp ASC LIMIT ?
)`,
)
.run(deleteCount);
}
} catch {
// audit logging must never throw and break the caller
}
@@ -17,8 +17,10 @@ vi.mock("../database/db/index.js", async () => {
const { drizzle } = await import("drizzle-orm/better-sqlite3");
const sqlite = new Database(":memory:");
mocks.sqlite = sqlite;
const db = drizzle(sqlite);
return {
db: drizzle(sqlite),
db,
getDb: () => db,
saveMemoryDatabaseToFile: mocks.saveDatabase,
};
});
+97 -301
View File
@@ -3,14 +3,20 @@ import crypto from "crypto";
import { UserCrypto } from "./user-crypto.js";
import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js";
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
import { databaseLogger, authLogger } from "./logger.js";
import type { Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs";
import { db } from "../database/db/index.js";
import { sessions, trustedDevices, apiKeys } from "../database/db/schema.js";
import { eq, and, inArray, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import { and, eq, inArray } from "drizzle-orm";
import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js";
import { createCurrentSettingsRepository } from "../database/repositories/current-settings-repository.js";
import { createCurrentSessionRepository } from "../database/repositories/current-session-repository.js";
import { createCurrentUserRepository } from "../database/repositories/current-user-repository.js";
import { createCurrentApiKeyRepository } from "../database/repositories/current-api-key-repository.js";
import { createCurrentTrustedDeviceRepository } from "../database/repositories/current-trusted-device-repository.js";
interface AuthenticationResult {
success: boolean;
@@ -191,20 +197,7 @@ class AuthManager {
return;
}
const { getSqlite, saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
const sqlite = getSqlite();
const migrationResult = await DataCrypto.migrateUserSensitiveFields(
userId,
userDataKey,
sqlite,
);
if (migrationResult.migrated) {
await saveMemoryDatabaseToFile();
}
await DataCrypto.migrateCurrentUserSensitiveFields(userId, userDataKey);
try {
const { CredentialSystemEncryptionMigration } =
@@ -213,7 +206,9 @@ class AuthManager {
const credResult = await credMigration.migrateUserCredentials(userId);
if (credResult.migrated > 0) {
await saveMemoryDatabaseToFile();
await DatabaseSaveTrigger.forceSave(
"login_credential_migration_explicit_save",
);
}
} catch (error) {
databaseLogger.warn("Credential migration failed during login", {
@@ -353,10 +348,10 @@ class AuthManager {
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
const timeoutRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
.get() as { value: string } | undefined;
const defaultExpiry = `${timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24}h`;
const timeoutValue = await createCurrentSettingsRepository().get(
"session_timeout_hours",
);
const defaultExpiry = `${timeoutValue ? parseInt(timeoutValue, 10) || 24 : 24}h`;
let expiresIn = options.expiresIn;
if (!expiresIn && !options.pendingTOTP) {
@@ -389,7 +384,7 @@ class AuthManager {
const createdAt = now.toISOString();
try {
await db.insert(sessions).values({
await createCurrentSessionRepository().create({
id: sessionId,
userId,
jwtToken: token,
@@ -402,21 +397,6 @@ class AuthManager {
expiresAt,
lastActiveAt: createdAt,
});
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session creation",
saveError,
{
operation: "session_create_db_save_failed",
sessionId,
},
);
}
} catch (error) {
databaseLogger.error("Failed to create session", error, {
operation: "session_create_failed",
@@ -461,13 +441,11 @@ class AuthManager {
if (payload.sessionId) {
try {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, payload.sessionId))
.limit(1);
const sessionRecord = await createCurrentSessionRepository().findById(
payload.sessionId,
);
if (sessionRecords.length === 0) {
if (!sessionRecord) {
databaseLogger.warn("Session not found during JWT verification", {
operation: "jwt_verify_session_not_found",
sessionId: payload.sessionId,
@@ -478,7 +456,7 @@ class AuthManager {
await this.restoreDataKeyFromPayload(
payload,
sessionRecords[0].expiresAt,
sessionRecord.expiresAt,
);
} catch (dbError) {
databaseLogger.error(
@@ -509,17 +487,14 @@ class AuthManager {
userId: string,
sessionId: string,
): Promise<{ token: string; maxAge: number } | null> {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
const sessionRecord =
await createCurrentSessionRepository().findById(sessionId);
if (sessionRecords.length === 0 || sessionRecords[0].userId !== userId) {
if (!sessionRecord || sessionRecord.userId !== userId) {
return null;
}
const expiresAt = new Date(sessionRecords[0].expiresAt).getTime();
const expiresAt = new Date(sessionRecord.expiresAt).getTime();
const maxAge = expiresAt - Date.now();
if (!Number.isFinite(maxAge) || maxAge <= 0) {
return null;
@@ -532,28 +507,7 @@ class AuthManager {
expiresIn: Math.ceil(maxAge / 1000),
} as jwt.SignOptions);
await db
.update(sessions)
.set({
jwtToken: token,
lastActiveAt: new Date().toISOString(),
})
.where(eq(sessions.id, sessionId));
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session token refresh",
saveError,
{
operation: "session_token_refresh_db_save_failed",
sessionId,
},
);
}
await createCurrentSessionRepository().updateToken(sessionId, token);
return { token, maxAge };
}
@@ -573,22 +527,7 @@ class AuthManager {
sessionId,
});
await db.delete(sessions).where(eq(sessions.id, sessionId));
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session revocation",
saveError,
{
operation: "session_revoke_db_save_failed",
sessionId,
},
);
}
await createCurrentSessionRepository().revoke(sessionId);
return true;
} catch (error) {
@@ -605,10 +544,8 @@ class AuthManager {
exceptSessionId?: string,
): Promise<number> {
try {
const userSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
const sessionRepository = createCurrentSessionRepository();
const userSessions = await sessionRepository.listByUserId(userId);
const deletedCount = userSessions.filter(
(s) => !exceptSessionId || s.id !== exceptSessionId,
@@ -620,33 +557,7 @@ class AuthManager {
sessionCount: deletedCount,
});
if (exceptSessionId) {
await db
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
sql`${sessions.id} != ${exceptSessionId}`,
),
);
} else {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after revoking all user sessions",
saveError,
{
operation: "user_sessions_revoke_db_save_failed",
userId,
},
);
}
await sessionRepository.revokeAllForUser(userId, exceptSessionId);
return deletedCount;
} catch (error) {
@@ -673,6 +584,7 @@ class AuthManager {
if (ssoProviderId != null)
conditions.push(eq(sessions.ssoProviderId, ssoProviderId));
const db = getDb();
const matched = await db
.select()
.from(sessions)
@@ -716,10 +628,9 @@ class AuthManager {
async cleanupExpiredSessions(): Promise<number> {
try {
const expiredSessions = await db
.select()
.from(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`);
const sessionRepository = createCurrentSessionRepository();
const now = new Date();
const expiredSessions = await sessionRepository.listExpired(now);
const expiredCount = expiredSessions.length;
@@ -727,30 +638,11 @@ class AuthManager {
return 0;
}
await db
.delete(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`);
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after cleaning up expired sessions",
saveError,
{
operation: "sessions_cleanup_db_save_failed",
},
);
}
await sessionRepository.deleteExpired(now);
const affectedUsers = new Set(expiredSessions.map((s) => s.userId));
for (const userId of affectedUsers) {
const remainingSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
const remainingSessions = await sessionRepository.listByUserId(userId);
if (remainingSessions.length === 0) {
this.userCrypto.logoutUser(userId);
@@ -768,8 +660,7 @@ class AuthManager {
async getAllSessions(): Promise<Record<string, unknown>[]> {
try {
const allSessions = await db.select().from(sessions);
return allSessions;
return createCurrentSessionRepository().listAll();
} catch (error) {
databaseLogger.error("Failed to get all sessions", error, {
operation: "sessions_get_all_failed",
@@ -780,11 +671,7 @@ class AuthManager {
async getUserSessions(userId: string): Promise<Record<string, unknown>[]> {
try {
const userSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
return userSessions;
return createCurrentSessionRepository().listByUserId(userId);
} catch (error) {
databaseLogger.error("Failed to get user sessions", error, {
operation: "sessions_get_user_failed",
@@ -825,13 +712,10 @@ class AuthManager {
): Promise<void> {
try {
const tokenPrefix = token.substring(0, 12);
const apiKeyRepository = createCurrentApiKeyRepository();
const candidates = await db
.select()
.from(apiKeys)
.where(
and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)),
);
const candidates =
await apiKeyRepository.listActiveByTokenPrefix(tokenPrefix);
if (candidates.length === 0) {
res.status(401).json({ error: "Invalid API key" });
@@ -857,21 +741,17 @@ class AuthManager {
}
if (requireAdmin) {
const { users } = await import("../database/db/schema.js");
const userRows = await db
.select()
.from(users)
.where(eq(users.id, matchedKey.userId))
.limit(1);
if (!userRows[0]?.isAdmin) {
const user = await createCurrentUserRepository().findById(
matchedKey.userId,
);
if (!user?.isAdmin) {
res.status(403).json({ error: "Admin access required" });
return;
}
}
db.update(apiKeys)
.set({ lastUsedAt: new Date().toISOString() })
.where(eq(apiKeys.id, matchedKey.id))
apiKeyRepository
.updateLastUsedAt(matchedKey.id, new Date().toISOString())
.then(() => {})
.catch((err) => {
databaseLogger.warn("Failed to update API key lastUsedAt", {
@@ -898,13 +778,10 @@ class AuthManager {
!this.userCrypto.isUserUnlocked(matchedKey.userId)
) {
try {
const { users } = await import("../database/db/schema.js");
const oidcRows = await db
.select()
.from(users)
.where(eq(users.id, matchedKey.userId))
.limit(1);
if (oidcRows[0]?.isOidc) {
const keyUser = await createCurrentUserRepository().findById(
matchedKey.userId,
);
if (keyUser?.isOidc) {
await this.authenticateOIDCUser(matchedKey.userId);
}
} catch (err) {
@@ -965,13 +842,10 @@ class AuthManager {
if (payload.sessionId) {
try {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, payload.sessionId))
.limit(1);
const sessionRepository = createCurrentSessionRepository();
const session = await sessionRepository.findById(payload.sessionId);
if (sessionRecords.length === 0) {
if (!session) {
databaseLogger.warn("Session not found in middleware", {
operation: "middleware_session_not_found",
sessionId: payload.sessionId,
@@ -986,8 +860,6 @@ class AuthManager {
});
}
const session = sessionRecords[0];
const sessionExpiryTime = new Date(session.expiresAt).getTime();
const currentTime = Date.now();
const isExpired = sessionExpiryTime < currentTime;
@@ -1002,18 +874,12 @@ class AuthManager {
difference: currentTime - sessionExpiryTime,
});
db.delete(sessions)
.where(eq(sessions.id, payload.sessionId))
sessionRepository
.revoke(payload.sessionId)
.then(async () => {
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
const remainingSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, payload.userId));
const remainingSessions =
await sessionRepository.listByUserId(payload.userId);
if (remainingSessions.length === 0) {
this.userCrypto.logoutUser(payload.userId);
@@ -1049,9 +915,8 @@ class AuthManager {
});
}
db.update(sessions)
.set({ lastActiveAt: new Date().toISOString() })
.where(eq(sessions.id, payload.sessionId))
sessionRepository
.touch(payload.sessionId)
.then(() => {})
.catch((error) => {
databaseLogger.warn("Failed to update session lastActiveAt", {
@@ -1132,16 +997,11 @@ class AuthManager {
}
try {
const { db } = await import("../database/db/index.js");
const { users } = await import("../database/db/schema.js");
const { eq } = await import("drizzle-orm");
const user = await createCurrentUserRepository().findById(
payload.userId,
);
const user = await db
.select()
.from(users)
.where(eq(users.id, payload.userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
if (!user?.isAdmin) {
databaseLogger.warn(
"Non-admin user attempted to access admin endpoint",
{
@@ -1171,30 +1031,13 @@ class AuthManager {
}
async logoutUser(userId: string, sessionId?: string): Promise<void> {
const sessionRepository = createCurrentSessionRepository();
if (sessionId) {
try {
await db.delete(sessions).where(eq(sessions.id, sessionId));
await sessionRepository.revoke(sessionId);
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after logout",
saveError,
{
operation: "logout_db_save_failed",
userId,
sessionId,
},
);
}
const remainingSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
const remainingSessions = await sessionRepository.listByUserId(userId);
if (remainingSessions.length === 0) {
this.userCrypto.logoutUser(userId);
@@ -1210,15 +1053,7 @@ class AuthManager {
}
} else {
try {
await db.delete(sessions).where(eq(sessions.userId, userId));
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch {
// best effort
}
await sessionRepository.revokeAllForUser(userId);
} catch (error) {
databaseLogger.error("Failed to revoke all sessions on logout", error, {
operation: "session_revoke_all_failed",
@@ -1264,38 +1099,29 @@ class AuthManager {
deviceFingerprint: string,
): Promise<boolean> {
try {
const device = await db
.select()
.from(trustedDevices)
.where(
and(
eq(trustedDevices.userId, userId),
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
),
)
.limit(1);
const trustedDeviceRepository = createCurrentTrustedDeviceRepository();
const device = await trustedDeviceRepository.findByUserAndFingerprint(
userId,
deviceFingerprint,
);
if (!device || device.length === 0) {
if (!device) {
return false;
}
const now = new Date();
const expiresAt = new Date(device[0].expiresAt);
const expiresAt = new Date(device.expiresAt);
if (now > expiresAt) {
await this.removeTrustedDevice(userId, deviceFingerprint);
return false;
}
await db
.update(trustedDevices)
.set({ lastUsedAt: now.toISOString() })
.where(
and(
eq(trustedDevices.userId, userId),
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
),
);
await trustedDeviceRepository.touch(
userId,
deviceFingerprint,
now.toISOString(),
);
return true;
} catch (error) {
@@ -1313,56 +1139,26 @@ class AuthManager {
const now = new Date();
const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const existingDevice = await db
.select()
.from(trustedDevices)
.where(
and(
eq(trustedDevices.userId, userId),
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
),
)
.limit(1);
if (existingDevice && existingDevice.length > 0) {
await db
.update(trustedDevices)
.set({
expiresAt: expiresAt.toISOString(),
lastUsedAt: now.toISOString(),
})
.where(
and(
eq(trustedDevices.userId, userId),
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
),
);
} else {
await db.insert(trustedDevices).values({
id: nanoid(),
userId,
deviceFingerprint,
deviceType,
deviceInfo,
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
lastUsedAt: now.toISOString(),
});
}
await createCurrentTrustedDeviceRepository().upsert({
id: nanoid(),
userId,
deviceFingerprint,
deviceType,
deviceInfo,
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
lastUsedAt: now.toISOString(),
});
}
async removeTrustedDevice(
userId: string,
deviceFingerprint: string,
): Promise<void> {
await db
.delete(trustedDevices)
.where(
and(
eq(trustedDevices.userId, userId),
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
),
);
await createCurrentTrustedDeviceRepository().deleteByUserAndFingerprint(
userId,
deviceFingerprint,
);
}
}
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CredentialSystemEncryptionMigration } from "./credential-system-encryption-migration.js";
import { DataCrypto } from "./data-crypto.js";
import { FieldCrypto } from "./field-crypto.js";
import { SystemCrypto } from "./system-crypto.js";
const credentialRepository = {
listMissingSystemEncryptionByUserId: vi.fn(),
updateSystemEncryptionForUser: vi.fn(),
};
const sharedCredentialRepository = {
markNeedsReEncryptionByOriginalCredentialId: vi.fn(),
};
vi.mock("../database/repositories/current-credential-repository.js", () => ({
createCurrentCredentialRepository: vi.fn(() => credentialRepository),
}));
vi.mock(
"../database/repositories/current-shared-credential-repository.js",
() => ({
createCurrentSharedCredentialRepository: vi.fn(
() => sharedCredentialRepository,
),
}),
);
describe("CredentialSystemEncryptionMigration", () => {
beforeEach(() => {
vi.restoreAllMocks();
credentialRepository.listMissingSystemEncryptionByUserId.mockReset();
credentialRepository.updateSystemEncryptionForUser.mockReset();
sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId.mockReset();
});
it("migrates missing credential system-key copies through repositories", async () => {
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(
SystemCrypto.getInstance(),
"getCredentialSharingKey",
).mockResolvedValue(Buffer.from("system-key"));
vi.spyOn(FieldCrypto, "decryptField").mockImplementation(
(_value, _key, _recordId, fieldName) => `plain-${fieldName}`,
);
vi.spyOn(FieldCrypto, "encryptField").mockImplementation(
(value, _key, _recordId, fieldName) => `system-${fieldName}-${value}`,
);
credentialRepository.listMissingSystemEncryptionByUserId.mockResolvedValue([
{
id: 123,
userId: "user-1",
password: "encrypted-password",
key: "encrypted-key",
keyPassword: "encrypted-key-password",
},
]);
await expect(
new CredentialSystemEncryptionMigration().migrateUserCredentials(
"user-1",
),
).resolves.toEqual({ migrated: 1, failed: 0, skipped: 0 });
expect(
credentialRepository.listMissingSystemEncryptionByUserId,
).toHaveBeenCalledWith("user-1");
expect(
credentialRepository.updateSystemEncryptionForUser,
).toHaveBeenCalledWith(
"user-1",
123,
expect.objectContaining({
systemPassword: "system-password-plain-password",
systemKey: "system-key-plain-key",
systemKeyPassword: "system-key_password-plain-keyPassword",
}),
);
expect(
sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId,
).toHaveBeenCalledWith(123);
});
});
@@ -1,6 +1,5 @@
import { db } from "../database/db/index.js";
import { sshCredentials, sharedCredentials } from "../database/db/schema.js";
import { eq, and, or, isNull } from "drizzle-orm";
import { createCurrentCredentialRepository } from "../database/repositories/current-credential-repository.js";
import { createCurrentSharedCredentialRepository } from "../database/repositories/current-shared-credential-repository.js";
import { DataCrypto } from "./data-crypto.js";
import { SystemCrypto } from "./system-crypto.js";
import { FieldCrypto } from "./field-crypto.js";
@@ -20,20 +19,12 @@ export class CredentialSystemEncryptionMigration {
const systemCrypto = SystemCrypto.getInstance();
const CSKEK = await systemCrypto.getCredentialSharingKey();
const credentialRepository = createCurrentCredentialRepository();
const sharedCredentialRepository =
createCurrentSharedCredentialRepository();
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.userId, userId),
or(
isNull(sshCredentials.systemPassword),
isNull(sshCredentials.systemKey),
isNull(sshCredentials.systemKeyPassword),
),
),
);
const credentials =
await credentialRepository.listMissingSystemEncryptionByUserId(userId);
let migrated = 0;
let failed = 0;
@@ -95,20 +86,20 @@ export class CredentialSystemEncryptionMigration {
)
: null;
await db
.update(sshCredentials)
.set({
await credentialRepository.updateSystemEncryptionForUser(
userId,
cred.id,
{
systemPassword,
systemKey,
systemKeyPassword,
updatedAt: new Date().toISOString(),
})
.where(eq(sshCredentials.id, cred.id));
},
);
await db
.update(sharedCredentials)
.set({ needsReEncryption: true })
.where(eq(sharedCredentials.originalCredentialId, cred.id));
await sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId(
cred.id,
);
migrated++;
} catch (error) {
+94 -92
View File
@@ -1,20 +1,15 @@
import { FieldCrypto } from "./field-crypto.js";
import { LazyFieldEncryption } from "./lazy-field-encryption.js";
import { UserCrypto } from "./user-crypto.js";
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
import { databaseLogger } from "./logger.js";
interface DatabaseInstance {
prepare: (sql: string) => {
all: (param?: unknown) => DatabaseRecord[];
get: (param?: unknown) => DatabaseRecord;
run: (...params: unknown[]) => unknown;
};
}
interface DatabaseRecord {
id: number | string;
[key: string]: unknown;
}
import {
createCurrentUserEncryptionMigrationStore,
RawSqliteUserEncryptionMigrationStore,
type LegacyDatabaseInstance,
type UserEncryptionMigrationStore,
type UserEncryptionMigrationRecord,
} from "./user-encryption-migration-store.js";
class DataCrypto {
private static userCrypto: UserCrypto;
@@ -30,14 +25,14 @@ class DataCrypto {
userDataKey: Buffer,
): T {
const encryptedRecord: Record<string, unknown> = { ...record };
const recordId = record.id || "temp-" + Date.now();
const recordId = String(record.id || "temp-" + Date.now());
for (const [fieldName, value] of Object.entries(record)) {
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
encryptedRecord[fieldName] = FieldCrypto.encryptField(
value as string,
userDataKey,
recordId as string,
recordId,
fieldName,
);
}
@@ -55,14 +50,14 @@ class DataCrypto {
if (!record) return record;
const decryptedRecord: Record<string, unknown> = { ...record };
const recordId = record.id;
const recordId = String(record.id);
for (const [fieldName, value] of Object.entries(record)) {
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
value as string,
userDataKey,
recordId as string,
recordId,
fieldName,
);
}
@@ -86,7 +81,34 @@ class DataCrypto {
static async migrateUserSensitiveFields(
userId: string,
userDataKey: Buffer,
db: DatabaseInstance,
db: LegacyDatabaseInstance,
): Promise<{
migrated: boolean;
migratedTables: string[];
migratedFieldsCount: number;
}> {
try {
const store = new RawSqliteUserEncryptionMigrationStore(db);
return await this.migrateUserSensitiveFieldsInStore(
userId,
userDataKey,
store,
);
} catch (error) {
databaseLogger.error("User sensitive fields migration failed", error, {
operation: "user_sensitive_migration_failed",
userId,
error: error instanceof Error ? error.message : "Unknown error",
});
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
}
}
private static async migrateUserSensitiveFieldsInStore(
userId: string,
userDataKey: Buffer,
store: UserEncryptionMigrationStore,
): Promise<{
migrated: boolean;
migratedTables: string[];
@@ -101,16 +123,14 @@ class DataCrypto {
await LazyFieldEncryption.checkUserNeedsMigration(
userId,
userDataKey,
db,
store,
);
if (!needsMigration) {
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
}
const sshDataRecords = db
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
.all(userId) as DatabaseRecord[];
const sshDataRecords = store.listHostRecords(userId);
for (const record of sshDataRecords) {
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data");
@@ -123,22 +143,7 @@ class DataCrypto {
);
if (needsUpdate) {
const updateQuery = `
UPDATE ssh_data
SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.password || null,
updatedRecord.key || null,
updatedRecord.key_password || null,
updatedRecord.key_type || null,
updatedRecord.autostart_password || null,
updatedRecord.autostart_key || null,
updatedRecord.autostart_key_password || null,
updatedRecord.sudo_password || null,
record.id,
);
store.updateHostSensitiveFields(record.id, updatedRecord);
migratedFieldsCount += migratedFields.length;
if (!migratedTables.includes("ssh_data")) {
@@ -148,9 +153,7 @@ class DataCrypto {
}
}
const sshCredentialsRecords = db
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
.all(userId) as DatabaseRecord[];
const sshCredentialsRecords = store.listCredentialRecords(userId);
for (const record of sshCredentialsRecords) {
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials");
@@ -163,20 +166,7 @@ class DataCrypto {
);
if (needsUpdate) {
const updateQuery = `
UPDATE ssh_credentials
SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, key_type = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.password || null,
updatedRecord.key || null,
updatedRecord.key_password || null,
updatedRecord.private_key || null,
updatedRecord.public_key || null,
updatedRecord.key_type || null,
record.id,
);
store.updateCredentialSensitiveFields(record.id, updatedRecord);
migratedFieldsCount += migratedFields.length;
if (!migratedTables.includes("ssh_credentials")) {
@@ -186,9 +176,7 @@ class DataCrypto {
}
}
const userRecord = db
.prepare("SELECT * FROM users WHERE id = ?")
.get(userId) as DatabaseRecord | undefined;
const userRecord = store.getUserRecord(userId);
if (userRecord) {
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("users");
@@ -201,18 +189,7 @@ class DataCrypto {
);
if (needsUpdate) {
const updateQuery = `
UPDATE users
SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ?
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.totp_secret || null,
updatedRecord.totp_backup_codes || null,
updatedRecord.client_secret || null,
updatedRecord.oidc_identifier || null,
userId,
);
store.updateUserSensitiveFields(userId, updatedRecord);
migratedFieldsCount += migratedFields.length;
if (!migratedTables.includes("users")) {
@@ -234,6 +211,29 @@ class DataCrypto {
}
}
static async migrateCurrentUserSensitiveFields(
userId: string,
userDataKey: Buffer,
): Promise<{
migrated: boolean;
migratedTables: string[];
migratedFieldsCount: number;
}> {
const result = await this.migrateUserSensitiveFieldsInStore(
userId,
userDataKey,
await createCurrentUserEncryptionMigrationStore(),
);
if (result.migrated) {
await DatabaseSaveTrigger.forceSave(
"user_sensitive_migration_explicit_save",
);
}
return result;
}
static getUserDataKey(userId: string): Buffer | null {
return this.userCrypto.getUserDataKey(userId);
}
@@ -241,7 +241,7 @@ class DataCrypto {
static async reencryptUserDataAfterPasswordReset(
userId: string,
newUserDataKey: Buffer,
db: DatabaseInstance,
db: LegacyDatabaseInstance,
): Promise<{
success: boolean;
reencryptedTables: string[];
@@ -256,7 +256,14 @@ class DataCrypto {
};
try {
const tablesToReencrypt = [
const store = new RawSqliteUserEncryptionMigrationStore(db);
type PasswordResetTable = Parameters<
UserEncryptionMigrationStore["updatePasswordResetFields"]
>[0];
const tablesToReencrypt: Array<{
table: PasswordResetTable;
fields: string[];
}> = [
{
table: "ssh_data",
fields: [
@@ -292,17 +299,19 @@ class DataCrypto {
for (const { table, fields } of tablesToReencrypt) {
try {
const selectQuery =
table === "users"
? `SELECT * FROM ${table} WHERE id = ?`
: `SELECT * FROM ${table} WHERE user_id = ?`;
const records = db
.prepare(selectQuery)
.all(userId) as DatabaseRecord[];
const records =
table === "ssh_data"
? store.listHostRecords(userId)
: table === "ssh_credentials"
? store.listCredentialRecords(userId)
: [store.getUserRecord(userId)].filter(
(record): record is UserEncryptionMigrationRecord =>
Boolean(record),
);
for (const record of records) {
const recordId = record.id.toString();
const updatedRecord: DatabaseRecord = { ...record };
const updatedRecord: UserEncryptionMigrationRecord = { ...record };
let needsUpdate = false;
for (const fieldName of fields) {
@@ -350,19 +359,12 @@ class DataCrypto {
(field) => updatedRecord[field] !== record[field],
);
if (updateFields.length > 0) {
const setClause = updateFields
.map((f) => `${f} = ?`)
.join(", ");
const updateQuery =
table === "users"
? `UPDATE ${table} SET ${setClause} WHERE id = ?`
: `UPDATE ${table} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
const updateValues = updateFields.map(
(field) => updatedRecord[field],
store.updatePasswordResetFields(
table,
record.id,
updateFields,
updatedRecord,
);
updateValues.push(record.id);
db.prepare(updateQuery).run(...updateValues);
if (!result.reencryptedTables.includes(table)) {
result.reencryptedTables.push(table);
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, it } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import {
DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER,
DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX,
DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV,
ensureDatabaseLayerPreupgradeBackup,
} from "./database-layer-preupgrade-backup.js";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-prebackup-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("ensureDatabaseLayerPreupgradeBackup", () => {
it("copies existing database files and writes a marker", () => {
const dataDir = makeTempDir();
const encryptedDbPath = path.join(dataDir, "db.sqlite.encrypted");
const metadataPath = `${encryptedDbPath}.meta`;
const envPath = path.join(dataDir, ".env");
fs.writeFileSync(encryptedDbPath, "encrypted-db");
fs.writeFileSync(metadataPath, '{"version":"v2"}');
fs.writeFileSync(envPath, "DATABASE_KEY=test-key");
const result = ensureDatabaseLayerPreupgradeBackup({
dataDir,
version: "2.5.0-test",
now: new Date("2026-06-27T12:00:00.000Z"),
env: {
DATABASE_LAYER_REPOSITORY_ROLLOUT: "settings,users",
} as NodeJS.ProcessEnv,
});
expect(result.status).toBe("created");
expect(result.backupDir).toContain(DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX);
expect(
fs.existsSync(
path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER),
),
).toBe(true);
expect(
fs.readFileSync(
path.join(result.backupDir!, "db.sqlite.encrypted"),
"utf8",
),
).toBe("encrypted-db");
expect(
fs.readFileSync(
path.join(result.backupDir!, "db.sqlite.encrypted.meta"),
"utf8",
),
).toBe('{"version":"v2"}');
expect(fs.readFileSync(path.join(result.backupDir!, ".env"), "utf8")).toBe(
"DATABASE_KEY=test-key",
);
const manifest = JSON.parse(
fs.readFileSync(path.join(result.backupDir!, "manifest.json"), "utf8"),
) as { sourceVersion: string; rollout: { mode: string } };
expect(manifest.sourceVersion).toBe("2.5.0-test");
expect(manifest.rollout.mode).toBe("partial");
});
it("skips when the marker already exists", () => {
const dataDir = makeTempDir();
fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db");
fs.writeFileSync(
path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER),
"{}",
);
const result = ensureDatabaseLayerPreupgradeBackup({
dataDir,
env: {} as NodeJS.ProcessEnv,
});
expect(result).toMatchObject({
status: "skipped",
reason: "marker_exists",
});
expect(fs.existsSync(path.join(dataDir, "backups"))).toBe(false);
});
it("skips new installs without a database file", () => {
const dataDir = makeTempDir();
const result = ensureDatabaseLayerPreupgradeBackup({
dataDir,
env: {} as NodeJS.ProcessEnv,
});
expect(result).toMatchObject({
status: "skipped",
reason: "no_database_file",
});
expect(
fs.existsSync(
path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER),
),
).toBe(false);
});
it("honors the explicit skip environment variable", () => {
const dataDir = makeTempDir();
fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db");
const result = ensureDatabaseLayerPreupgradeBackup({
dataDir,
env: {
[DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV]: "1",
} as NodeJS.ProcessEnv,
});
expect(result).toMatchObject({
status: "skipped",
reason: "skip_env",
});
expect(fs.existsSync(path.join(dataDir, "backups"))).toBe(false);
});
it("fails closed when the backup cannot be written", () => {
const dataDir = makeTempDir();
fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db");
fs.writeFileSync(path.join(dataDir, "backups"), "not-a-directory");
expect(() =>
ensureDatabaseLayerPreupgradeBackup({
dataDir,
env: {} as NodeJS.ProcessEnv,
}),
).toThrow("Failed to create database layer pre-upgrade backup");
});
});
@@ -0,0 +1,201 @@
import fs from "fs";
import path from "path";
import { databaseLogger } from "./logger.js";
import { getRepositoryRolloutStatus } from "../database/repositories/repository-rollout.js";
export const DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER =
".database-layer-preupgrade-backup.json";
export const DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX =
"pre-database-layer-refactor";
export const DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV =
"DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP";
export const DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP_ENV =
"DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP";
interface PreupgradeBackupOptions {
dataDir?: string;
version?: string;
now?: Date;
env?: NodeJS.ProcessEnv;
}
interface BackupFileEntry {
source: string;
backup: string;
size: number;
}
export interface PreupgradeBackupManifest {
reason: "pre-database-layer-refactor";
createdAt: string;
sourceVersion: string;
dataDir: string;
backupDir: string;
rollout: ReturnType<typeof getRepositoryRolloutStatus>;
files: BackupFileEntry[];
}
export interface PreupgradeBackupResult {
status: "created" | "skipped";
reason: "backup_created" | "skip_env" | "marker_exists" | "no_database_file";
backupDir?: string;
markerPath: string;
}
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
function timestampForPath(now: Date): string {
return now.toISOString().replace(/[:.]/g, "-");
}
function parseKeepCount(env: NodeJS.ProcessEnv): number {
const raw = env[DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP_ENV];
if (!raw) return 3;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 1) return 3;
return parsed;
}
function copyIfExists(
source: string,
backupDir: string,
): BackupFileEntry | null {
if (!fs.existsSync(source)) return null;
const backup = path.join(backupDir, path.basename(source));
fs.copyFileSync(source, backup);
return {
source,
backup,
size: fs.statSync(backup).size,
};
}
function cleanupOldBackups(backupsRoot: string, keepCount: number): void {
if (!fs.existsSync(backupsRoot)) return;
const entries = fs
.readdirSync(backupsRoot, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith(`${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-`),
)
.map((entry) => {
const fullPath = path.join(backupsRoot, entry.name);
return {
fullPath,
mtimeMs: fs.statSync(fullPath).mtimeMs,
};
})
.sort((a, b) => b.mtimeMs - a.mtimeMs);
for (const entry of entries.slice(keepCount)) {
fs.rmSync(entry.fullPath, { recursive: true, force: true });
}
}
export function ensureDatabaseLayerPreupgradeBackup(
options: PreupgradeBackupOptions = {},
): PreupgradeBackupResult {
const env = options.env ?? process.env;
const dataDir = path.resolve(options.dataDir ?? env.DATA_DIR ?? "./db/data");
const markerPath = path.join(
dataDir,
DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER,
);
if (
TRUE_VALUES.has(
env[DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV]?.trim().toLowerCase() ??
"",
)
) {
databaseLogger.warn("Database layer pre-upgrade backup skipped by env", {
operation: "database_layer_preupgrade_backup_skipped",
envKey: DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV,
});
return { status: "skipped", reason: "skip_env", markerPath };
}
if (fs.existsSync(markerPath)) {
return { status: "skipped", reason: "marker_exists", markerPath };
}
const encryptedDbPath = path.join(dataDir, "db.sqlite.encrypted");
const encryptedMetadataPath = `${encryptedDbPath}.meta`;
const plaintextDbPath = path.join(dataDir, "db.sqlite");
const envPath = path.join(dataDir, ".env");
const databaseFiles = [
encryptedDbPath,
encryptedMetadataPath,
plaintextDbPath,
].filter((file) => fs.existsSync(file));
if (databaseFiles.length === 0) {
return { status: "skipped", reason: "no_database_file", markerPath };
}
const backupsRoot = path.join(dataDir, "backups");
const backupDir = path.join(
backupsRoot,
`${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-${timestampForPath(
options.now ?? new Date(),
)}`,
);
try {
fs.mkdirSync(backupDir, { recursive: true });
const files = [...databaseFiles, envPath]
.map((source) => copyIfExists(source, backupDir))
.filter((entry): entry is BackupFileEntry => entry !== null);
const manifest: PreupgradeBackupManifest = {
reason: "pre-database-layer-refactor",
createdAt: (options.now ?? new Date()).toISOString(),
sourceVersion: options.version ?? env.VERSION ?? "unknown",
dataDir,
backupDir,
rollout: getRepositoryRolloutStatus(env),
files,
};
fs.writeFileSync(
path.join(backupDir, "manifest.json"),
JSON.stringify(manifest, null, 2),
);
fs.writeFileSync(markerPath, JSON.stringify(manifest, null, 2));
cleanupOldBackups(backupsRoot, parseKeepCount(env));
databaseLogger.info("Database layer pre-upgrade backup created", {
operation: "database_layer_preupgrade_backup_created",
backupDir,
files: files.map((file) => path.basename(file.backup)),
});
return {
status: "created",
reason: "backup_created",
backupDir,
markerPath,
};
} catch (error) {
databaseLogger.error(
"Failed to create database layer pre-upgrade backup",
error,
{
operation: "database_layer_preupgrade_backup_failed",
dataDir,
backupDir,
},
);
throw new Error(
`Failed to create database layer pre-upgrade backup. Set ${DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV}=1 only if you already have a verified external backup.`,
{ cause: error },
);
}
}
+39 -188
View File
@@ -1,8 +1,8 @@
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
import { databaseLogger } from "./logger.js";
import { DatabaseFileEncryption } from "./database-file-encryption.js";
import { LegacySqliteDatabaseCopyStore } from "./legacy-sqlite-database-copy-store.js";
export interface MigrationResult {
success: boolean;
@@ -124,80 +124,6 @@ export class DatabaseMigration {
}
}
private async verifyMigration(
originalDb: Database.Database,
memoryDb: Database.Database,
): Promise<boolean> {
try {
memoryDb.exec("PRAGMA foreign_keys = OFF");
const originalTables = originalDb
.prepare(
`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`,
)
.all() as { name: string }[];
const memoryTables = memoryDb
.prepare(
`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`,
)
.all() as { name: string }[];
if (originalTables.length !== memoryTables.length) {
databaseLogger.error(
"Table count mismatch during migration verification",
null,
{
operation: "migration_verify_failed",
originalCount: originalTables.length,
memoryCount: memoryTables.length,
},
);
return false;
}
for (const table of originalTables) {
const originalCount = originalDb
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
.get() as { count: number };
const memoryCount = memoryDb
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
.get() as { count: number };
if (originalCount.count !== memoryCount.count) {
databaseLogger.error(
"Row count mismatch for table during migration verification",
null,
{
operation: "migration_verify_table_failed",
table: table.name,
originalRows: originalCount.count,
memoryRows: memoryCount.count,
},
);
return false;
}
}
memoryDb.exec("PRAGMA foreign_keys = ON");
return true;
} catch (error) {
databaseLogger.error("Migration verification failed", error, {
operation: "migration_verify_error",
});
return false;
}
}
async migrateDatabase(): Promise<MigrationResult> {
const startTime = Date.now();
let backupPath: string | undefined;
@@ -207,121 +133,46 @@ export class DatabaseMigration {
try {
backupPath = this.createBackup();
const originalDb = new Database(this.unencryptedDbPath, {
readonly: true,
const copyResult =
new LegacySqliteDatabaseCopyStore().copyDatabaseToMemoryBuffer(
this.unencryptedDbPath,
);
migratedTables = copyResult.migratedTables;
migratedRows = copyResult.migratedRows;
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
copyResult.buffer,
this.encryptedDbPath,
);
if (
!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)
) {
throw new Error("Encrypted database file verification failed");
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`;
fs.renameSync(this.unencryptedDbPath, migratedPath);
databaseLogger.success("Database migration completed successfully", {
operation: "migration_complete",
migratedTables,
migratedRows,
duration: Date.now() - startTime,
backupPath,
migratedPath,
encryptedDbPath: this.encryptedDbPath,
});
const memoryDb = new Database(":memory:");
try {
const tables = originalDb
.prepare(
`
SELECT name, sql FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
`,
)
.all() as { name: string; sql: string }[];
for (const table of tables) {
memoryDb.exec(table.sql);
migratedTables++;
}
memoryDb.exec("PRAGMA foreign_keys = OFF");
for (const table of tables) {
const rows = originalDb
.prepare(`SELECT * FROM ${table.name}`)
.all() as Record<string, unknown>[];
if (rows.length > 0) {
const columns = Object.keys(rows[0]);
const placeholders = columns.map(() => "?").join(", ");
const insertStmt = memoryDb.prepare(
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`,
);
const insertTransaction = memoryDb.transaction(
(dataRows: Record<string, unknown>[]) => {
for (const row of dataRows) {
const values = columns.map((col) => row[col]);
insertStmt.run(values);
}
},
);
insertTransaction(rows);
migratedRows += rows.length;
}
}
memoryDb.exec("PRAGMA foreign_keys = ON");
const fkCheckResult = memoryDb
.prepare("PRAGMA foreign_key_check")
.all();
if (fkCheckResult.length > 0) {
databaseLogger.error(
"Foreign key constraints violations detected after migration",
null,
{
operation: "migration_fk_check_failed",
violations: fkCheckResult,
},
);
throw new Error(
`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`,
);
}
const verificationPassed = await this.verifyMigration(
originalDb,
memoryDb,
);
if (!verificationPassed) {
throw new Error("Migration integrity verification failed");
}
const buffer = memoryDb.serialize();
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
buffer,
this.encryptedDbPath,
);
if (
!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)
) {
throw new Error("Encrypted database file verification failed");
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`;
fs.renameSync(this.unencryptedDbPath, migratedPath);
databaseLogger.success("Database migration completed successfully", {
operation: "migration_complete",
migratedTables,
migratedRows,
duration: Date.now() - startTime,
backupPath,
migratedPath,
encryptedDbPath: this.encryptedDbPath,
});
return {
success: true,
migratedTables,
migratedRows,
backupPath,
duration: Date.now() - startTime,
};
} finally {
originalDb.close();
memoryDb.close();
}
return {
success: true,
migratedTables,
migratedRows,
backupPath,
duration: Date.now() - startTime,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
describe("DatabaseSaveTrigger", () => {
afterEach(() => {
vi.useRealTimers();
DatabaseSaveTrigger.cleanup();
});
it("force saves through the initialized save function", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
await DatabaseSaveTrigger.forceSave("test_force_save");
expect(save).toHaveBeenCalledTimes(1);
expect(DatabaseSaveTrigger.getStatus()).toMatchObject({
initialized: true,
pendingSave: false,
hasPendingTimeout: false,
});
});
it("debounces dirty saves and marks the database clean after saving", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
await DatabaseSaveTrigger.triggerSave("first");
await DatabaseSaveTrigger.triggerSave("second");
expect(DatabaseSaveTrigger.isDirty).toBe(true);
expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
expect(save).toHaveBeenCalledTimes(1);
expect(DatabaseSaveTrigger.isDirty).toBe(false);
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
});
});
+9 -22
View File
@@ -1,13 +1,6 @@
import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
interface DatabaseInstance {
prepare: (sql: string) => {
all: (param?: unknown) => unknown[];
get: (param?: unknown) => unknown;
run: (...params: unknown[]) => unknown;
};
}
import type { UserEncryptionMigrationStore } from "./user-encryption-migration-store.js";
export class LazyFieldEncryption {
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
@@ -367,7 +360,7 @@ export class LazyFieldEncryption {
static async checkUserNeedsMigration(
userId: string,
userKEK: Buffer,
db: DatabaseInstance,
store: UserEncryptionMigrationStore,
): Promise<{
needsMigration: boolean;
plaintextFields: Array<{
@@ -384,11 +377,7 @@ export class LazyFieldEncryption {
let needsMigration = false;
try {
const sshHosts = db
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
.all(userId) as Array<
Record<string, unknown> & { id: string | number }
>;
const sshHosts = store.listHostRecords(userId);
for (const host of sshHosts) {
const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data");
const hostPlaintextFields: string[] = [];
@@ -418,11 +407,7 @@ export class LazyFieldEncryption {
}
}
const sshCredentials = db
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
.all(userId) as Array<
Record<string, unknown> & { id: string | number }
>;
const sshCredentials = store.listCredentialRecords(userId);
for (const credential of sshCredentials) {
const sensitiveFields =
this.getSensitiveFieldsForTable("ssh_credentials");
@@ -453,16 +438,18 @@ export class LazyFieldEncryption {
}
}
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
const user = store.getUserRecord(userId);
if (user) {
const sensitiveFields = this.getSensitiveFieldsForTable("users");
const userPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
const column = this.propertyToColumn(field);
const value = user[column];
if (
user[column] &&
this.fieldNeedsMigration(user[column], userKEK, userId, field)
typeof value === "string" &&
value &&
this.fieldNeedsMigration(value, userKEK, userId, field)
) {
userPlaintextFields.push(field);
needsMigration = true;
@@ -0,0 +1,71 @@
import Database from "better-sqlite3";
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, describe, expect, it } from "vitest";
import { LegacySqliteDatabaseCopyStore } from "./legacy-sqlite-database-copy-store.js";
describe("LegacySqliteDatabaseCopyStore", () => {
let tempDir: string | null = null;
afterEach(() => {
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
function createLegacyDatabase(): string {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-legacy-copy-"));
const dbPath = path.join(tempDir, "db.sqlite");
const db = new Database(dbPath);
try {
db.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL
);
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
INSERT INTO users (id, username) VALUES ('user-1', 'alice');
INSERT INTO ssh_data (user_id, name) VALUES ('user-1', 'host-1');
`);
} finally {
db.close();
}
return dbPath;
}
it("copies legacy SQLite schema and rows into a serialized memory buffer", () => {
const dbPath = createLegacyDatabase();
const result =
new LegacySqliteDatabaseCopyStore().copyDatabaseToMemoryBuffer(dbPath);
expect(result.migratedTables).toBe(2);
expect(result.migratedRows).toBe(2);
expect(result.buffer.length).toBeGreaterThan(0);
const copied = new Database(result.buffer);
try {
expect(
copied.prepare("SELECT username FROM users WHERE id = ?").get("user-1"),
).toEqual({ username: "alice" });
expect(
copied
.prepare("SELECT name FROM ssh_data WHERE user_id = ?")
.get("user-1"),
).toEqual({ name: "host-1" });
expect(copied.prepare("PRAGMA foreign_key_check").all()).toEqual([]);
} finally {
copied.close();
}
});
});
@@ -0,0 +1,169 @@
import Database from "better-sqlite3";
import { databaseLogger } from "./logger.js";
interface LegacyTableDefinition {
name: string;
sql: string;
}
export interface LegacyDatabaseCopyResult {
buffer: Buffer;
migratedTables: number;
migratedRows: number;
}
export class LegacySqliteDatabaseCopyStore {
copyDatabaseToMemoryBuffer(sourcePath: string): LegacyDatabaseCopyResult {
const originalDb = new Database(sourcePath, { readonly: true });
const memoryDb = new Database(":memory:");
try {
const tables = this.listTableDefinitions(originalDb);
let migratedTables = 0;
let migratedRows = 0;
for (const table of tables) {
memoryDb.exec(table.sql);
migratedTables++;
}
memoryDb.exec("PRAGMA foreign_keys = OFF");
for (const table of tables) {
migratedRows += this.copyTableRows(originalDb, memoryDb, table.name);
}
memoryDb.exec("PRAGMA foreign_keys = ON");
this.assertNoForeignKeyViolations(memoryDb);
this.assertRowCountsMatch(originalDb, memoryDb);
return {
buffer: memoryDb.serialize(),
migratedTables,
migratedRows,
};
} finally {
originalDb.close();
memoryDb.close();
}
}
private listTableDefinitions(db: Database.Database): LegacyTableDefinition[] {
return db
.prepare(
`
SELECT name, sql FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`,
)
.all() as LegacyTableDefinition[];
}
private listTableNames(db: Database.Database): string[] {
return db
.prepare(
`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`,
)
.all()
.map((table) => (table as { name: string }).name);
}
private copyTableRows(
originalDb: Database.Database,
memoryDb: Database.Database,
tableName: string,
): number {
const rows = originalDb
.prepare(`SELECT * FROM ${tableName}`)
.all() as Record<string, unknown>[];
if (rows.length === 0) {
return 0;
}
const columns = Object.keys(rows[0]);
const placeholders = columns.map(() => "?").join(", ");
const insertStmt = memoryDb.prepare(
`INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`,
);
const insertTransaction = memoryDb.transaction(
(dataRows: Record<string, unknown>[]) => {
for (const row of dataRows) {
insertStmt.run(columns.map((column) => row[column]));
}
},
);
insertTransaction(rows);
return rows.length;
}
private assertNoForeignKeyViolations(memoryDb: Database.Database): void {
const fkCheckResult = memoryDb.prepare("PRAGMA foreign_key_check").all();
if (fkCheckResult.length === 0) {
return;
}
databaseLogger.error(
"Foreign key constraints violations detected after migration",
null,
{
operation: "migration_fk_check_failed",
violations: fkCheckResult,
},
);
throw new Error(
`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`,
);
}
private assertRowCountsMatch(
originalDb: Database.Database,
memoryDb: Database.Database,
): void {
const originalTables = this.listTableNames(originalDb);
const memoryTables = this.listTableNames(memoryDb);
if (originalTables.length !== memoryTables.length) {
databaseLogger.error(
"Table count mismatch during migration verification",
null,
{
operation: "migration_verify_failed",
originalCount: originalTables.length,
memoryCount: memoryTables.length,
},
);
throw new Error("Migration integrity verification failed");
}
for (const tableName of originalTables) {
const originalCount = originalDb
.prepare(`SELECT COUNT(*) as count FROM ${tableName}`)
.get() as { count: number };
const memoryCount = memoryDb
.prepare(`SELECT COUNT(*) as count FROM ${tableName}`)
.get() as { count: number };
if (originalCount.count !== memoryCount.count) {
databaseLogger.error(
"Row count mismatch for table during migration verification",
null,
{
operation: "migration_verify_table_failed",
table: tableName,
originalRows: originalCount.count,
memoryRows: memoryCount.count,
},
);
throw new Error("Migration integrity verification failed");
}
}
}
}
+27 -89
View File
@@ -1,13 +1,8 @@
import type { Request, Response, NextFunction } from "express";
import { db } from "../database/db/index.js";
import {
hostAccess,
roles,
userRoles,
hosts,
users,
} from "../database/db/schema.js";
import { eq, and, or, isNull, gte, sql } from "drizzle-orm";
import { createCurrentRbacAccessRepository } from "../database/repositories/current-rbac-access-repository.js";
import { createCurrentRoleRepository } from "../database/repositories/current-role-repository.js";
import { createCurrentUserRepository } from "../database/repositories/current-user-repository.js";
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
import { databaseLogger } from "./logger.js";
interface AuthenticatedRequest extends Request {
@@ -65,15 +60,7 @@ class PermissionManager {
private async cleanupExpiredAccess(): Promise<void> {
try {
const now = new Date().toISOString();
await db
.delete(hostAccess)
.where(
and(
sql`${hostAccess.expiresAt} IS NOT NULL`,
sql`${hostAccess.expiresAt} <= ${now}`,
),
);
await createCurrentRbacAccessRepository().deleteExpiredHostAccess();
} catch (error) {
databaseLogger.error("Failed to cleanup expired host access", error, {
operation: "host_access_cleanup_failed",
@@ -96,13 +83,8 @@ class PermissionManager {
}
try {
const userRoleRecords = await db
.select({
permissions: roles.permissions,
})
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(eq(userRoles.userId, userId));
const userRoleRecords =
await createCurrentRoleRepository().listUserRolePermissions(userId);
const allPermissions = new Set<string>();
for (const record of userRoleRecords) {
@@ -165,13 +147,9 @@ class PermissionManager {
action: "read" | "write" | "execute" | "delete" | "share" = "read",
): Promise<HostAccessInfo> {
try {
const host = await db
.select()
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.limit(1);
const hostResolutionRepository = createCurrentHostResolutionRepository();
if (host.length > 0) {
if (await hostResolutionRepository.isHostOwnedByUser(hostId, userId)) {
return {
hasAccess: true,
isOwner: true,
@@ -179,43 +157,20 @@ class PermissionManager {
};
}
const userRoleIds = await db
.select({ roleId: userRoles.roleId })
.from(userRoles)
.where(eq(userRoles.userId, userId));
const roleIds = userRoleIds.map((r) => r.roleId);
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const now = new Date().toISOString();
const sharedAccess = await db
.select()
.from(hostAccess)
.where(
and(
eq(hostAccess.hostId, hostId),
or(
eq(hostAccess.userId, userId),
roleIds.length > 0
? sql`${hostAccess.roleId} IN (${sql.join(
roleIds.map((id) => sql`${id}`),
sql`, `,
)})`
: sql`false`,
),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
),
)
.limit(1);
const access =
await createCurrentRbacAccessRepository().findActiveHostAccess(
hostId,
userId,
roleIds,
);
if (sharedAccess.length > 0) {
const access = sharedAccess[0];
if (access) {
const ownerId = await hostResolutionRepository.findHostOwnerId(hostId);
const hostOwnerCheck = await db
.select({ ownerId: hosts.userId })
.from(hosts)
.where(eq(hosts.id, hostId))
.limit(1);
if (hostOwnerCheck.length > 0 && hostOwnerCheck[0].ownerId === userId) {
if (ownerId === userId) {
return {
hasAccess: true,
isOwner: true,
@@ -234,12 +189,7 @@ class PermissionManager {
}
try {
await db
.update(hostAccess)
.set({
lastAccessedAt: now,
})
.where(eq(hostAccess.id, access.id));
await createCurrentRbacAccessRepository().touchHostAccess(access.id);
} catch (error) {
databaseLogger.warn("Failed to update host access timestamp", {
operation: "update_host_access_timestamp",
@@ -278,28 +228,16 @@ class PermissionManager {
async isAdmin(userId: string): Promise<boolean> {
try {
const user = await db
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
const user = await createCurrentUserRepository().findById(userId);
if (user.length > 0 && user[0].isAdmin) {
if (user?.isAdmin) {
return true;
}
const adminRoles = await db
.select({ roleName: roles.name })
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(
and(
eq(userRoles.userId, userId),
or(eq(roles.name, "admin"), eq(roles.name, "super_admin")),
),
);
return adminRoles.length > 0;
return createCurrentRoleRepository().userHasAnyRoleName(userId, [
"admin",
"super_admin",
]);
} catch (error) {
databaseLogger.error("Failed to check admin status", error, {
operation: "is_admin",
+89 -148
View File
@@ -1,12 +1,8 @@
import { db } from "../database/db/index.js";
import {
sharedCredentials,
sshCredentials,
hostAccess,
userRoles,
hosts,
} from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { createCurrentCredentialRepository } from "../database/repositories/current-credential-repository.js";
import { createCurrentRbacAccessRepository } from "../database/repositories/current-rbac-access-repository.js";
import { createCurrentRoleRepository } from "../database/repositories/current-role-repository.js";
import { createCurrentSharedCredentialRepository } from "../database/repositories/current-shared-credential-repository.js";
import type { SharedCredentialRecord } from "../database/repositories/shared-credential-repository.js";
import { DataCrypto } from "./data-crypto.js";
import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
@@ -39,18 +35,15 @@ class SharedCredentialManager {
ownerId: string,
): Promise<void> {
try {
const existing = await db
.select({ id: sharedCredentials.id })
.from(sharedCredentials)
.where(
and(
eq(sharedCredentials.hostAccessId, hostAccessId),
eq(sharedCredentials.targetUserId, targetUserId),
),
)
.limit(1);
const sharedCredentialRepository =
createCurrentSharedCredentialRepository();
const existing =
await sharedCredentialRepository.existsForHostAccessAndTargetUser(
hostAccessId,
targetUserId,
);
if (existing.length > 0) return;
if (existing) return;
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
@@ -78,7 +71,7 @@ class SharedCredentialManager {
hostAccessId,
);
await db.insert(sharedCredentials).values({
await sharedCredentialRepository.create({
hostAccessId,
originalCredentialId,
targetUserId,
@@ -106,7 +99,7 @@ class SharedCredentialManager {
hostAccessId,
);
await db.insert(sharedCredentials).values({
await sharedCredentialRepository.create({
hostAccessId,
originalCredentialId,
targetUserId,
@@ -131,12 +124,10 @@ class SharedCredentialManager {
ownerId: string,
): Promise<void> {
try {
const roleUsers = await db
.select({ userId: userRoles.userId })
.from(userRoles)
.where(eq(userRoles.roleId, roleId));
const roleUserIds =
await createCurrentRoleRepository().listRoleUserIds(roleId);
for (const { userId } of roleUsers) {
for (const userId of roleUserIds) {
try {
await this.createSharedCredentialForUser(
hostAccessId,
@@ -176,27 +167,26 @@ class SharedCredentialManager {
targetUserId: string,
): Promise<void> {
try {
const hostsSharedWithRole = await db
.select()
.from(hostAccess)
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
.where(eq(hostAccess.roleId, roleId));
const hostsSharedWithRole =
await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources(
roleId,
);
for (const { host_access, ssh_data } of hostsSharedWithRole) {
for (const sharedHost of hostsSharedWithRole) {
const activeCredentialId =
ssh_data.credentialId ??
ssh_data.rdpCredentialId ??
ssh_data.vncCredentialId ??
ssh_data.telnetCredentialId;
sharedHost.credentialId ??
sharedHost.rdpCredentialId ??
sharedHost.vncCredentialId ??
sharedHost.telnetCredentialId;
if (!activeCredentialId) continue;
try {
await this.createSharedCredentialForUser(
host_access.id,
sharedHost.hostAccessId,
activeCredentialId,
targetUserId,
ssh_data.userId,
sharedHost.hostOwnerId,
);
} catch (error) {
databaseLogger.error(
@@ -206,7 +196,7 @@ class SharedCredentialManager {
operation: "create_shared_credentials_role_member",
roleId,
targetUserId,
hostId: ssh_data.id,
hostId: sharedHost.hostId,
},
);
}
@@ -227,12 +217,10 @@ class SharedCredentialManager {
async createSharedCredentialsForUserRoles(userId: string): Promise<void> {
try {
const roleRows = await db
.select({ roleId: userRoles.roleId })
.from(userRoles)
.where(eq(userRoles.userId, userId));
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
for (const { roleId } of roleRows) {
for (const roleId of roleIds) {
await this.createSharedCredentialsForRoleMember(roleId, userId);
}
} catch (error) {
@@ -258,37 +246,23 @@ class SharedCredentialManager {
throw new Error(`User ${userId} data not unlocked`);
}
const sharedCred = await db
.select()
.from(sharedCredentials)
.innerJoin(
hostAccess,
eq(sharedCredentials.hostAccessId, hostAccess.id),
)
.where(
and(
eq(hostAccess.hostId, hostId),
eq(sharedCredentials.targetUserId, userId),
),
)
.limit(1);
const cred =
await createCurrentRbacAccessRepository().findSharedCredentialForHostAndUser(
hostId,
userId,
);
if (sharedCred.length === 0) {
if (!cred) {
return null;
}
const cred = sharedCred[0].shared_credentials;
if (cred.needsReEncryption) {
await this.reEncryptSharedCredential(cred.id, userId);
const refreshed = await db
.select()
.from(sharedCredentials)
.where(eq(sharedCredentials.id, cred.id))
.limit(1);
const refreshed =
await createCurrentSharedCredentialRepository().findById(cred.id);
if (refreshed.length === 0 || refreshed[0].needsReEncryption) {
if (!refreshed || refreshed.needsReEncryption) {
databaseLogger.warn(
"Shared credential needs re-encryption but cannot be accessed yet",
{
@@ -300,7 +274,7 @@ class SharedCredentialManager {
return null;
}
return this.decryptSharedCredential(refreshed[0], userDEK);
return this.decryptSharedCredential(refreshed, userDEK);
}
return this.decryptSharedCredential(cred, userDEK);
@@ -319,10 +293,12 @@ class SharedCredentialManager {
ownerId: string,
): Promise<void> {
try {
const sharedCreds = await db
.select()
.from(sharedCredentials)
.where(eq(sharedCredentials.originalCredentialId, credentialId));
const sharedCredentialRepository =
createCurrentSharedCredentialRepository();
const sharedCreds =
await sharedCredentialRepository.listByOriginalCredentialId(
credentialId,
);
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
let credentialData: CredentialData;
@@ -347,10 +323,9 @@ class SharedCredentialManager {
error: error instanceof Error ? error.message : "Unknown error",
},
);
await db
.update(sharedCredentials)
.set({ needsReEncryption: true })
.where(eq(sharedCredentials.originalCredentialId, credentialId));
await sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId(
credentialId,
);
return;
}
}
@@ -359,10 +334,9 @@ class SharedCredentialManager {
const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId);
if (!targetDEK) {
await db
.update(sharedCredentials)
.set({ needsReEncryption: true })
.where(eq(sharedCredentials.id, sharedCred.id));
await sharedCredentialRepository.updateById(sharedCred.id, {
needsReEncryption: true,
});
continue;
}
@@ -373,14 +347,11 @@ class SharedCredentialManager {
sharedCred.hostAccessId,
);
await db
.update(sharedCredentials)
.set({
...encryptedForTarget,
needsReEncryption: false,
updatedAt: new Date().toISOString(),
})
.where(eq(sharedCredentials.id, sharedCred.id));
await sharedCredentialRepository.updateById(sharedCred.id, {
...encryptedForTarget,
needsReEncryption: false,
updatedAt: new Date().toISOString(),
});
}
} catch (error) {
databaseLogger.error("Failed to update shared credentials", error, {
@@ -394,9 +365,9 @@ class SharedCredentialManager {
credentialId: number,
): Promise<void> {
try {
await db
.delete(sharedCredentials)
.where(eq(sharedCredentials.originalCredentialId, credentialId));
await createCurrentSharedCredentialRepository().deleteByOriginalCredentialId(
credentialId,
);
} catch (error) {
databaseLogger.error("Failed to delete shared credentials", error, {
operation: "delete_shared_credentials",
@@ -412,14 +383,9 @@ class SharedCredentialManager {
return;
}
const pendingCreds = await db
.select()
.from(sharedCredentials)
.where(
and(
eq(sharedCredentials.targetUserId, userId),
eq(sharedCredentials.needsReEncryption, true),
),
const pendingCreds =
await createCurrentSharedCredentialRepository().listPendingByTargetUserId(
userId,
);
for (const cred of pendingCreds) {
@@ -438,23 +404,15 @@ class SharedCredentialManager {
ownerId: string,
ownerDEK: Buffer,
): Promise<CredentialData> {
const creds = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, ownerId),
),
)
.limit(1);
const cred = await createCurrentCredentialRepository().findByIdForUser(
ownerId,
credentialId,
);
if (creds.length === 0) {
if (!cred) {
throw new Error(`Credential ${credentialId} not found`);
}
const cred = creds[0];
return {
username: cred.username,
authType: cred.authType,
@@ -479,18 +437,13 @@ class SharedCredentialManager {
private async getDecryptedCredentialViaSystemKey(
credentialId: number,
): Promise<CredentialData> {
const creds = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, credentialId))
.limit(1);
const cred =
await createCurrentCredentialRepository().findById(credentialId);
if (creds.length === 0) {
if (!cred) {
throw new Error(`Credential ${credentialId} not found`);
}
const cred = creds[0];
if (!cred.systemPassword && !cred.systemKey && !cred.systemKeyPassword) {
throw new Error(
"Credential not yet migrated for offline sharing. " +
@@ -580,7 +533,7 @@ class SharedCredentialManager {
}
private decryptSharedCredential(
sharedCred: typeof sharedCredentials.$inferSelect,
sharedCred: SharedCredentialRecord,
userDEK: Buffer,
): CredentialData {
const recordId = `shared-${sharedCred.hostAccessId}-${sharedCred.targetUserId}`;
@@ -649,7 +602,7 @@ class SharedCredentialManager {
originalCredentialId: number,
targetUserId: string,
): Promise<void> {
await db.insert(sharedCredentials).values({
await createCurrentSharedCredentialRepository().create({
hostAccessId,
originalCredentialId,
targetUserId,
@@ -670,13 +623,10 @@ class SharedCredentialManager {
userId: string,
): Promise<void> {
try {
const sharedCred = await db
.select()
.from(sharedCredentials)
.where(eq(sharedCredentials.id, sharedCredId))
.limit(1);
const cred =
await createCurrentSharedCredentialRepository().findById(sharedCredId);
if (sharedCred.length === 0) {
if (!cred) {
databaseLogger.warn("Re-encrypt: shared credential not found", {
operation: "reencrypt_not_found",
sharedCredId,
@@ -684,16 +634,12 @@ class SharedCredentialManager {
return;
}
const cred = sharedCred[0];
const ownerId =
await createCurrentRbacAccessRepository().findHostAccessOwnerId(
cred.hostAccessId,
);
const access = await db
.select()
.from(hostAccess)
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
.where(eq(hostAccess.id, cred.hostAccessId))
.limit(1);
if (access.length === 0) {
if (!ownerId) {
databaseLogger.warn("Re-encrypt: host access not found", {
operation: "reencrypt_access_not_found",
sharedCredId,
@@ -701,8 +647,6 @@ class SharedCredentialManager {
return;
}
const ownerId = access[0].ssh_data.userId;
const userDEK = DataCrypto.getUserDataKey(userId);
if (!userDEK) {
databaseLogger.warn("Re-encrypt: user DEK not available", {
@@ -747,14 +691,11 @@ class SharedCredentialManager {
cred.hostAccessId,
);
await db
.update(sharedCredentials)
.set({
...encryptedForTarget,
needsReEncryption: false,
updatedAt: new Date().toISOString(),
})
.where(eq(sharedCredentials.id, sharedCredId));
await createCurrentSharedCredentialRepository().updateById(sharedCredId, {
...encryptedForTarget,
needsReEncryption: false,
updatedAt: new Date().toISOString(),
});
} catch (error) {
databaseLogger.error("Failed to re-encrypt shared credential", error, {
operation: "reencrypt_shared_credential",
+26 -100
View File
@@ -1,8 +1,6 @@
import crypto from "crypto";
import { getDb } from "../database/db/index.js";
import { settings } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { databaseLogger } from "./logger.js";
import { createCurrentSettingsRepository } from "../database/repositories/current-settings-repository.js";
import { SystemCrypto } from "./system-crypto.js";
interface KEKSalt {
@@ -385,10 +383,6 @@ class UserCrypto {
await this.storeKEKSalt(userId, newKekSalt);
await this.storeEncryptedDEK(userId, newEncryptedDEK);
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
oldKEK.fill(0);
newKEK.fill(0);
DEK.fill(0);
@@ -422,10 +416,6 @@ class UserCrypto {
await this.storeKEKSalt(userId, newKekSalt);
await this.storeEncryptedDEK(userId, newEncryptedDEK);
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
newKEK.fill(0);
const session = this.userSessions.get(userId);
@@ -472,23 +462,7 @@ class UserCrypto {
const key = `user_encrypted_dek_oidc_${userId}`;
const value = JSON.stringify(oidcEncryptedDEK);
const { getDb } = await import("../database/db/index.js");
const { settings } = await import("../database/db/schema.js");
const { eq } = await import("drizzle-orm");
const existing = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (existing.length > 0) {
await getDb()
.update(settings)
.set({ value })
.where(eq(settings.key, key));
} else {
await getDb().insert(settings).values({ key, value });
}
await this.setSetting(key, value);
databaseLogger.info(
"Converted user encryption to dual-auth (password + OIDC)",
@@ -497,10 +471,6 @@ class UserCrypto {
userId,
},
);
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (error) {
databaseLogger.error("Failed to convert to OIDC encryption", error, {
operation: "convert_to_oidc_encryption_error",
@@ -655,10 +625,7 @@ class UserCrypto {
try {
const dek = this.decryptDEK(encryptedDEK, legacyKey);
const value = JSON.stringify(this.encryptDEK(dek, systemKey));
await getDb()
.update(settings)
.set({ value })
.where(eq(settings.key, settingKey));
await createCurrentSettingsRepository().set(settingKey, value);
databaseLogger.info("Migrated legacy system-wrapped user key", {
operation: "user_key_wrap_migrated",
userId,
@@ -704,38 +671,31 @@ class UserCrypto {
return decrypted;
}
private async setSetting(key: string, value: string): Promise<void> {
await createCurrentSettingsRepository().set(key, value);
}
private async getSetting(key: string): Promise<string | null> {
return createCurrentSettingsRepository().get(key);
}
private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise<void> {
const key = `user_kek_salt_${userId}`;
const value = JSON.stringify(kekSalt);
const existing = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (existing.length > 0) {
await getDb()
.update(settings)
.set({ value })
.where(eq(settings.key, key));
} else {
await getDb().insert(settings).values({ key, value });
}
await this.setSetting(key, value);
}
private async getKEKSalt(userId: string): Promise<KEKSalt | null> {
try {
const key = `user_kek_salt_${userId}`;
const result = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
const value = await this.getSetting(key);
if (result.length === 0) {
if (value === null) {
return null;
}
return JSON.parse(result[0].value);
return JSON.parse(value);
} catch {
return null;
}
@@ -748,34 +708,19 @@ class UserCrypto {
const key = `user_encrypted_dek_${userId}`;
const value = JSON.stringify(encryptedDEK);
const existing = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (existing.length > 0) {
await getDb()
.update(settings)
.set({ value })
.where(eq(settings.key, key));
} else {
await getDb().insert(settings).values({ key, value });
}
await this.setSetting(key, value);
}
private async getEncryptedDEK(userId: string): Promise<EncryptedDEK | null> {
try {
const key = `user_encrypted_dek_${userId}`;
const result = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
const value = await this.getSetting(key);
if (result.length === 0) {
if (value === null) {
return null;
}
return JSON.parse(result[0].value);
return JSON.parse(value);
} catch {
return null;
}
@@ -786,16 +731,13 @@ class UserCrypto {
): Promise<EncryptedDEK | null> {
try {
const key = `user_encrypted_dek_oidc_${userId}`;
const result = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
const value = await this.getSetting(key);
if (result.length === 0) {
if (value === null) {
return null;
}
return JSON.parse(result[0].value);
return JSON.parse(value);
} catch {
return null;
}
@@ -807,20 +749,7 @@ class UserCrypto {
): Promise<void> {
const key = `user_encrypted_dek_webauthn_${userId}`;
const value = JSON.stringify(encryptedDEK);
const existing = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
if (existing.length > 0) {
await getDb()
.update(settings)
.set({ value })
.where(eq(settings.key, key));
} else {
await getDb().insert(settings).values({ key, value });
}
await this.setSetting(key, value);
}
private async getWebAuthnEncryptedDEK(
@@ -828,16 +757,13 @@ class UserCrypto {
): Promise<EncryptedDEK | null> {
try {
const key = `user_encrypted_dek_webauthn_${userId}`;
const result = await getDb()
.select()
.from(settings)
.where(eq(settings.key, key));
const value = await this.getSetting(key);
if (result.length === 0) {
if (value === null) {
return null;
}
return JSON.parse(result[0].value);
return JSON.parse(value);
} catch {
return null;
}
+23 -47
View File
@@ -1,15 +1,8 @@
import { getDb } from "../database/db/index.js";
import {
users,
hosts,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
transferRecent,
dismissedAlerts,
} from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { createCurrentDismissedAlertRepository } from "../database/repositories/current-dismissed-alert-repository.js";
import { createCurrentFileManagerBookmarkRepository } from "../database/repositories/current-file-manager-bookmark-repository.js";
import { createCurrentTransferRecentRepository } from "../database/repositories/current-transfer-recent-repository.js";
import { createCurrentUserDataExportRepository } from "../database/repositories/current-user-data-export-repository.js";
import { createCurrentUserRepository } from "../database/repositories/current-user-repository.js";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
@@ -54,16 +47,11 @@ class UserDataExport {
} = options;
try {
const user = await getDb()
.select()
.from(users)
.where(eq(users.id, userId));
if (!user || user.length === 0) {
const userRecord = await createCurrentUserRepository().findById(userId);
if (!userRecord) {
throw new Error(`User not found: ${userId}`);
}
const userRecord = user[0];
let userDataKey: Buffer | null = null;
if (format === "plaintext") {
userDataKey = DataCrypto.getUserDataKey(userId);
@@ -74,10 +62,8 @@ class UserDataExport {
}
}
const sshHosts = await getDb()
.select()
.from(hosts)
.where(eq(hosts.userId, userId));
const exportRepository = createCurrentUserDataExportRepository();
const sshHosts = await exportRepository.listHostsByUserId(userId);
const processedSshHosts =
format === "plaintext" && userDataKey
? sshHosts.map((host) =>
@@ -87,10 +73,8 @@ class UserDataExport {
let sshCredentialsData: unknown[] = [];
if (includeCredentials) {
const credentials = await getDb()
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
const credentials =
await exportRepository.listCredentialsByUserId(userId);
sshCredentialsData =
format === "plaintext" && userDataKey
? credentials.map((cred) =>
@@ -106,28 +90,20 @@ class UserDataExport {
const [recentFiles, pinnedFiles, shortcuts, transferRecentData] =
await Promise.all([
getDb()
.select()
.from(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId)),
getDb()
.select()
.from(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId)),
getDb()
.select()
.from(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId)),
getDb()
.select()
.from(transferRecent)
.where(eq(transferRecent.userId, userId)),
createCurrentFileManagerBookmarkRepository().listRecentByUserId(
userId,
),
createCurrentFileManagerBookmarkRepository().listPinnedByUserId(
userId,
),
createCurrentFileManagerBookmarkRepository().listShortcutsByUserId(
userId,
),
createCurrentTransferRecentRepository().listByUserId(userId),
]);
const alerts = await getDb()
.select()
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const alerts =
await createCurrentDismissedAlertRepository().listByUserId(userId);
const exportData: UserExportData = {
version: this.EXPORT_VERSION,
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from "vitest";
import { RawSqliteUserEncryptionMigrationStore } from "./user-encryption-migration-store.js";
describe("RawSqliteUserEncryptionMigrationStore", () => {
function createDb() {
const all = vi.fn(() => [{ id: 1 }]);
const get = vi.fn(() => ({ id: "user-1" }));
const run = vi.fn();
const prepare = vi.fn(() => ({ all, get, run }));
return { db: { prepare }, all, get, run };
}
it("owns legacy user encryption migration reads", () => {
const { db, all, get } = createDb();
const store = new RawSqliteUserEncryptionMigrationStore(db);
expect(store.listHostRecords("user-1")).toEqual([{ id: 1 }]);
expect(store.listCredentialRecords("user-1")).toEqual([{ id: 1 }]);
expect(store.getUserRecord("user-1")).toEqual({ id: "user-1" });
expect(db.prepare).toHaveBeenCalledWith(
"SELECT * FROM ssh_data WHERE user_id = ?",
);
expect(db.prepare).toHaveBeenCalledWith(
"SELECT * FROM ssh_credentials WHERE user_id = ?",
);
expect(db.prepare).toHaveBeenCalledWith("SELECT * FROM users WHERE id = ?");
expect(all).toHaveBeenCalledWith("user-1");
expect(get).toHaveBeenCalledWith("user-1");
});
it("owns legacy sensitive field update statements", () => {
const { db, run } = createDb();
const store = new RawSqliteUserEncryptionMigrationStore(db);
store.updateHostSensitiveFields(12, {
password: "p",
key: "k",
key_password: "kp",
key_type: "ed25519",
autostart_password: "ap",
autostart_key: "ak",
autostart_key_password: "akp",
sudo_password: "sp",
});
store.updateCredentialSensitiveFields(13, {
password: "p",
key: "k",
key_password: "kp",
private_key: "priv",
public_key: "pub",
key_type: "rsa",
});
store.updateUserSensitiveFields("user-1", {
totp_secret: "totp",
totp_backup_codes: "codes",
client_secret: "client",
oidc_identifier: "oidc",
});
expect(db.prepare).toHaveBeenCalledWith(
expect.stringContaining("UPDATE ssh_data"),
);
expect(db.prepare).toHaveBeenCalledWith(
expect.stringContaining("UPDATE ssh_credentials"),
);
expect(db.prepare).toHaveBeenCalledWith(
expect.stringContaining("UPDATE users"),
);
expect(run).toHaveBeenCalledWith(
"p",
"k",
"kp",
"ed25519",
"ap",
"ak",
"akp",
"sp",
12,
);
expect(run).toHaveBeenCalledWith("p", "k", "kp", "priv", "pub", "rsa", 13);
expect(run).toHaveBeenCalledWith(
"totp",
"codes",
"client",
"oidc",
"user-1",
);
});
it("owns password-reset dynamic field updates", () => {
const { db, run } = createDb();
const store = new RawSqliteUserEncryptionMigrationStore(db);
store.updatePasswordResetFields(
"ssh_credentials",
99,
["password", "key"],
{ password: "p", key: "k" },
);
expect(db.prepare).toHaveBeenCalledWith(
"UPDATE ssh_credentials SET password = ?, key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
);
expect(run).toHaveBeenCalledWith("p", "k", 99);
});
});
@@ -0,0 +1,150 @@
import { getCurrentRepositorySqlite } from "../database/repositories/current-repository-runtime.js";
export interface UserEncryptionMigrationRecord {
id: number | string;
[key: string]: unknown;
}
export interface UserEncryptionMigrationStore {
listHostRecords(userId: string): UserEncryptionMigrationRecord[];
listCredentialRecords(userId: string): UserEncryptionMigrationRecord[];
getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined;
updateHostSensitiveFields(
recordId: number | string,
record: Record<string, unknown>,
): void;
updateCredentialSensitiveFields(
recordId: number | string,
record: Record<string, unknown>,
): void;
updateUserSensitiveFields(
userId: string,
record: Record<string, unknown>,
): void;
updatePasswordResetFields(
table: "ssh_data" | "ssh_credentials" | "users",
recordId: number | string,
fields: string[],
record: Record<string, unknown>,
): void;
}
export interface LegacyDatabaseInstance {
prepare: (sql: string) => {
all: (param?: unknown) => UserEncryptionMigrationRecord[];
get: (param?: unknown) => UserEncryptionMigrationRecord | undefined;
run: (...params: unknown[]) => unknown;
};
}
export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigrationStore {
constructor(private readonly db: LegacyDatabaseInstance) {}
listHostRecords(userId: string): UserEncryptionMigrationRecord[] {
return this.db
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
.all(userId);
}
listCredentialRecords(userId: string): UserEncryptionMigrationRecord[] {
return this.db
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
.all(userId);
}
getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined {
return this.db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
}
updateHostSensitiveFields(
recordId: number | string,
record: Record<string, unknown>,
): void {
this.db
.prepare(
`
UPDATE ssh_data
SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
)
.run(
record.password || null,
record.key || null,
record.key_password || null,
record.key_type || null,
record.autostart_password || null,
record.autostart_key || null,
record.autostart_key_password || null,
record.sudo_password || null,
recordId,
);
}
updateCredentialSensitiveFields(
recordId: number | string,
record: Record<string, unknown>,
): void {
this.db
.prepare(
`
UPDATE ssh_credentials
SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, key_type = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
)
.run(
record.password || null,
record.key || null,
record.key_password || null,
record.private_key || null,
record.public_key || null,
record.key_type || null,
recordId,
);
}
updateUserSensitiveFields(
userId: string,
record: Record<string, unknown>,
): void {
this.db
.prepare(
`
UPDATE users
SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ?
WHERE id = ?
`,
)
.run(
record.totp_secret || null,
record.totp_backup_codes || null,
record.client_secret || null,
record.oidc_identifier || null,
userId,
);
}
updatePasswordResetFields(
table: "ssh_data" | "ssh_credentials" | "users",
recordId: number | string,
fields: string[],
record: Record<string, unknown>,
): void {
const setClause = fields.map((field) => `${field} = ?`).join(", ");
const updateQuery =
table === "users"
? `UPDATE ${table} SET ${setClause} WHERE id = ?`
: `UPDATE ${table} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
const updateValues = fields.map((field) => record[field]);
updateValues.push(recordId);
this.db.prepare(updateQuery).run(...updateValues);
}
}
export async function createCurrentUserEncryptionMigrationStore(): Promise<UserEncryptionMigrationStore> {
return new RawSqliteUserEncryptionMigrationStore(
getCurrentRepositorySqlite(),
);
}