nyxcore-systems
8 min read

Shipping Multi-Tenancy: A Deep Dive into Building Team Management for NyxCore

Join us as we recount the intense development sprint to implement a full multi-tenant team management system, from database schema to secure API endpoints and the inevitable bumps along the way.

multi-tenancyprismatrpcnextjsrbacsecuritydockerdevopslessons-learned

Building a robust SaaS application often leads to the need for multi-tenancy – allowing multiple organizations (tenants) to use the same software instance while keeping their data completely separate and secure. This past week, we embarked on a focused mission to implement a comprehensive multi-tenant team management system for NyxCore, and I'm thrilled to report that all 8 phases are complete, deployed, and live!

This post isn't just a victory lap; it's a technical deep dive into the architecture, the decisions made, and crucially, the hard-won lessons from the trenches. If you're building similar systems, grab a coffee – this one's for you.

The Core Challenge: Empowering Teams with Multi-Tenancy

Our goal was clear: give users the ability to create and manage teams (tenants), invite members, assign roles, and securely integrate with external services like GitHub, all within a multi-tenant context. This meant touching everything from our database schema to our API layer, frontend UI, and deployment pipeline.

Phase 1: Laying the Database Foundation with Prisma

The journey began by extending our Prisma schema to support the core entities required for team management. We introduced:

  • Invitation Model: To track pending invites, including who invited whom and when.
  • GitHubToken Model: For securely storing encrypted GitHub access tokens linked to a specific tenant.
  • TenantMember Enhancements: Added fields like invitedAt, invitedBy, joinedAt, and a relation to the inviter to provide a full audit trail of team membership.

This foundational work was critical for ensuring data integrity and providing the necessary hooks for our business logic.

typescript
// Simplified Prisma Schema snippet
model Invitation {
  id        String    @id @default(uuid())
  tokenHash String    @unique // SHA-256 hash of the magic link token
  tenantId  String
  tenant    Tenant    @relation(fields: [tenantId], references: [id])
  email     String
  expiresAt DateTime
  createdAt DateTime  @default(now())
  invitedBy String?
  inviter   User?     @relation("InvitedByUser", fields: [invitedBy], references: [id])
}

model GitHubToken {
  id        String    @id @default(uuid())
  tenantId  String    @unique
  tenant    Tenant    @relation(fields: [tenantId], references: [id])
  encryptedToken String // AES-256-GCM encrypted token
  iv        String     // Initialization Vector
  tag       String     // Authentication Tag
}

Phase 2: Fortifying Access with Role-Based Access Control (RBAC)

Multi-tenancy without robust RBAC is like a house without locks. We implemented a TenantRole type and utility functions (hasMinRole, canManageRole) in src/lib/roles.ts. The real power came from integrating this into our tRPC middleware: enforceTeamRole.

This middleware ensures that any API procedure requiring a specific role automatically checks the user's permissions within their active tenant.

typescript
// src/server/trpc/middleware.ts (Conceptual snippet)
import { TRPCError } from '@trpc/server';
import { middleware } from '../trpc';
import { hasMinRole, TenantRole } from '~/lib/roles';

export const enforceTeamRole = (minRole: TenantRole) =>
  middleware(async ({ ctx, next }) => {
    if (!ctx.session?.user || !ctx.session.user.activeTenantId) {
      throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated or no active tenant.' });
    }

    const tenantMember = await ctx.db.tenantMember.findUnique({
      where: {
        userId_tenantId: {
          userId: ctx.session.user.id,
          tenantId: ctx.session.user.activeTenantId,
        },
      },
      select: { role: true },
    });

    if (!tenantMember || !hasMinRole(tenantMember.role, minRole)) {
      throw new TRPCError({ code: 'FORBIDDEN', message: 'Insufficient team role.' });
    }

    return next({
      ctx: {
        ...ctx,
        // Potentially add the tenantMember or its role to the context for downstream procedures
        tenantMemberRole: tenantMember.role,
      },
    });
  });

