Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [0.10.0] - 2026-09-08

### Changed

- **MFA enrolment now runs on BetterAuth's `twoFactor` plugin end to end.** The
settings page enables, verifies, disables and regenerates backup codes through the
plugin client, so the flag the login gate checks is finally the one enrolment
writes. **Users who had MFA enabled must enrol again** (the previous flag was never
enforced at login). LDAP/SSO accounts can enrol without a local password; backup
codes are stored encrypted. Enrolment, removal, regeneration and TOTP/backup-code
logins are written to the audit log; the credentials login audit no longer fires
before the second factor is verified.
- Compliance dashboard: the "User Authentication Controls" check reports real
two-factor coverage instead of "not yet implemented".

### Security

- Compliance dashboard counts (users, assets, audit logs) are scoped to the caller's
organization; they were computed across all tenants.
- Vercel builds run `prisma migrate deploy` only when `VERCEL_ENV=production`.
Preview builds used to migrate whatever database the Preview environment pointed
at, which on the personal deployment was production.

### Removed

- Custom `/api/auth/mfa/{setup,verify,disable}` routes, `lib/mfa.ts`, the
`otplib` dependency, the `user.mfaEnabled/mfaSecret/mfaBackupCodes` columns
(migration `20260908_betterauth_two_factor`) and the unused `encryptArray` helpers.

## [0.9.6] - 2026-09-07

### Security
Expand Down
55 changes: 35 additions & 20 deletions TECHNICAL_DEBT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Technical Debt

Last updated: 2026-09-07 (v0.9.6register re-verification and debt sweep)
Last updated: 2026-09-08 (v0.10.0MFA on BetterAuth, D1 resolved)

This document tracks issues found by whole-application audits. Items marked
**FIXED** were resolved in the version noted; **DEFERRED** items are documented
Expand All @@ -23,15 +23,31 @@ with a recommended fix. Two audits have run so far:
| CI (lint, typecheck, unit, build) | green since 2026-09-07 (v0.9.5 fixed the lockfile/typings; the DB suites and the Build job first passed today) |
| Production dependency advisories | 0 (was 1 critical, 53 high) |
| Cross-tenant data access | no known open read/write path (status-type cache key fixed in v0.9.6) |
| MFA login enforcement | **NOT FUNCTIONAL** — decision required (D1) |
| MFA login enforcement | functional since v0.10.0 (enrolment on BetterAuth twoFactor) |
| SSO (SAML/OIDC) login completion | **NOT FUNCTIONAL** — decision required (D2) |
| TypeScript strict mode | off; 830 errors to clear (D3) |
| Paid plan feature enforcement | **5 of 6 gated features unenforced server-side** (item 29, critical) |
| Advertised but unfinished features | 10 (items 33–42) |
| Advertised but unfinished features | 9 (items 33–36, 38–42; item 37 partly closed) |
| Unit coverage of auth/tenant layer | partial (api-auth, url-validation, org-suspension now tested) |

---

## FIXED in v0.10.0 (2026-09-08)

- **D1 resolved.** `MfaSettings.tsx` enrols through `authClient.twoFactor.enable /
verifyTotp / disable / generateBackupCodes`; the custom routes, `lib/mfa.ts`,
`otplib` and the `mfa*` user columns are gone (migration
`20260908_betterauth_two_factor`, which also adds the plugin's `verified`,
`failedVerificationCount` and `lockedUntil` columns to `twoFactor`).
`allowPasswordless: true` lets LDAP/SSO accounts enrol; backup codes are stored
encrypted. Audit coverage moved to `lib/auth-two-factor-audit.ts`, driven from the
BetterAuth `after` hook (enrolment, removal, regeneration, TOTP and backup-code
logins); the credentials login audit now waits for the second factor.
**Existing enrolments must re-enrol** — communicate before deploying.
- **Compliance route was cross-tenant**: every count in `api/admin/compliance` was
global. Now scoped to the admin's organization (403 without one). The dashboard's
MFA check reports real `twoFactorEnabled` coverage (part of item 37).

