← All posts

एक हाइफ़न, दो टेनेंट, एक साइनिंग की

Authagonal·June 25, 2026
authsecuritymultitenancyisolationsaas

हमारे दो टेनेंट दरअसल एक ही टेनेंट थे। उनके नाम अलग थे, साइनअप अलग थे, और बिलिंग रो भी अलग थीं। पर वे एक ही डेटाबेस और एक ही टोकन-साइनिंग की भी साझा करते थे, और हम तीनों में से किसी को — न उन दोनों टेनेंट को, न हमें — इसका ज़रा भी अंदाज़ा नहीं था। यह कहानी है उस एक-लाइन फ़ंक्शन की जिसने उन्हें मिला दिया, इस बात की कि ऐसा होते समय सिस्टम की हर परत बिल्कुल सही क्यों दिख रही थी, और इस बात की कि लॉन्च-से-पहले का ऑडिट वह सबसे सस्ता बीमा क्यों है जो आप कभी खरीदेंगे।

मल्टी-टेनेंट auth का ठीक एक ही काम है जिसे वह गलत नहीं कर सकता: टेनेंट को एक-दूसरे से अलग रखना। Acme के यूज़र, Acme के सेशन, और सबसे बढ़कर Acme की साइनिंग की किसी और की पहुँच में कभी नहीं आनी चाहिए। साइनिंग की ही असली ख़ज़ाना है। जो भी Acme की की से साइन कर सकता है, वह एक ऐसा टोकन गढ़ सकता है जिसे Acme का अपना auth सर्वर असली मानकर स्वीकार कर लेगा, किसी भी यूज़र के लिए, किसी भी रोल के साथ, बिना किसी पासवर्ड के। इसलिए हम जो भी प्रति-टेनेंट रिसोर्स बनाते हैं, हर स्टोरेज टेबल और हर की, उसे टेनेंट के स्लग से नेमस्पेस किया जाता है। उस नेमस्पेसिंग को सही कर लीजिए और टेनेंट द्वीप बन जाते हैं। उसे ज़रा-सी सूक्ष्मता से गलत कर दीजिए और वे चुपचाप एक ही जगह बन जाते हैं।

वह एक-लाइन का बग

यह रहा वह फ़ंक्शन जो किसी टेनेंट स्लग को उस प्रीफ़िक्स में बदल देता है जिससे हम उसके स्टोरेज और की को नाम देते हैं। डॉक कमेंट पढ़िए। यह बग को ऐसे दस्तावेज़ करता है मानो वह कोई फ़ीचर हो।

/// Derive the table name prefix from a tenant slug by stripping hyphens.
/// E.g. "acme-corp" → "acmecorp".
public static string GetTablePrefix(string tenantSlug)
{
    return tenantSlug.Replace("-", "");
}

Replace("-", ""). यह हाइफ़न हटा देता है। इरादा साफ़-सफ़ाई का था: स्लग Azure Table के नामों और Vault की के नामों में जाते हैं, जिनके अपने कैरेक्टर नियम होते हैं, इसलिए हमने उन्हें सैनिटाइज़ किया। दिक़्क़त यह है कि कैरेक्टर हटाना एक लॉसी ट्रांसफ़ॉर्म है, और किसी पहचानकर्ता पर लॉसी ट्रांसफ़ॉर्म injective नहीं होता। acme-corp और acmecorp दोनों acmecorp बनकर निकलते हैं। ac-me-corp और acme--corp भी यही बनते हैं। अलग-अलग स्लग, एक ही नेमस्पेस।

वह नेमस्पेस ही डाउनस्ट्रीम सब कुछ है। यूज़र टेबल है {prefix}-Users। और प्रति-टेनेंट साइनिंग की, हू-ब-हू, यह है:

private string GetKeyName() => $"signing-{ShardRouter.GetTablePrefix(_tenantContext.Slug)}";