// Usage in a router:
// .middleware(enforceTeamRole('ADMIN'))

Phase 3-5: The Backend Engine - Invitations, Teams, and Secure Integrations

With RBAC in place, we built out the core services and API endpoints:

  • src/server/services/invitation-service.ts: Handles the lifecycle of team invitations – generating secure magic link tokens, SHA-256 hashing them for storage, validating, and atomically consuming them in a transaction when a user joins.
  • src/server/trpc/routers/teams.ts: This became the heart of team management, exposing 9 procedures covering:
    • currentRole, members, pendingInvitations (for reads)
    • invite, revokeInvitation, updateRole, removeMember, updateSettings (for writes)
    • details (for tenant information)
  • src/server/services/github-token-service.ts: A crucial security component. We implemented Bring Your Own Key (BYOK) AES-256-GCM encryption for sensitive data like GitHub access tokens. This means encryption keys are managed by the application owner, not necessarily the database, adding an extra layer of protection.
  • src/server/trpc/routers/github.ts: Four dedicated procedures to manage GitHub integrations, leveraging the secure token service.

Phase 6-8: Bringing it to Life - Frontend & Final Touches

No backend is complete without a user interface!

  • Magic Link Invite Route (src/app/api/auth/invite/[token]/route.ts): This API route is the landing page for invited users, validating the magic token and guiding them through the joining process.
  • Team Management UI (src/app/(dashboard)/dashboard/team/page.tsx): A comprehensive page for team owners/admins to view members, invite new ones, update roles, and remove users.
  • src/hooks/useTeamRole.ts & src/components/team/role-gate.tsx: Custom hooks and components to easily integrate role-based UI rendering and access control directly into our React components, ensuring users only see and interact with what their role permits.
  • Sidebar Link & Minor UI Fixes: A simple but necessary addition to src/components/layout/sidebar.tsx to make the new team page accessible. We also caught a minor UI bug where Badge variant="outline" was used, but the component only supported default | success | accent | warning | danger. A quick fix to variant="default" resolved it.

Production Readiness & Security

Finally, the new schema was pushed to our production database, and critically, Row-Level Security (RLS) policies were applied to invitations and github_tokens tables. This ensures that even if an attacker bypasses our application logic, the database itself will prevent unauthorized access to sensitive tenant-specific data.

An example RLS policy for invitations might look like:

sql
-- Conceptual RLS policy for 'invitations' table
CREATE POLICY tenant_invitations_policy ON invitations
  FOR ALL
  USING (tenant_id = (SELECT active_tenant_id FROM users WHERE id = current_user_id()));

With all checks green and lint clean, the new features were live!

Navigating the Minefield: Lessons Learned (The "Pain" Log)