## FIXED in v0.9.6 (2026-09-07)

- **CI Build job never ran.** The build script is the Vercel build command
Expand Down Expand Up @@ -132,16 +148,9 @@ with a recommended fix. Two audits have run so far:

## DEFERRED — decisions required

**D1. MFA is bypassable (critical).** Two disconnected implementations: the settings UI
(`user/[id]/settings/ui/MfaSettings.tsx`) calls custom `/api/auth/mfa/{setup,verify,
disable}` which set `user.mfaEnabled`/`mfaSecret`; the login gate is BetterAuth's
`twoFactor` plugin, which only fires on its own `user.twoFactorEnabled` column and
`twoFactor` table — set nowhere. Enabling MFA has no effect at login.
_Recommended:_ rewrite `MfaSettings.tsx` against `authClient.twoFactor.enable /
verifyTotp / disable` (the login side already uses `verifyTotp`), delete the custom
routes, `lib/mfa.ts`, and the `mfaSecret`/`mfaBackupCodes` columns. Existing
enrolments must re-enrol. Alternative: have the custom verify route also write
BetterAuth's columns (couples to library internals; not recommended).
**D1. MFA is bypassable.** _Resolved in v0.10.0 — see FIXED above._ Follow-ups left
open: org-wide "require MFA" policy and an admin "reset MFA for user" action (neither
existed before either).

**D2. SSO login never completes (high).** SAML/OIDC callbacks create/link the user,
mint a one-time token and redirect to `/login?sso_user=&sso_token=`; nothing reads
Expand Down Expand Up @@ -186,9 +195,14 @@ excluded from `tsconfig` and CI, and was last touched on 2026-06-01 (three commi
On Vercel prefer the platform header; self-hosted needs a trusted-proxy setting.
6. **Sentry `beforeSend`** scrubbing absent in all three configs (relies on
`sendDefaultPii:false` only).
7. **Vercel preview deploys run `prisma migrate deploy`** against whatever
`DATABASE_URL` the preview has. Documented in `DEPLOYMENT.md`; consider gating on
`VERCEL_ENV === "production"` in the build command.
7. **Vercel preview deploys ran `prisma migrate deploy`** against whatever
`DATABASE_URL` the preview has. _Fixed in v0.10.0_: `vercel.json` now runs the
migration only when `VERCEL_ENV=production`. This was found the hard way — the
preview build of PR #87 applied the column-dropping MFA migration to the personal
deployment's production database (its Preview and Production environments share
`DATABASE_URL`) and broke sign-in on the June build until the columns were restored.
Still open: give Preview its own database, and keep the production URL out of
local `.env` files (the checked-out `.env` pointed at production too).
8. `.mcp.json` points at a work Sentry org from a private repo — remove before any
open-sourcing.

