Mapping External IdP Groups to Tenant Roles

An external IdP hands you opaque group strings like acme-engineering-prod; your application needs an authoritative answer to "what may this user do inside this tenant?" — and getting that translation wrong grants cross-tenant access. This page sits under SSO Mapping & Identity Federation and shows how to turn SAML/OIDC group claims into per-tenant roles with an explicit mapping table, just-in-time provisioning, default-deny, and deterministic conflict resolution.

Problem Framing

A customer's identity provider — Okta, Entra ID, Google Workspace, Ping — owns the user's group memberships. Those groups exist for the customer's own purposes: org charts, departments, mailing lists. They are not your authorization model, and you do not control their naming, their lifecycle, or their values. When a SAML assertion or an OIDC ID token arrives, the groups claim is just an array of strings. Your job is to map that array onto the roles your application actually enforces, scoped to exactly one tenant.

The decision matters because the group claim is the single most dangerous input in a federated login. Two failures recur. The first is over-grant by string collision: two tenants both have a group called admins, and a naive global mapping promotes a tenant B user to administrator because their token happened to contain admins. The second is implicit allow: an unmapped group is treated as "no harm, just no extra access" — until that group is the only thing standing between a deactivated contractor and your data. The correct posture is the inverse. Every mapping is keyed by (tenant_id, idp_group), and any user who maps to zero roles is denied, not admitted with a blank role set. The roles you produce here feed directly into the enforcement layer described in role-based access control per tenant, so an error here is an error in every downstream permission check.

Step-by-Step Guide

1. Define a tenant-scoped mapping table

Make the table the source of truth, keyed on (tenant_id, idp_group) with a unique constraint so the same group string in two tenants can never collide. Store a priority column to break conflicts deterministically (higher number = more privileged) and an is_active flag so a mapping can be disabled without deletion.

CREATE TABLE idp_group_role_map (
  id          BIGSERIAL PRIMARY KEY,
  tenant_id   TEXT    NOT NULL,
  idp_group   TEXT    NOT NULL,   -- exact string from the claim
  role        TEXT    NOT NULL,   -- internal role, e.g. 'admin'
  priority    INT     NOT NULL DEFAULT 0,
  is_active   BOOLEAN NOT NULL DEFAULT TRUE,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, idp_group, role)
);

CREATE INDEX idx_group_map_lookup
  ON idp_group_role_map (tenant_id, idp_group)
  WHERE is_active;

2. Extract the group claim defensively

The claim name and shape vary by IdP: Okta and Entra emit groups, some SAML setups emit a single comma-joined string, and a user in no groups may emit null, [], or omit the key entirely. Normalize all of these to a string array before lookup, and never trust a tenant_id from the request body — it comes only from the verified token, exactly as in the Okta SSO integration flow.

type Claims = Record<string, unknown>;

export function extractGroups(claims: Claims): string[] {
  const raw = claims["groups"] ?? claims["http://schemas.xmlsoap.org/claims/Group"];
  if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
  if (typeof raw === "string") {
    return raw.split(",").map((g) => g.trim()).filter(Boolean);
  }
  return []; // null, undefined, or unexpected shape -> no groups
}

3. Resolve groups to roles within the tenant

Query only the rows for the verified tenant and the user's groups. The WHERE tenant_id = $1 clause is what makes cross-tenant collision impossible — a admins group from another tenant is never in the result set because its rows carry a different tenant_id.

import type { Pool } from "pg";

interface RoleGrant { role: string; priority: number; }

export async function resolveRoles(
  db: Pool, tenantId: string, groups: string[],
): Promise<RoleGrant[]> {
  if (groups.length === 0) return [];
  const { rows } = await db.query<RoleGrant>(
    `SELECT DISTINCT role, priority
       FROM idp_group_role_map
      WHERE tenant_id = $1 AND idp_group = ANY($2) AND is_active`,
    [tenantId, groups],
  );
  return rows;
}

4. Apply default-deny