तो acme-corp और acmecorp सिर्फ़ एक यूज़र लिस्ट ही साझा नहीं करते। वे अपने टोकन Vault में उसी signing-acmecorp की से साइन करते हैं। एक के लिए गढ़ा गया टोकन, बाइट-दर-बाइट, दूसरे के लिए भी एक वैध रूप से साइन किया हुआ टोकन है। अगर आप कोई ऐसा स्लग रजिस्टर कर सकते हैं जो किसी मौजूदा टेनेंट के समान प्रीफ़िक्स पर सिमट जाए, तो आप खुद को ऐसे टोकन जारी कर सकते हैं जिन पर उनका auth सर्वर पूरा भरोसा करता है। क्रॉस-टेनेंट अकाउंट टेकओवर, और शोषण बस इतना है: "एक हाइफ़न के साथ साइन अप कर लो।"

इसे किसी ने पकड़ा क्यों नहीं

बेचैन कर देने वाली बात यह है कि हर परत कितनी सामान्य दिख रही थी। साइनअप ने स्लग को वैलिडेट किया और उसे एक ताज़ा, अनछुई स्ट्रिंग के रूप में देखा। प्रोविज़निंग ने पूरे स्लग acme-corp से कीड किया हुआ एक टेनेंट रिकॉर्ड बनाया, जो कंट्रोल प्लेन में acmecorp से सचमुच अलग है। टोकन वैलिडेशन ने स्लग से की निकाली और ख़ुशी-ख़ुशी सत्यापन कर दिया। हर कंपोनेंट ने अपने इनपुट पर अपना काम सही किया।

सिस्टम में कभी किसी चीज़ ने दो स्लग के प्रीफ़िक्स की तुलना नहीं की, क्योंकि किसी एक कंपोनेंट के पास यह इनवैरिएंट नहीं था कि "एक स्लग ठीक एक नेमस्पेस पर मैप होता है।" यह टकराव उस खाई में बसा था जो एक स्लग के बीच है, जिस पर कंट्रोल प्लेन कीड करता है, और एक प्रीफ़िक्स के बीच, जिस पर स्टोरेज और Vault कीड करते हैं। उस खाई में कोई खड़ा नहीं था। यही बग के सबसे ख़तरनाक वर्ग की पहचान है: कोई ऐसी जाँच नहीं जिसे कोई भूल गया हो, बल्कि एक ऐसी मान्यता जो किसी को पता ही नहीं थी कि वह बना रहा है।

दूसरा टकराव, मुफ़्त में

उसी लॉसी ट्रांसफ़ॉर्म का एक दूसरा शिकार भी था। हमारे आंतरिक सिस्टम टेनेंट सफ़िक्स से नाम पाते हैं: {slug}-admin किसी टेनेंट की पोर्टल टीम रखता है, {slug}-sandbox उसका टेस्ट एनवायरनमेंट रखता है। इन्हें उसी डी-हाइफ़नेटर से गुज़ारिए और acme-admin बन जाता है acmeadmin। यानी जिस ग्राहक ने स्लग acmeadmin रजिस्टर किया, वह Acme के admin टेनेंट के प्रीफ़िक्स पर सिमट जाएगा, वही एक जगह जो ग्राहक से ज़्यादा विशेषाधिकार-प्राप्त होनी चाहिए, उसके साथ साझा नहीं।

एक स्ट्रिपिंग फ़ंक्शन, ख़तरे में दो अलग-अलग आइसोलेशन सीमाएँ: टेनेंट से टेनेंट, और टेनेंट से उसका अपना कंट्रोल प्लेन। जब एक ही लाइन दो असंबंधित सीमाओं को ख़तरे में डालती है, तो यही इस बात का संकेत है कि यह एक रूट-कॉज़ बग है, न कि कोई सतही। फ़िक्स को ट्रांसफ़ॉर्म पर उतरना होगा, किसी एक लक्षण पर नहीं।

फ़िक्स: निर्माण से ही injective

स्वाभाविक प्रवृत्ति होती है प्रीफ़िक्स फ़ंक्शन को और चतुर बना देने की। हाइफ़न को एस्केप कर दो, स्लग को हैश कर दो, उसे base32-एनकोड कर दो। इनमें से हर एक अब भी एक ऐसा ट्रांसफ़ॉर्म है जिसे आपको हमेशा के लिए injective साबित करना होगा, हर भविष्य के बदलाव के ख़िलाफ़, किसी ऐसे इंसान के हाथों जिसे शायद यह पता ही न हो कि यह मायने क्यों रखता है। सस्ता और कहीं ज़्यादा टिकाऊ फ़िक्स है ट्रांसफ़ॉर्म की आज़ादी को पूरी तरह हटा देना: इनपुट को इस तरह बाँध दो कि ट्रांसफ़ॉर्म ही पहचान (identity) बन जाए।

