Dentolize · X-Ray Integration Walkthrough
On this pageBusiness viewTechnical view

Access Keys & Security Model

Business view

Before a desktop scanning application can talk to Dentolize, the clinic has to issue it an Access Key — a long random secret the desktop app presents on first connection instead of a username and password. Keys are created and revoked from Account → Security by a company admin, and each one carries:

  • A label, so a clinic with several workstations can tell "Front Desk Scanner" from "Room 2 Sensor".
  • A set of scopes — the specific things that key is allowed to do: read the branch/room list (Read Branches), upload files (Create Files), and drive the acquisition workflow (Acquire X-rays). A "Desktop App" checkbox in the creation form selects all three scopes at once, since that's the combination the real desktop client needs.

The raw key is shown exactly once, immediately after creation, with an explicit warning that it can't be retrieved again. After that, the key list only ever shows a short prefix, the label, creator, timestamps, and granted scopes — the key value itself is never displayed again.

Revoking a key (the button is a delete icon, but the underlying action is a revoke) takes effect immediately: any desktop app still using it fails authentication on its very next request, and any room it was actively holding is released right away rather than waiting for its lease to expire. The key's row stays visible with a "Revoked" status instead of disappearing, so the audit trail (who used it, when, for what) is preserved.

Managing Access Keys is now a dedicated admin capability (MANAGE_ACCESS_KEYS), not something every logged-in staff member can do from their own account settings.

Technical view

This section documents the security model as it exists in the current branch. An earlier draft of this documentation, written before a subsequent rewrite on this same branch, found and listed several serious access-control gaps (plaintext key storage, no tenant check on delete, unauthenticated key enumeration, cross-tenant record reads, an ungated UI). All of those were fixed as part of the "desktop-apis-reorganized" rewrite this branch is named after. The list below is a fresh re-audit of the current code, not a copy of the old findings.

Data model

AccessKey (packages/prisma/schema.prisma:279) stores keyHash (@unique), keyPrefix, label, companyId, scopes: String[], createdById, createdAt, lastUsedAt, and revokedAt. There is no plaintext key column. It's created by packages/prisma/migrations/20260816000000_add_access_keys/migration.sql. XrayAcquisitionRequest.respondedByKeyId and XrayAcquisitionLog.accessKeyId both reference it, so every acquisition can be traced back to the key/workstation that handled it.

Key format, hashing, and generation

generateAccessKey (packages/server/src/utils/accessKeys.js:18) produces a key of the form xak_<10-hex-char prefix>_<43-char base64url secret> (a 5-byte prefix plus a 32-byte secret). The server never stores the raw key — hashAccessKey (line 15) computes HMAC-SHA256(key, secret=ACCESS_KEY_HMAC_SECRET) and only the hash and the prefix are persisted; lookups on incoming requests re-hash the presented key and query by keyHash. ACCESS_KEY_HMAC_SECRET must be configured with at least 32 bytes or the server refuses to start (assertAccessKeyConfiguration, called at boot). ACCESS_KEY_SCOPES (line 3) is a fixed allow-list: branches.read, files.create, xray.acquire — submitting any other scope string is rejected (validateAccessKeyScopes, line 31). This is a change from the previous scope set (which included a now-removed patients.read scope backing a GET /api/patients endpoint that no longer exists in this codebase).

Issuing, listing, and revoking keys

  • createAccessKey (packages/server/src/resolvers/mutations/companyMutations.js:2742) resolves a resolveXrayWebPrincipal requiring MANAGE_ACCESS_KEYS, normalizes the label (1–64 chars) and scopes, generates the key, and stores only the hash. The raw key is returned once in the mutation response and never persisted anywhere else server-side.
  • getAccessKeys (packages/server/src/resolvers/queries/companyQueries.js:2497) requires the same principal and scopes results to companyId, selecting keyPrefix, label, scopes, timestamps, and revokedAt — never keyHash.
  • revokeAccessKey (companyMutations.js:2784) — named accurately now (the earlier draft of this documentation, and an earlier version of this codebase, called it deleteAccessKey): prisma.accessKey.updateMany({ where: { id, companyId: principal.companyId, revokedAt: null }, data: { revokedAt: new Date() } }). The companyId filter means a key ID from another company simply matches zero rows rather than being revoked. It then calls desktopConnectionManager.revokeByAccessKey(id) to immediately tear down any Redis room lease the key currently holds, so the room becomes available without waiting out the lease TTL.
  • Permission shield entries: getAccessKeys, createAccessKey, and revokeAccessKey are all chain(isAuthenticated, hasPermission('MANAGE_ACCESS_KEYS')) in packages/server/src/permissions/permissions.js (lines 286, 2751, 2752) — no allow-without-auth entries remain for this surface.
  • Frontend: the UI (AccessKeys, AccessKeysForm, AccessKeyItem) lives in packages/clinic-web-canary/src/features/AccessKeys/ and is mounted into the clinic-web Account page's Security tab only when user.permissions.doAll || user.permissions.manageAccessKeys (packages/clinic-web/src/components/dashboard/settings/Account/Account.js:71), matching the backend gate one-for-one.

