Skip to main content

Auth, ownership & security

In one line: the agent talks to Business Central as a single OAuth2 service account, approval links are HMAC-signed, and tenancy is enforced in the application layer (not by BC) through ownership gates — but those gates are applied inconsistently, and 9 of 11 webhooks are unauthenticated.

What it is

The agent authenticates to BC as one service account via OAuth2 client-credentials. Tenancy/ownership is enforced in the application layer: a sender (tenant or vendor) is matched to a BC customer/vendor by phone or email, and a ticket is "owned" by a tenant only if its tenantCustomerId equals the sender's entity id (by a vendor only if assignedVendorId matches). Any uncertainty denies ownership. The magic-link approval flow is protected by an HMAC-signed token because Lua webhooks have no built-in signature check.

How it works

BC OAuth2 (client-credentials) — src/services/bc-auth.ts

  • Token URL https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token, scope https://api.businesscentral.dynamics.com/.default (:10-11).
  • Grant client_credentials with client_id/client_secret from env, with hardcoded fallbacks (:19-25).
  • Token cached in-process, auto-refreshed 5 min before expiry (:14,31-57); clearTokenCache() for forced re-auth.

⚠️ A real-looking client secret is hardcoded as the fallback at bc-auth.ts:23. The live sandbox/local secret is the placeholder your-client-secret-here (BC calls 401 offline), but this production-shaped value sits in source — flag for rotation/removal.

Approval-token HMAC — src/utils/approval-token.ts

  • Why: Lua webhooks have no built-in HMAC/signature validation and the approval-decision URL is publicly addressable, so without a signed token anyone could forge ?ticketId&decision=approve (:1-15).
  • Format: base64url(payloadJSON).base64url(HMAC-SHA256(payloadJSON)); payload = { ticketId, companyId, decision, expiresAt }.
  • Secret: APPROVAL_TOKEN_SECRET, required — throws if unset.
  • TTL: 7 days. Verify (:76-127) does structural checks → constant-time signature compare (timingSafeEqual) → payload checks → decision must be approve/reject → expiry. Distinct failure reasons: secret-not-set, malformed, bad-signature, malformed-payload, bad-decision, expired.

⚠️ The GET-based magic-link buttons never worked in practice (Lua webhooks are POST-only) — reply-based approval became the live path. The HMAC util is correct; whether it's on a live path is ⚠️ Unverified.

Ownership / cross-tenant gates — src/utils/thread-ticket-resolver.ts

  • Sender → entity match: findUserCompany searches all companies (Tribeca first) by phone and case-insensitive email, customers then vendors; vendor matches fetch $top:25 and pickPreferredVendor prefers an active record (bc-entities.ts:88-231).
  • Ownership fields: tenant = tenantCustomerId === identity.entityId; vendor = assignedVendorId === identity.entityId; chosen by identity.entityType (isTicketOwnedBy, :185-193).
  • Gate (R3): verifyTicketOwnership (:200-220) — deny on any uncertainty: no ticketId → no-ticket; missing identity → no-identity; fetcher throws → lookup-error; not found → not-found; else owned/mismatch. Returns the ticket only when owned (avoids leaking data + saves a call).
  • Thread resolution tiers (R2, first match wins): Tier 1 explicit body id (LUA-REF: > Ref: > bare MT-…), Tier 2 subject id, Tier 3 references[]/In-Reply-Toemail_msgid_ticket map (newest non-stale within 30 days). Tier 4 (user_active_ticket guess) + the ownership gate are the caller's job.
  • Synthetic-block defense: body text is stripped of injected [CURRENT THREAD HINT]/[PRIOR THREAD]/[SYSTEM CONTEXT] before ID extraction so the agent's own injected context can't be mistaken for the sender's ticket reference (strip-synthetic-blocks.ts).

Gotchas & failure modes

  1. Inconsistent ownership enforcement (security-relevant). Owner gates are NOT uniform:
    • Has owner gate: my_tickets (every action), confirm_visit_time, record_tenant_feedback.
    • No tool-level owner gate (operate by companyId+ticketId for any caller): search_maintenance_history, update_ticket_details, upload_issue_images, close_ticket, record_completion_docs, record_tenant_dispute, request_tenant_confirmation, notify_tenant_access, upload_vendor_photos, update_vendor_metrics. These rely on the preprocessor role-content-guard + thread-ownership gate + the persona, not a tool-level check.
  2. 9 of 11 webhooks are unauthenticated. Anyone with the public webhook.heylua.ai/{agentId}/{name} URL can POST. Most dangerous: clear-data (deletes ticket data, gated only by {confirm:true}), finance-approval (forge an approval), escalation-response and assign-vendor (mutate ticket state). Only approval-decision is HMAC-protected; inbound-email only sanity-checks the AgentMail event shape. See APIs, webhooks & tools.
  3. Cross-tenant binding is the historical leak class. Inbound thread→ticket binding is ownership-gated through 4 tiers (current-thread-ticket-injection.preprocessor.ts:294-314, [ALERT][cross-tenant-bind-blocked]). Bypassing it re-opens the MT-2026-056 leak.
  4. Hardcoded BC client secret in source (see above) — rotate/remove regardless of the env override.
  5. Tenancy is application-layer, not BC-enforced. BC sees one service account; if the app-layer gates are bypassed, BC will not stop a cross-tenant read.