nyxcore-systems
5 min read

Unlocking Shared Data: A Deep Dive into Multi-Tenant Visibility Fixes

We tackled a critical multi-tenant visibility bug, ensuring all team members can access shared project data. Learn about our tRPC query overhaul, unexpected Auth.js hashing quirks, and WhatsApp's bot-induced magic link woes.

multi-tenancytRPCNext.jsAuth.jsbugfixdatabasedevelopmenttypescript

Building multi-tenant applications comes with its own unique set of challenges. One of the most fundamental is ensuring that users can see the data they're supposed to, and only the data they're supposed to. Recently, we faced a classic scenario where shared project data wasn't visible to all members of a specific tenant, particularly those with a 'viewer' role.

Our mission: to ensure that every member of the ckb-nyx tenant, regardless of their role, could access and view shared project data on their dashboard.

The Case of the Invisible Projects

The core of the problem lay in our data fetching logic. While our system correctly isolated data by tenantId, many of our tRPC query procedures were also filtering by userId. This meant that even if a user was part of the ckb-nyx tenant, they could only see project-related data if they were the original creator (or if their userId was explicitly tied to the data in the query). Viewers, who by definition don't create data, were left staring at empty dashboards.

The Fix: A Surgical Strike on userId Filters

Our solution involved a targeted refactor of our projects.ts tRPC router. We meticulously audited 10 query procedures, removing the userId: ctx.user.id filter from each. These procedures spanned various data types, including:

  • healthCheck
  • stats
  • notes.list
  • docs.list and docs.get
  • blogPosts.list, blogPosts.get, and blogPosts.unblogged
  • overview

By removing the userId filter from these queries, they now exclusively rely on tenantId for data partitioning. This simple but critical change means that any user belonging to the ckb-nyx tenant can now see all relevant project data, irrespective of who originally created it.

It's crucial to note that this change only applied to queries. Our mutation procedures (like update, delete, publishBlog, unpublishBlog) still retain userId in their WHERE clauses, ensuring that ownership and modification rights are correctly enforced. This maintains a robust security posture while enhancing data visibility.

The Foundation We Built On

This wasn't an isolated change. It built upon previous work to harden our multi-tenancy:

  • Role-Based Access Control: We had already implemented enforceMutationRole middleware across 19 feature routers, effectively blocking 'viewer' roles from performing any mutations.
  • Initial Query Clean-up: Earlier sessions saw userId filters removed from projects.list, projects.get, and several queries within memory.ts and consolidation.ts.
  • Data Migration & User Setup: A full data copy from our nyx tenant to ckb-nyx was completed, and a test viewer account (lisa@tastehub.io) was set up to validate the changes.

After deploying the fix, we verified a clean TypeScript build and a perfect 271/271 test pass rate. The commit fbf1d16 ("fix: remove userId from project query procedures for shared tenant visibility") now lives on our production server.

Our Debugging Adventures: Lessons Learned

Even with a clear goal, the path to production is rarely smooth. We encountered a few interesting challenges along the way, offering valuable lessons:

1. The Peculiar Case of Auth.js Magic Link Hashing

The Problem: We needed to manually generate a magic link token for a user. Our first instinct was to use a shell command like sha256sum to hash the token. It seemed straightforward.

The Failure: The generated hash simply didn't match what Auth.js was expecting.

The Insight: Auth.js's internal hashing mechanism for magic links isn't just a simple SHA-256. It leverages the Web Crypto API and appends the AUTH_SECRET to the token before hashing. This subtle difference meant our external shell command was producing an incompatible hash.

The Workaround: We spun up a temporary Node.js script inside our application container and used crypto.subtle.digest("SHA-256", ...) to accurately replicate Auth.js's hashing process.

Lesson: For sensitive operations like authentication token generation, always use the exact same library and environment the primary system uses. Don't assume external tools will replicate internal, framework-specific logic.

2. WhatsApp vs. Single-Use Magic Links

The Problem: We tried sending magic links to a user via WhatsApp for convenience.

The Failure: The links were immediately consumed and invalidated before the user could even click them.

The Insight: WhatsApp's bot actively crawls URLs pasted into chats to generate rich previews. For single-use verification tokens, this "pre-fetching" acts as a click, invalidating the link before the intended recipient can use it.

The Workaround: Users must send the magic link as plain text (without a preview) or, more reliably, use email delivery where such bot crawling isn't an issue.

Lesson: Be mindful of how external communication platforms interact with time-sensitive, single-use URLs. Not all platforms are created equal when it comes to link previews.

3. Schema Drift and insightScope

The Problem: While performing a data migration via raw SQL INSERT statements for workflow_insights, we included an insightScope column.

The Failure: The INSERT failed on the production server.

The Insight: The insightScope column hadn't been deployed to the production database schema yet. It existed in our development environment but was missing in production.

The Workaround: We temporarily removed insightScope from the INSERT statement to complete the migration.

Lesson: Always ensure your database schema is in sync across all environments, especially production, before attempting data operations that rely on new columns. Automated schema migrations are your best friend here.

The Road Ahead

While this critical visibility bug is squashed, our work continues. Here's what's next on our immediate roadmap:

  1. User Verification: The absolute top priority is to verify that Lisa (our test viewer user) can now successfully load project detail pages on the ckb-nyx tenant.
  2. Router Audit: We'll conduct a broader audit of other routers (admin.ts, nyxbook.ts, axiom.ts, code-analysis.ts) to ensure no other shared data queries are inadvertently filtering by userId. (Our wardrobe.ts router is intentionally user-scoped, so it's exempt).
  3. Mutation Ownership Review: We'll discuss with the team whether userId should remain in the WHERE clauses of mutation procedures in projects.ts. While it currently ensures only the creator can modify, there might be a future need for team-based editing.
  4. Infrastructure Tasks: Setting AUDIT_CRON_SECRET on production and configuring an hourly cron job are pending tasks that need to be completed.
  5. Schema Migration: The insightScope column on the workflow_insights table still needs to be officially migrated to our production schema.

Ensuring seamless data access is paramount for a collaborative multi-tenant application. This session brought us significant steps closer to that goal, and we're excited for what's next!