No deployment is ever perfectly smooth. Here are some critical lessons we learned during this sprint, which hopefully save you some headaches:

  1. UI Component Specificity is King:
    • Problem: Using Badge variant="outline" on our team page resulted in a TypeScript error (TS2322) because the component only supported specific variants (default, success, accent, warning, danger).
    • Takeaway: Always refer to component documentation or type definitions. Assume nothing about component props, especially across different versions or libraries. A quick variant="default" provided the desired bordered look.
  2. SSH Access Management Matters:
    • Problem: Attempting ssh deploy@46.225.232.35 failed with "Permission denied" because the deploy user's SSH key wasn't configured on the server.
    • Takeaway: Ensure all necessary SSH keys are properly configured for specific users on your deployment targets. Relying on root access (even if it works without an explicit key file) for routine deployments is generally a bad practice for security and auditing.
  3. Docker Networking Can Be Tricky:
    • Problem: After deployment, curl localhost:3000 on the production host failed to reach our application.
    • Takeaway: Remember that services inside Docker containers are isolated within their own network. localhost:3000 from the host refers to the host's localhost, not the container's. Health checks should target the exposed port, usually through a reverse proxy like Nginx (e.g., curl https://nyxcore.cloud/api/v1/health).
  4. Pin Your Tooling Versions (Especially in Docker):
    • Problem: Running npx prisma db push --skip-generate inside our Docker container fetched Prisma 7.x, which dropped support for --skip-generate, leading to an error. Our local setup used Prisma 5.x.
    • Takeaway: Always pin critical tooling versions, especially in automated environments like Dockerfiles or CI/CD pipelines, to ensure consistent behavior. Explicitly using npx prisma@5.22.0 db push resolved the issue. (A side note: a harmless EACCES error on generate inside the container can be ignored if the built image already contains the correct Prisma client.)

What's Next?

While the core system is live, development never truly stops. Our immediate next steps include:

  1. Applying enforceTeamRole RBAC to existing routers (e.g., NyxBook: viewer for reads, member for writes; Wardrobe: member for all).
  2. Adding email sending for invitation magic links (currently the token is generated but not dispatched).
  3. Developing a team switcher UI for users belonging to multiple tenants.
  4. Conducting end-to-end testing of the invitation flow on production.
  5. Continuing our dual-provider integration work from the previous session.

Conclusion

Shipping a complex feature like multi-tenant team management is a significant milestone. It involved careful database design, robust RBAC implementation, secure handling of sensitive data, comprehensive API development, and a user-friendly frontend. The journey wasn't without its bumps, but each "pain" transformed into a valuable "lesson learned," making our system and our team stronger.

We're excited about the new capabilities this brings to NyxCore users and looking forward to refining it further. Stay tuned for more updates!

json
{
  "thingsDone": [
    "Extended Prisma schema (Invitation, GitHubToken, TenantMember fields)",
    "Implemented RBAC with TenantRole type and enforceTeamRole tRPC middleware",
    "Created invitation service (generate, hash, validate, consume tokens)",
    "Developed comprehensive tRPC teams router (9 procedures)",
    "Implemented GitHub token service with BYOK AES-256-GCM encryption",
    "Created tRPC GitHub router (4 procedures)",
    "Built magic link invite API route",
    "Developed full team management UI",
    "Created useTeamRole hook and RoleGate component for frontend RBAC",
    "Added team link to sidebar",
    "Fixed Badge component variant issue",
    "Pushed schema to production DB",
    "Applied RLS policies for invitations and github_tokens tables",
    "Achieved typecheck and lint cleanliness"
  ],
  "pains": [
    {
      "problem": "Badge component variant mismatch (TS2322 for 'outline')",
      "solution": "Changed to 'default' variant",
      "lesson": "Always check component documentation/type definitions for valid props."
    },
    {
      "problem": "SSH permission denied for 'deploy' user on production server",
      "solution": "Used 'root' user (without explicit key file)",
      "lesson": "Ensure proper SSH key configuration for specific deployment users; avoid root for routine tasks."
    },
    {
      "problem": "curl localhost:3000 failed from production host (Docker networking)",
      "solution": "Health checked via Nginx exposed endpoint (https://nyxcore.cloud/api/v1/health)",
      "lesson": "Understand Docker's internal networking vs. host exposure; use public endpoints for external checks."
    },
    {
      "problem": "Prisma db push failed in Docker due to version mismatch ('--skip-generate' removed in v7.x)",
      "solution": "Pinned Prisma version to 5.22.0 for db push command",
      "lesson": "Explicitly pin critical tooling versions in build/deployment environments to ensure consistency."
    }
  ],
  "successes": [
    "Successfully implemented full multi-tenant team management system",
    "Deployed all features to production",
    "Secured sensitive data with BYOK AES-256-GCM encryption",
    "Established robust RBAC system at API and UI layers",
    "Applied database-level security with RLS policies"
  ],
  "techStack": [
    "Next.js",
    "tRPC",
    "Prisma",
    "PostgreSQL",
    "React",
    "TypeScript",
    "Docker",
    "Nginx",
    "AES-256-GCM"
  ]
}