A user who maps to zero roles must be rejected with a logged 403, not admitted with an empty array. The empty-array path is where contractors keep access after their group is removed; making it a hard denial closes that gap.

import { HttpError } from "./errors";

export function enforceDefaultDeny(
  tenantId: string, sub: string, grants: RoleGrant[],
): void {
  if (grants.length === 0) {
    logger.warn({ event: "idp_mapping_denied", tenantId, sub });
    throw new HttpError(403, "NO_MAPPED_ROLE");
  }
}

5. Resolve conflicts deterministically

When a user's groups map to several roles, decide on purpose. Two patterns are defensible: union (grant every mapped role, additive) or highest-privilege-wins (collapse to the single role with the greatest priority). For sensitive tenants, highest-privilege-wins with explicit ranking avoids accidental escalation through an additive grant the customer did not intend.

export function resolveConflict(
  grants: RoleGrant[], strategy: "union" | "highest",
): string[] {
  if (strategy === "union") {
    return [...new Set(grants.map((g) => g.role))];
  }
  const top = grants.reduce((a, b) => (b.priority > a.priority ? b : a));
  return [top.role];
}

6. Just-in-time provision the membership

On a successful, non-empty resolution, upsert the user and reconcile their role set on every login so the IdP stays authoritative. Roles removed at the IdP must be removed in your store — JIT provisioning that only ever adds roles leaks privilege over time.

INSERT INTO tenant_user (tenant_id, sub, email, last_login)
VALUES ($1, $2, $3, now())
ON CONFLICT (tenant_id, sub)
DO UPDATE SET email = EXCLUDED.email, last_login = now();

DELETE FROM tenant_user_role
 WHERE tenant_id = $1 AND sub = $2 AND role <> ALL($4);

INSERT INTO tenant_user_role (tenant_id, sub, role)
SELECT $1, $2, unnest($4::text[])
ON CONFLICT (tenant_id, sub, role) DO NOTHING;

Verification

Assert the three behaviors that define correct mapping: collisions stay tenant-isolated, an unmapped user is denied, and conflicts resolve as configured.

import { describe, it, expect } from "vitest";

describe("idp group to role mapping", () => {
  it("does not leak a colliding group across tenants", async () => {
    // 'admins' maps to 'admin' in acme but is unmapped in globex
    const acme = await resolveRoles(db, "acme", ["admins"]);
    const globex = await resolveRoles(db, "globex", ["admins"]);
    expect(acme.map((r) => r.role)).toEqual(["admin"]);
    expect(globex).toEqual([]);
  });

  it("denies a user who maps to no role", () => {
    expect(() => enforceDefaultDeny("acme", "u1", [])).toThrow("NO_MAPPED_ROLE");
  });

  it("collapses to highest privilege when configured", () => {
    const grants = [{ role: "viewer", priority: 0 }, { role: "admin", priority: 10 }];
    expect(resolveConflict(grants, "highest")).toEqual(["admin"]);
  });
});

Confirm no global (tenant-less) mappings exist — a row with a null or shared tenant is a cross-tenant escalation waiting to happen:

SELECT tenant_id, idp_group, count(*)
  FROM idp_group_role_map
 WHERE tenant_id IS NULL OR tenant_id = ''
 GROUP BY 1, 2;
-- expect zero rows

Failure Modes & Gotchas

FAQ

Should I map every IdP group or only the ones I recognize? Only the ones in your mapping table. Unrecognized groups should produce no role, and a user with only unrecognized groups should hit default-deny. Mapping unknown groups to a fallback role is how silent over-grants happen.

Union or highest-privilege-wins for conflict resolution? Highest-privilege-wins is safer for tenants with sensitive data because it prevents an additive grant the customer did not intend; union is acceptable when roles are purely additive capabilities. Make it a per-tenant setting and default to highest-privilege.

Where does the tenant_id come from during mapping? Only from the verified token, never from a header or request body. The same equality check that binds the login to a tenant binds the group lookup, so a token for one tenant can never resolve roles in another.