Closing enterprise software deals often stalls on a non-negotiable security requirement: Security Assertion Markup Language (SAML) 2.0 single sign-on (SSO) combined with fine-grained, dynamic Role-Based Access Control (RBAC). Mid-market and Fortune 500 buyers refuse to manage separate credentials in your application. They demand integration with their identity providers (IdPs) like Azure Entra ID (formerly Azure AD), Okta, Ping Identity, or Google Workspace, paired with a deterministic permission matrix that maps external groups to internal application roles.
Building a multi-tenant authorization and authentication architecture that scales past tens of thousands of enterprise users requires a strict separation of concerns. You must decouple raw identity assertion ingestion from your application's internal permission boundaries.
The Enterprise Identity Lifecycle
Enterprise authentication relies on the SAML 2.0 Web Browser SSO profile. When an enterprise user attempts to access your SaaS platform, your application issues an authentication request (AuthnRequest) to the customer’s IdP. The IdP authenticates the user via MFA, corporate credentials, or conditional access policies, and posts a digitally signed XML assertion back to your Assertion Consumer Service (ACS) endpoint.
[Enterprise User] ---> (1. GET /sso/saml/:tenant) ---> [Your SaaS App (ACS)]
|
(2. Redirect AuthnRequest)
|
v
[Enterprise IdP (Okta/Azure)] <-------------------------------+
|
(3. MFA & Auth)
|
v
[Enterprise User] ---> (4. POST /sso/saml/consume + XML Assertion) ---> [Your SaaS App]
Your service must parse this XML securely, validate digital signatures against the IdP’s X.509 certificate, verify timestamp validity windows to prevent replay attacks, and extract group membership attributes for RBAC assignment.
Database Schema for Multi-Tenant RBAC
To support enterprise-grade RBAC across hundreds of isolated tenants, avoid global permission tables. Instead, implement a tenant-scoped permission matrix where identities are bound dynamically via claims mapping.
| Table Name | Purpose | Key Columns |
|---|---|---|
tenants |
Organization root context | id, slug, saml_issuer, saml_sso_url, saml_x509_cert |
roles |
Tenant-scoped permission groupings | id, tenant_id, name, description |
permissions |
Granular atomic capability flags | id, name (e.g., invoices:write) |
role_permissions |
Many-to-many join for roles and permissions | role_id, permission_id |
tenant_users |
Junction linking user identity to tenant | id, tenant_id, user_id, external_id |
user_roles |
Assignment of tenant roles to users | tenant_user_id, role_id |
Implementing SAML Assertion Parsing and RBAC Mapping (TypeScript)
The following TypeScript service uses a robust SAML library to process incoming assertions, extract enterprise group claims, and map them to application roles inside a transactional context.
import { saml } from '@node-saml/node-saml';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
interface SamlProfile {
nameID: string;
email: string;
groups?: string[];
[key: string]: any;
}
export class EnterpriseAuthService {
private getSamlStrategy(tenantId: string, idpConfig: any) {
return new saml.Saml({
entryPoint: idpConfig.saml_sso_url,
issuer: `https://app.bricktry.com/saml/${tenantId}/metadata`,
cert: idpConfig.saml_x509_cert,
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
});
}
async handleAssertion(tenantId: string, samlResponse: string): Promise<SamlProfile> {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new Error(`Tenant ${tenantId} not found.`);
const strategy = this.getSamlStrategy(tenantId, tenant);
return new Promise((resolve, reject) => {
strategy.validatePostResponse({ Body: { SAMLResponse: samlResponse } }, async (err, profile) => {
if (err || !profile) {
return reject(new Error(`SAML validation failed: ${err?.message}`)).
}
try {
const mappedUser = await this.syncUserAndRoles(tenantId, profile as SamlProfile);
resolve(mappedUser);
} catch (dbError) {
reject(dbError);
}
});
});
}
private async syncUserAndRoles(tenantId: string, profile: SamlProfile) {
const email = profile.email || profile.nameID;
const externalGroups = profile.groups || [];
return await prisma.$transaction(async (tx) => {
// 1. Upsert base user
let user = await tx.user.findUnique({ where: { email } });
if (!user) {
user = await tx.user.create({ data: { email } });
}
// 2. Link user to tenant
let tenantUser = await tx.tenantUser.findUnique({
where: { tenantId_userId: { tenantId, userId: user.id } },
});
if (!tenantUser) {
tenantUser = await tx.tenantUser.create({
data: { tenantId, userId: user.id, externalId: profile.nameID },
});
}
// 3. Resolve incoming IdP groups to internal roles
const matchedRoles = await tx.role.findMany({
where: {
tenantId,
name: { in: externalGroups },
},
});
// 4. Sync role assignments atomically
await tx.userRole.deleteMany({ where: { tenantUserId: tenantUser.id } });
if (matchedRoles.length > 0) {
await tx.userRole.createMany({
data: matchedRoles.map((role) => ({
tenantUserId: tenantUser.id,
roleId: role.id,
})),
});
}
return { user, tenantUser, roles: matchedRoles };
});
}
}
Securing API Endpoints with Dynamic Permission Grids (Node.js/Express)
Once a user is authenticated via SAML and their roles are mapped, downstream API requests must evaluate granular permissions rather than broad role titles.
import { Request, Response, NextFunction } from 'express';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export function requirePermission(requiredPermission: string) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.user?.id; // Populated by JWT session middleware
if (!tenantId || !userId) {
return res.status(401).json({ error: 'Unauthorized: Missing tenant or user context.' });
}
// Optimized query utilizing compound database indexes
const permissionCheck = await prisma.tenantUser.findFirst({
where: {
tenantId,
userId,
userRoles: {
some: {
role: {
rolePermissions: {
some: {
permission: {
name: requiredPermission,
},
},
},
},
},
},
},
});
if (!permissionCheck) {
return res.status(403).json({
error: `Forbidden: Lacks required permission [${requiredPermission}]`
});
}
next();
} catch (error) {
console.error('Authorization Evaluation Error:', error);
return res.status(500).json({ error: 'Internal authorization error.' });
}
};
}
How BrickTry Accelerates & Powers This
Designing, testing, and debugging SAML assertion flows across diverse enterprise IdPs like Okta, Azure AD, and Shibboleth is notoriously time-consuming. BrickTry eliminates this friction through an integrated suite of architectural tooling:
- BrickTry Lab Sandbox (
/lab): Instantly spin up an isolated in-browser Node.js/Vite virtual container runtime to prototype SAML ACS endpoints, simulate incoming XML assertions, and test certificate verification without leaving your browser. - AI-Human Dev Pairing: Leverage autonomous AI agents to generate initial SAML XML parsing boilerplate, unit test suites, and database migration scripts, paired with senior full-stack engineering review pods to ensure your session handling and token encryption meet SOC2 and ISO 27001 compliance.
- Interactive Scoping Engine: Translate enterprise security questionnaires and multi-tenant requirements into structured database schemas, RBAC models, and automated deployment configurations.
- 100% Source Code Ownership: Maintain complete ownership of your GitHub repositories, Docker configurations, and relational database migrations with zero vendor lock-in.
Build, Test, and Scale This on BrickTry
BrickTry pairs you with autonomous AI scaffolding supervised by dedicated senior full-stack software engineers in an interactive in-browser development sandbox. Test, build, and deploy production-grade software with 100% source code ownership and zero vendor lock-in.