Three HIGH findings from the QA round. Each was demonstrated end to end against a
running server, and each is now refused there.
ENFORCEMENT PROTECTED A DOMAIN, NOT AN ORGANIZATION
ssoOnlyForEmail answers about an address's domain, so any account in the tenant at an
outside address kept password login — a contractor, an MSP, the one address nobody
remembered. And it could be manufactured: POST /api/admin/users accepts workspace_admin
and creates a LOCAL password account at any address bound to that workspace. A review
created backdoor@notacme.test, logged in with the password, landed in the SSO-only org,
and used it to create another. Enforcement is now keyed on MEMBERSHIP as well as domain
(ssoOnlyForUser), and that route refuses to mint password accounts into an SSO-only
organization at all. platform_admin keeps both, as the operator break-glass.
THE APPROVAL WORKFLOW WAS DECORATIVE
`sso_only` is honoured only while a provider is enabled and a domain is verified, so
`PUT {enabled:false}`, `PUT {email_domains:""}` and `DELETE` each switched enforcement
off — with sso_only still reading true, no request filed and the operator never told.
The delete variant additionally rewrites every federated account to `local`, after
which a password reset takes over accounts the identity provider was supposed to own.
Anyone who could file a request could simply turn the provider off instead. All three
now refuse with sso_only_locked when nothing else would still enforce, and say to ask
for approval.
FRESH INSTALLS FAILED THE MIGRATION AND FAILED OPEN
The ALTER adding organizations.sso_only sat in the column-migration array, which runs
BEFORE the multi-tenancy migration that creates the table: `[migrate] FAILED … no such
table: organizations`, one line among ~85. The instance then ran its whole first boot
with the SSO settings screen 500ing and ssoOnlyForEmail catching `no such column` and
answering "not required" — password login proceeding for an organization that had
switched it off. It self-healed on the second boot, which is what made it easy to miss.
The column is now added after the table exists, and the catch distinguishes "this
instance has no per-org SSO" (null, so single-tenant installs keep working) from drift
on a table that DOES exist (throw). Login treats an undeterminable answer as "required"
rather than letting a 500 escape or letting the login through.
Verified live, all four refused with enforcement intact and the operator still able to
sign in. 1609 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
Platform admins can now cleanly remove a customer org (account ends) or a stray
workspace from the UI, instead of raw SQL that risks orphaning resources.
The tenant cascade isn't pure DB CASCADE - workspace-scoped tables (devices,
content, playlists, ...) are NO ACTION and must be purged before the workspace.
Extracted that logic out of deleteUserCascade into shared deleteWorkspaceCascade /
deleteOrgCascade helpers (one tested implementation; deleteUserCascade now reuses
the purgeWorkspaces extraction).
Backend (platform-admin only): GET /api/admin/orgs (list + owner + counts +
workspaces), DELETE /api/admin/orgs/:id, DELETE /api/admin/workspaces/:id.
UI: an Organizations section in Admin listing every org/workspace with a
type-the-name confirmation before the irreversible delete.
Tests: org/workspace cascade (real FKs) + endpoint gating/404. Suite 53/53.
MSPs onboarding customers as separate orgs had no way to create one with
AUTO_CREATE_ORG_ON_SIGNUP=false (the only path was signup auto-org). Add a
platform-admin 'Create organization' action.
POST /api/admin/orgs (requirePlatformAdmin) creates the org + its first 'Default'
workspace. organizations.owner_user_id is NOT NULL, so an org can't be ownerless;
the creating admin becomes org_owner + workspace_admin (mirrors the signup
bootstrap in routes/auth.js) - which also surfaces the org in their switcher.
Customer users are then added via the existing Add User / manage-memberships flow.
UI: 'Create organization' button + single-field modal in the Admin area (gated).
Tests: create (201 + memberships + audit), empty-name 400, non-admin/operator 403.
The Workspace column on the platform Users page could only move a 0/1-workspace
user and showed a dead "N workspaces" label for multi-membership users. Replace
it with a "Manage workspaces" modal that handles the full picture.
Backend (routes/admin.js, requirePlatformAdmin):
- GET /api/admin/users/:id/workspaces list memberships (+org/ws names, role)
- POST /api/admin/users/:id/workspaces add to a workspace (upsert role)
- PUT /api/admin/users/:id/workspaces/:wsId change role in a workspace
- DELETE /api/admin/users/:id/workspaces/:wsId remove (last one allowed -> unassigned)
Roles validated against WORKSPACE_ROLES; each mutation writes an audit row.
Frontend:
- Workspace cell is now a summary (Unassigned / <name> / N workspaces /
"Platform (all)" for staff) + a Manage button.
- New admin-user-workspaces-modal: lists every membership with an inline role
dropdown + Remove, plus a type-to-filter "Add to workspace" picker (org-grouped,
excludes current memberships) with a role select. Staff get a note that they
already have platform-wide access. Refreshes the table on close if changed.
- Removed the old single-select inline move control (superseded by the modal).
Tests: 6 added (add to multiple workspaces, per-workspace role change, upsert,
remove incl. last->unassigned, validation 400/404, non-platform-admin 403).
Full suite 33/33. Verified headless: Manage opens, lists memberships, filtered
picker, add/role-change/remove round-trips persist (throwaway user, cleaned up).
Adds a "Workspace" column (after Plan) to the platform Users admin table so a
platform_admin can see and reassign a user's workspace inline, alongside the
Role/Plan dropdowns. Single-workspace move/assign model.
Backend:
- GET /api/auth/users (platform branch): one aggregate query adds
workspace_count and, for exactly-one membership, the workspace id/name + org
name (no N+1).
- PUT /api/admin/users/:id/workspace (requirePlatformAdmin - operator excluded):
move (1 membership) or assign (0) into the chosen workspace, default role
workspace_viewer, in a transaction; no-op if already there; REFUSES (400) a
user with >1 membership (manage in the members view). logActivity
admin_set_user_workspace.
Frontend (admin.js):
- Editable <select> only for a 'user' with 0/1 membership; multi-membership ->
read-only "N workspaces", platform staff -> read-only "Platform (all)".
- Options grouped by org via <optgroup>, built ONCE from /me's
accessible_workspaces (same source as the Add User picker) and reused per row.
- Picking "Unassigned" or the same workspace is a no-op so a stray pick can't
strip a membership. Success -> toast + refresh. EN i18n only.
Tests: 4 added (single-membership move 200 + changed, zero-membership assign
200, multi-membership 400 refused, non-platform-admin/operator 403). npm test
16/16. Verified headless: column renders, selected value correct, "Platform
(all)" for staff, and a dropdown move persisted (throwaway user, cleaned up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bug: #13 added 'platform_operator' to the frontend role dropdown
(PLATFORM_ROLE_OPTIONS) but #14's PUT /api/auth/users/:id/role whitelist
(ASSIGNABLE_PLATFORM_ROLES) only listed ['user','platform_admin'], so
selecting "Platform operator" returned 400 "Invalid role" - the role was
unassignable via the UI.
Fix: add 'platform_operator' to ASSIGNABLE_PLATFORM_ROLES. One line; the
self-demote guard is intentionally left untouched (a platform_admin still
cannot self-assign the non-owner operator role and lock themselves out).
Tests (node:test, isolated in-memory DB injection - no DB_PATH change):
- admin-users.test.js: platform_admin can PUT role=platform_operator on a
target user -> 200 and the row persists as platform_operator (regression
guard for the whitelist gap).
- operator-permissions.test.js (new): verify-then-test of the highest-blast
-radius deny. Operator CAN update/delete a workspace-scoped content row
(cross-org write works) but is denied (403) updating or deleting a shared
(workspace_id IS NULL) row - proving the separate PLATFORM_ROLES gate in
content.js's checkContentWrite still holds after canWrite was broadened to
isPlatformStaff.
Verified read-only (no leak): the other shared-asset write sites keep their
PLATFORM_ROLES gate that excludes operator - kiosk.js:57, widgets.js:110,
folders.js:31, layouts.js:59/117/133.
cd server && npm test -> 12 pass / 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds server/test/admin-users.test.js and a `npm test` (node --test) script.
No DB_PATH override: the suite mounts the real routers against an isolated
in-memory better-sqlite3 instance injected into the require cache, seeded by
the test itself. Node v20 built-ins only (node:test, node:assert, fetch).
Covers: Add User success (response omits password/hash, hash stored not
plaintext, membership written, hosted lifecycle sentinels stamped, audit row
without the password), duplicate-email 409 (no overwrite), non-admin 403,
platform_operator denied (403), org_admin scoped to their own org only,
input validation, and the must_change_password lifecycle (set on create,
surfaced on login, cleared on PUT /api/auth/me).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>