Expand Down Expand Up @@ -274,10 +288,11 @@ Decide per item: finish it or remove the promise.
36. **`white_label` is vaporware.** Listed as an Enterprise perk in
`plan-features-shared.ts` and `docs/DEVELOPMENT_NOTES.md`; no branding feature,
no schema, no gate. Implement or remove from the plan matrix. Effort L / S.
37. **Compliance dashboard hard-codes three checks**
(`admin/compliance/ui/ComplianceDashboard.tsx`): MFA and encryption coverage say
"not yet implemented", and the incident-response check ignores its input and
always returns "Not Configured". Effort S (remove) / M (back with settings).
37. **Compliance dashboard hard-codes two checks**
(`admin/compliance/ui/ComplianceDashboard.tsx`): encryption coverage says "not
yet implemented", and the incident-response check ignores its input and always
returns "Not Configured". (MFA coverage is real since v0.10.0.) Effort S (remove)
/ M (back with settings).
38. **`email_templates` table is loaded and discarded.** `admin/settings/page.tsx`
fetches it and `AdminSettingsPage.tsx` binds it to `_emailTemplates`; there is no
editor and no route, and all outbound mail uses the hard-coded object in
Expand Down
17 changes: 0 additions & 17 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/DATABASE_MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Asset Tracker uses **PostgreSQL** (15+) with **Prisma ORM**. The database contai
2. `20260129151226_add_ticket_system` — IT ticket system
3. `20260129165029_multi_tanancy` — Multi-tenancy (organizations, departments, roles, webhooks)
4. `20260305_betterauth_schema` — BetterAuth auth tables (replaces NextAuth)
- **Encrypted data** — Some fields (API keys, MFA secrets) are encrypted with `ENCRYPTION_KEY`. The same key must be used on the new database.
- **Encrypted data** — Some fields (API keys, integration credentials) are encrypted with `ENCRYPTION_KEY`, and BetterAuth encrypts TOTP secrets and backup codes with `BETTER_AUTH_SECRET`. The same key must be used on the new database.

**Migration strategy:** Full `pg_dump` export → import into new database → switch `DATABASE_URL`.

Expand Down
8 changes: 4 additions & 4 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ You need a PostgreSQL 15+ database. Options:
Run these locally and save the output — you'll need them for env vars:

```bash
# BETTER_AUTH_SECRET — signs JWT tokens (must be at least 32 characters)
# BETTER_AUTH_SECRET — signs sessions and encrypts TOTP secrets (must be at least 32 characters)
openssl rand -base64 32

# ENCRYPTION_KEY — encrypts sensitive data at rest (API keys, MFA secrets)
# ENCRYPTION_KEY — encrypts sensitive data at rest (API keys, integration credentials)
openssl rand -hex 32

# CRON_SECRET — protects cron job endpoints
Expand Down Expand Up @@ -220,7 +220,7 @@ Optional email vars:

### Step 5: Preview Builds Warning

**Important:** The build command runs `prisma migrate deploy` on **every** deployment, including Preview builds. Preview environments must have their own `DATABASE_URL` (never share the production database). Additionally, set `DB_SCHEMA=public` if using the public schema instead of the default `assettool` schema.
**Important:** Since v0.10.0 the build command runs `prisma migrate deploy` only when `VERCEL_ENV=production`; Preview builds compile against whatever schema the preview database already has, so a preview of a branch that adds columns will fail on those pages until it is merged and released. Preview environments should still have their own `DATABASE_URL` (never share the production database): before v0.10.0 a preview build of a column-dropping migration was applied to production this way. Additionally, set `DB_SCHEMA=public` if using the public schema instead of the default `assettool` schema.

### Step 6: Deploy

Expand Down Expand Up @@ -325,7 +325,7 @@ docker compose exec app npx prisma migrate status # Check migration state
```

**"no matching decryption secret" error:**
The `BETTER_AUTH_SECRET` changed. Users need to clear cookies / log in again.
The `BETTER_AUTH_SECRET` changed. Users need to clear cookies / log in again. Because it also encrypts TOTP secrets and backup codes, every MFA enrolment is invalidated and users must set up MFA again.

