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, scopehttps://api.businesscentral.dynamics.com/.default(:10-11). - Grant
client_credentialswithclient_id/client_secretfrom 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 placeholderyour-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 beapprove/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:
findUserCompanysearches all companies (Tribeca first) by phone and case-insensitive email, customers then vendors; vendor matches fetch$top:25andpickPreferredVendorprefers an active record (bc-entities.ts:88-231). - Ownership fields: tenant =
tenantCustomerId === identity.entityId; vendor =assignedVendorId === identity.entityId; chosen byidentity.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; elseowned/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:> bareMT-…), Tier 2 subject id, Tier 3references[]/In-Reply-To→email_msgid_ticketmap (newest non-stale within 30 days). Tier 4 (user_active_ticketguess) + 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
- 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+ticketIdfor 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 preprocessorrole-content-guard+ thread-ownership gate + the persona, not a tool-level check.
- Has owner gate:
- 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-responseandassign-vendor(mutate ticket state). Onlyapproval-decisionis HMAC-protected;inbound-emailonly sanity-checks the AgentMail event shape. See APIs, webhooks & tools. - 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. - Hardcoded BC client secret in source (see above) — rotate/remove regardless of the env override.
- 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.