Files
agent-desk-ng/docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md
mono 63e0457886 fix: harden identity, live access, drafts and setup (review only)
Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
2026-10-16 12:15:20 +00:00

6.3 KiB

Customer identity and live-access upgrade

These are operator instructions for a reviewed staging rollout, not commands executed by this change. Back up data and verify the exact schema/engine before any production upgrade. Existing customer and conversation records are retained.

Additive schema and issuer boundaries

CustomerIdentity adds:

  • identity_scope: indexed varchar(80), default empty for legacy rows.
  • scoped_key: nullable varchar(64) with a unique index. Legacy rows remain NULL.

The normal GORM startup migration adds these definitions. No destructive index replacement, automatic legacy merge, or data deletion is supplied. Test this additive change on a backup with the deployment's actual SQLite/MySQL version.

A public identity scope is v1:<jwt|hmac|guest>:SHA256(trimmed channel code). The server derives it; clients cannot select an issuer or inject this scope. The unique claim key is SHA-256 of the JSON array containing scope, external source and trimmed external ID. Use the implementation's ScopedCustomerIdentityKey helper rather than a hand-built delimiter format. New public registrations insert the claim in the same customer transaction. A competing claim rolls back rather than creating two authorized customers.

WebSocket routing uses canonical customer ID and channel record ID. Incoming subscriptions and outgoing recipients are checked against current ownership. Raw external IDs are not cross-channel or cross-trust routing keys.

Existing sessions and history

Old customer-session JWTs without the new scope are rejected. The updated client must exchange a fresh token. Until legacy mappings are explicitly reconciled, a newly authenticated scoped identity can receive a separate customer record, not the old account's history. The original records remain available to appropriately authorized operators.

Before enabling traffic:

  1. Back up data; stop exchanges/writes for the affected scope during reconciliation.
  2. Run the read-only scripts/audit-customer-identity-scopes.sql reports against an approved staging copy after the additive columns exist. Keep report output private: it contains customer identifiers.
  3. Establish channel and issuer ownership using trustworthy host-side evidence. Legacy user rows alone cannot distinguish JWT from HMAC provenance.
  4. For a proven unique mapping, review a transaction setting both its scope and computed claim key while retaining its customer ID and conversation history. Check all associated conversation channels before making a change.
  5. Stop on duplicate, inactive, multi-channel or mixed-trust mappings. Never take the first row or merge customers solely because external IDs match. If a new scoped record already exists, reconcile both histories explicitly.
  6. Reissue customer tokens and verify authorized history, isolation, and rollback behavior on the staging copy before resuming traffic.

A channel reassigned to a different identity issuer needs a new channel code. Ordinary signing-key rotation intentionally retains the same issuer scope. Use a distinct host JWT signing secret for each independently trusted channel. The existing host JWT contract validates the channel's secret and expiry, but does not require a channel audience claim. Reusing a signing key across unrelated channels lets the holder assert an identity to either channel; scoped storage is not a defense against a shared issuer key. HMAC proofs bind the channel code inside the signed payload. No mandatory JWT audience rollout is introduced here. Legacy duplicate repair remains an operator-reviewed migration, not an automatic startup side effect. The nullable unique claim allows dirty legacy rows to remain isolated without blocking new correctly scoped registrations.

Anonymous and authenticated-only channels

Unsigned exchange issues a new random server-owned anonymous identity. Supplying an old guest external ID is not proof of ownership. Normal resume and refresh use the signed customer-session token. Near-expiry, still-valid credentials are renewed through the rate-limited POST /api/customer/session_refresh endpoint, which authenticates the existing session and never creates an anonymous identity. A refresh failure is not silently replaced with a fresh guest. Losing or expiring that credential does not authorize reclaiming history by an external ID.

An optional boolean requireSignedIdentity in a web channel's configJson rejects unsigned exchanges and existing guest sessions server-side. Default false retains anonymous channels. The SDK's requireUserToken is not a substitute for this policy.

Use the existing authenticated channel GET and POST /api/dashboard/channel/update flow with channel.update permission, preserving all unrelated configuration and required fields. No dedicated new UI toggle is provided. Older editors which omit this property preserve a configured true value; an explicit JSON false is needed to disable it. Null and nonboolean values are rejected. This source change does not configure a live channel.

Staff and customer live-session revocation

Staff sockets bind their original login-session ID and absolute expiry, without serializing that internal metadata into API responses. Fresh session, account, role and permission state is checked for incoming operations, event fanout, actual queued writes and idle checks. Scope changes terminate the connection; reconnection must obtain fresh authority. Idle checks run every five seconds; data operations revalidate immediately rather than using a five-second cache.

Notification sockets cannot subscribe to conversation topics. Dashboard conversation sockets require conversation-view permission. Customer connections recheck token expiry, channel policy, customer state and exact identity mapping.

This implementation deliberately makes database reads at authorization boundaries. Measure the cost under realistic fanout; any later caching must have complete revocation invalidation rather than silently reintroducing stale access.

Channel configuration updates and token-secret resets compare the previously read configuration bytes and update timestamp before writing. A stale writer receives a conflict instead of silently overwriting a newly enabled signed-identity policy. Reload the channel and reapply the intended change after reviewing that conflict.