From Access Key to lease token: how the desktop app actually authenticates

The Access Key itself is presented to the server exactly once, on connect. Everything after that uses a separate, short-lived lease token — this two-tier design means the long-lived secret is on the wire as rarely as possible.

  1. POST /api/xray-acquisition/connect (handleXrayAcquisitionConnect.js:28) validates the Access Key (validateDesktopAccessKeyresolveAccessKey, accessKeys.js:44 — checks the HMAC hash, revokedAt, company.disabled, and required scopes, then does a race-safe conditional lastUsedAt touch), confirms the beta feature flag is enabled for the company, validates the requested branch/room actually exists, and calls desktopConnectionManager.acquire() to atomically mint a lease token bound to (companyId, branchId, room, accessKeyId, instanceId) — implemented as a Lua script so "does another instance already hold this room" and "issue the lease" happen as one atomic Redis operation (desktopConnectionManager.js:24-78). If the room is already leased to a different desktop instance, connect fails with 409 ROOM_OCCUPIED rather than silently displacing it.
  2. Every subsequent call — heartbeat, disconnect, .../transitions, .../upload-intents, .../complete, and the GraphQL WebSocket subscription itself — presents the lease token as a Bearer credential, validated by validateDesktopPrincipal (packages/server/src/utils/desktopPrincipal.js:12), which resolves the lease from Redis, re-checks the underlying Access Key's revokedAt/company.disabled/scopes on every call (not just at connect time), and re-touches lastUsedAt.
  3. The lease has a 90-second TTL, refreshed by heartbeat(). Revoking the Access Key it was minted from immediately invalidates it (revokeByAccessKey), independent of the TTL.

Rate limiting and network hardening

Riding on this same branch (packages/server/src/index.js), several endpoint- and app-wide protections were added specifically because these routes accept a bearer credential from an unauthenticated network position:

LimiterScopeLimit
ipDesktopValidationLimiter/api/access-keys/validate120/min per IP
ipDesktopSetupLimiter/api/xray-acquisition/connect, GET /api/branches60/min per IP
leaseMinuteLimiterheartbeat, disconnect, .../transitions120/min, keyed by a hash of the lease/auth header
leaseUploadLimiter.../upload-intents, .../complete60/hour, same keying

All of the above use Redis-backed limiters (rate-limit-redis) so they work correctly across multiple server instances. Desktop-facing request bodies are additionally capped at 32 KB (desktopBodyLimit), separate from the app's general 500 KB limit. A related, broader fix in the same change: Express's trust proxy setting was changed from unconditionally trusting every X-Forwarded-For header to app.set('trust proxy', TRUST_PROXY_HOPS), defaulting to not trusting any forwarded header unless an operator explicitly configures the exact number of trusted proxy hops — closing a way the IP-based limiters above could otherwise be trivially bypassed by a spoofed header. This is an app-wide change, not X-ray-specific, and ships in the same PR — see Rollout, Feature Flag & Observability for the required TRUST_PROXY_HOPS go-live step.

Automated test coverage

packages/server/src/services/__tests__/xraySecurity.test.js (242 lines, plain node:assert tests, no framework) directly unit-tests the pieces above: the key format and HMAC determinism, the scope allow-list, that a revoked key fails both authentication and the lastUsedAt touch, transition idempotency/detail-sanitization, and the PNG/BMP header inspector used during upload verification. This is meaningful evidence the security-sensitive primitives are exercised in CI, though it's unit-level coverage of individual functions, not an end-to-end authorization test across the GraphQL/REST surface — see For Quality for what's still worth testing by hand.

What a fresh re-audit still surfaced

  • xrayAcquisitionRequest and xrayAcquisitionLogs (singular lookups, packages/server/src/resolvers/queries/patientQueries.js:912 and :978) each run a first, cheap companyId-scoped existence check before resolving a full resolveXrayWebPrincipal, and the final row-fetch additionally re-applies companyId and a branch.users: { some: { id } } membership filter — so a user without a permission the resolvers require gets null/[] rather than an error. This is correct-looking defense in depth, but it's worth deliberately testing (see For Quality) since the "return null instead of throwing" pattern can mask authorization bugs during manual testing if a tester doesn't check for an empty result specifically.
  • Scopes are still coarse-grained. All three current scopes are meaningful (unlike the old patients.read, which pointed at a since-removed endpoint), but a key created only for branches.read still gets full lease/heartbeat/connect access as long as xray.acquire is also granted — there's no way to issue a key that can only read branches without also being usable for the full acquisition flow, if xray.acquire is checked. In practice the UI only offers granting all-or-nothing via the "Desktop App" checkbox, so this is a minor design note rather than an exploitable gap.