// Lowercase alphanumeric ONLY, no hyphens. Forbidding hyphens makes prefix == slug,
// so distinct slugs can never share a data store or signing key.
if (!slug.All(c => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')))
    return false;

स्लग अब लोअरकेस अक्षर और अंक हैं, और कुछ नहीं। हटाने को कोई हाइफ़न ही नहीं बचा, तो GetTablePrefix के पास करने को कुछ नहीं, prefix == slug निर्माण से ही बना रहता है, और दो अलग स्लग फिर कभी एक नेमस्पेस साझा नहीं कर सकते। हम आरक्षित नामों और admin या sandbox पर समाप्त होने वाले किसी भी स्लग को भी अस्वीकार कर देते हैं, जो सिस्टम-टेनेंट टकराव को उसी लाइन में बंद कर देता है। वैलिडिटी जाँच वह संकरा बिंदु है जहाँ एक स्लग पहली बार नेमस्पेस बनता है, इसलिए ठीक वहीं पर वन-टू-वन गारंटी रहनी चाहिए।

फिर हम उसी नियम को एक बार और दोहराते हैं, प्रोविज़निंग चोकपॉइंट पर। एक स्लग दो दरवाज़ों से अंदर आ सकता है, सेल्फ़-सर्विस साइनअप और admin-संचालित प्रोविज़निंग, और दो में से सिर्फ़ एक दरवाज़े पर रक्षित सुरक्षा इनवैरिएंट दरअसल किसी पर भी रक्षित नहीं है।

हम आपको यह क्यों बता रहे हैं

हमने इसे अपने ही प्रोडक्ट के लॉन्च-से-पहले के एक सुरक्षा ऑडिट में पकड़ा, एक भी भुगतान करने वाले ग्राहक के मौजूद होने से पहले, जो इसे पकड़ने का एकमात्र स्वीकार्य समय है। प्रोडक्शन में कभी कोई टेनेंट मर्ज नहीं हुआ। पर यह एक विनम्र कर देने वाला बग है, क्योंकि यह कोई छूटी हुई जाँच या कमज़ोर एल्गोरिदम नहीं है। यह एक हेल्पर है जो कुछ बिल्कुल वाजिब करता है, एक स्ट्रिंग को सैनिटाइज़ करना, ऐसी जगह जहाँ "वाजिब" और "injective" अलग-अलग शब्द निकलते हैं।

सबक़ फ़िक्स से ज़्यादा जीवित रहा। कोई भी फ़ंक्शन जो यूज़र-नियंत्रित इनपुट को किसी सुरक्षा सीमा के नाम में बदलता है, एक टेबल, एक की, एक नेमस्पेस, एक पाथ, उसे injective होना ही चाहिए, और आपको इसे उस सबसे संकरे बिंदु पर लागू करना चाहिए जहाँ मैपिंग बनती है, यह उम्मीद नहीं करनी चाहिए कि यह नीचे की हर परत में बचा रहेगा। एक नॉर्मलाइज़ स्टेप, लोअरकेस, ट्रिम, स्ट्रिप, कोलैप्स, ठीक वही जगह है जहाँ दो पहचानें चुपचाप एक बन जाती हैं।

यह वही सिद्धांत है जिस पर पूरा प्रोडक्ट बना है। सुरक्षा कोई ऐसा टियर नहीं है जिसमें आप ग्रैजुएट होकर पहुँचते हैं; यह तो फ़र्श है। हर आइसोलेशन गारंटी, हर साइनिंग की, और हर सुरक्षा फ़ीचर जो हम शिप करते हैं, SSO और SAML, SCIM, MFA, ऑडिट एक्सपोर्ट, हर प्लान पर चालू है, क्योंकि विकल्प वह चीज़ है जो आप रात 2 बजे पाते हैं, न कि किसी ऑडिट में। देखिए इसमें क्या शामिल है