The Atlas MikeOSS.Azure's docs, bound to the code — and to the migration that built it
108 documents

The three-mode auth boundary

Follow one request from token to authorized handler across all three providers, then into the Entra-only tenant/role gate and the data edge — doc to code and back.

backend/src/middleware/tenantAccess.ts116 lines · tenantAccess L30–115
Outline 4 symbols
1import { NextFunction, Request, Response } from "express";
2import { createServerSupabase } from "../lib/supabase.js";
3import { resolveRoles } from "../lib/auth/roles.js";
4import { getConfig } from "../lib/config.js";
5
6function deny(res: Response, tenantId: string | undefined, userId: string, reason: string): void {
7 console.warn("auth.tenant_access_denied", {
8 tenantId,
9 userId,
10 reason,
11 timestamp: new Date().toISOString(),
12 });
13 res.status(403).json({ detail: reason });
14}
15
16// getConfig() reads env first (preserves existing AUTH_PROVIDER /
17// TENANT_ONBOARDING_MODE env vars where they're set), then falls
18// back to KV. This lets operators change the value via /install and
19// have it take effect on the next request (after flushConfigCache),
20// without a Container App revision restart. See gap #1 in
21// docs/issues/azure-migration/036-marketplace-install-gaps.md.
22async function readAuthProvider(): Promise<string> {
23 return (await getConfig("auth-provider").catch(() => "")) || "supabase";
24}
25
26async function readOnboardingMode(): Promise<string> {
27 return (await getConfig("tenant-onboarding-mode").catch(() => "")) || "manual";
28}
29
30export async function tenantAccess(
31 _req: Request,
32 res: Response,
33 next: NextFunction,
34): Promise<void> {
35 const provider = await readAuthProvider();
36 if (provider !== "entra") {
37 next();
38 return;
39 }
40
41 const principal = res.locals.principal;
42 const tenantId: string | undefined = principal?.tenantId;
43 const userId: string = principal?.userId ?? "unknown";
44
45 if (!tenantId) {
46 deny(res, tenantId, userId, "TENANT_UNKNOWN");
47 return;
48 }
49
50 const admin = createServerSupabase();
51 const { data: tenant, error } = await admin
52 .from("tenants")
53 .select("tenant_id,status")
54 .eq("tenant_id", tenantId)
55 .maybeSingle();
56
57 if (error) {
58 console.error(
59 "tenantAccess.lookup_failed",
60 JSON.stringify({ tenantId, userId, error }),
61 );
62 res.status(500).json({ detail: "Unable to evaluate tenant access" });
63 return;
64 }
65
66 if (!tenant) {
67 const onboardingMode = await readOnboardingMode();
68 if (onboardingMode === "auto") {
69 // Upsert with ignoreDuplicates instead of plain insert: when a
70 // freshly-signed-in user fires several authenticated requests in
71 // parallel (profile + projects + deployments + chat on first
72 // page load is common), every one of those requests races through
73 // here together, all SELECT empty, all attempt INSERT, all but
74 // the first hit Postgres 23505 unique_violation. The losers got
75 // an opaque 500 "Unable to onboard tenant" and the operator saw
76 // half the app fail to load on first sign-in. Observed on
77 // rg-mike-test4 2026-05-19. Upsert collapses the race — losers
78 // become no-op and the request proceeds.
79 const { error: upsertError } = await admin
80 .from("tenants")
81 .upsert(
82 { tenant_id: tenantId, status: "active" },
83 { onConflict: "tenant_id", ignoreDuplicates: true },
84 );
85 if (upsertError) {
86 console.error(
87 "tenantAccess.onboard_failed",
88 JSON.stringify({
89 tenantId,
90 userId,
91 error: upsertError,
92 }),
93 );
94 res.status(500).json({ detail: "Unable to onboard tenant" });
95 return;
96 }
97 } else {
98 deny(res, tenantId, userId, "TENANT_UNKNOWN");
99 return;
100 }
101 } else if (tenant.status !== "active") {
102 const reason = tenant.status === "pending" ? "TENANT_PENDING" : "TENANT_SUSPENDED";
103 deny(res, tenantId, userId, reason);
104 return;
105 }
106
107 const roles = await resolveRoles(principal.groups ?? []);
108 if (roles.length === 0) {
109 deny(res, tenantId, userId, "GROUP_NOT_WHITELISTED");
110 return;
111 }
112
113 res.locals.principal.roles = roles;
114 next();
115}
116