**Email not sending:**
Check Admin Settings > Email for env config status. Send a test email. Check logs:
Expand Down
2 changes: 1 addition & 1 deletion docs/DEVELOPMENT_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Shared database with `organizationId` column on all tenant-scoped tables. `scope
- **Auth flow:** BetterAuth credential login with optional TOTP/backup code MFA step, SSO via OAuth2 (Microsoft, Google), LDAP/SAML
- **Rate limiting:** IP-based (10 attempts/15 min) with progressive account lockout
- **Session tracking:** IP + user-agent recorded, hourly JWT revalidation
- **Encryption:** AES-256-GCM at rest for MFA secrets, webhook secrets, API keys, SSO/LDAP creds
- **Encryption:** AES-256-GCM at rest for webhook secrets, API keys, SSO/LDAP creds; TOTP secrets and backup codes are encrypted by BetterAuth with `BETTER_AUTH_SECRET`
- **Security headers:** Full CSP, HSTS, X-Frame-Options, X-Content-Type-Options
- **RBAC:** 35 granular permissions via `requirePermission()` on 30+ routes

Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "asset-tracker",
"version": "0.9.6",
"version": "0.10.0",
"private": true,
"license": "MIT",
"scripts": {
Expand Down Expand Up @@ -73,7 +73,6 @@
"maplibre-gl": "^5.24.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"otplib": "^13.5.0",
"pdf-lib": "^1.17.1",
"pg": "^8.23.0",
"qrcode": "^1.5.4",
Expand Down
13 changes: 13 additions & 0 deletions prisma/migrations/20260908_betterauth_two_factor/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- MFA enrolment moves onto BetterAuth's twoFactor plugin (v0.10.0).
-- The custom mfa* columns are dropped: enrolments made through the old flow were
-- never enforced at login and must be redone. The twoFactor table gains the
-- verification and lockout columns the plugin writes.
ALTER TABLE "public"."user"
DROP COLUMN IF EXISTS "mfaEnabled",
DROP COLUMN IF EXISTS "mfaSecret",
DROP COLUMN IF EXISTS "mfaBackupCodes";

ALTER TABLE "public"."twoFactor"
ADD COLUMN IF NOT EXISTS "verified" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS "failedVerificationCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "lockedUntil" TIMESTAMPTZ(6);
Comment on lines +10 to +13
7 changes: 4 additions & 3 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1039,9 +1039,6 @@ model user {
departmentId String? @db.Uuid
creation_date DateTime @db.Timestamp(6)
change_date DateTime? @db.Timestamp(6)
mfaEnabled Boolean @default(false)
mfaSecret String? @db.VarChar(255)
mfaBackupCodes String[]
// Phase 4A: LDAP/SSO fields
authProvider String @default("local") @db.VarChar(20)
externalId String? @db.VarChar(255)
Expand Down Expand Up @@ -1255,6 +1252,10 @@ model twoFactor {
secret String
backupCodes String
userId String @db.Uuid
// Plugin-managed: enrolment confirmation and verification lockout
verified Boolean @default(true)
failedVerificationCount Int @default(0)
lockedUntil DateTime? @db.Timestamptz(6)
Comment on lines +1255 to +1258
user user @relation(fields: [userId], references: [userid], onDelete: Cascade)

@@map("twoFactor")
Expand Down
15 changes: 7 additions & 8 deletions src/app/admin/compliance/ui/ComplianceDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface ComplianceData {
totalUsers: number;
adminUsers: number;
regularUsers: number;
mfaEnabledUsers: number;
};
auditCoverage: {
totalEntities: number;
Expand All @@ -35,10 +36,7 @@ interface ComplianceData {
}

type ComplianceStatus =
| "Compliant"
| "Needs Review"
| "Not Configured"
| "Not Yet Available";
"Compliant" | "Needs Review" | "Not Configured" | "Not Yet Available";

interface ComplianceCheckItem {
id: string;
Expand Down Expand Up @@ -101,11 +99,12 @@ const complianceChecklist: ComplianceCheckItem[] = [
{
id: "user-authentication",
label: "User Authentication Controls",
description:
"Multi-factor authentication is not yet implemented. Coming in a future update.",
description: "Users protect their accounts with two-factor authentication.",
framework: "HIPAA",
getStatus: () => {
return "Not Yet Available";
getStatus: (data) => {
const { totalUsers, mfaEnabledUsers } = data.accessControl;
if (totalUsers === 0 || mfaEnabledUsers === 0) return "Not Configured";
return mfaEnabledUsers === totalUsers ? "Compliant" : "Needs Review";
},
},
{
Expand Down
Loading
Loading