← All posts

Một dấu gạch nối, hai tenant, một khóa ký

Authagonal·June 25, 2026
authsecuritymultitenancyisolationsaas

Hai tenant của chúng tôi vốn là cùng một tenant. Chúng có tên khác nhau, đăng ký khác nhau, và dòng thanh toán khác nhau. Chúng cũng dùng chung một cơ sở dữ liệu và một khóa ký token, và không một ai trong ba bên, hai tenant đó và chúng tôi, hay biết gì cả. Đây là câu chuyện về hàm một dòng đã hợp nhất chúng, vì sao mọi tầng của hệ thống trông hoàn toàn đúng đắn trong lúc điều đó diễn ra, và vì sao một cuộc kiểm toán trước khi ra mắt là khoản bảo hiểm rẻ nhất bạn từng mua.

Auth đa tenant có đúng một nhiệm vụ không được phép sai: giữ các tenant tách biệt. Người dùng của Acme, phiên của Acme, và trên hết là khóa ký của Acme không bao giờ được phép tiếp cận bởi bất kỳ ai khác. Khóa ký là viên ngọc quý nhất. Ai có thể ký bằng khóa của Acme thì có thể tạo ra một token mà chính máy chủ auth của Acme sẽ chấp nhận là thật, cho bất kỳ người dùng nào, với bất kỳ vai trò nào, không cần mật khẩu. Vì vậy mọi tài nguyên theo từng tenant mà chúng tôi tạo ra, mọi bảng lưu trữ và mọi khóa, đều được đặt không gian tên theo slug của tenant. Đặt không gian tên đúng thì các tenant là những hòn đảo riêng. Đặt sai một cách tinh vi thì chúng âm thầm trở thành cùng một nơi.

Lỗi một dòng

Đây là hàm biến slug của một tenant thành tiền tố mà chúng tôi dùng để đặt tên cho bộ lưu trữ và khóa của tenant đó. Hãy đọc phần chú thích tài liệu. Nó ghi lại lỗi như thể đó là một tính năng.

/// 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("-", ""). Nó bỏ đi các dấu gạch nối. Ý định ban đầu là dọn dẹp: slug đi vào tên bảng Azure Table và tên khóa Vault, vốn có quy tắc ký tự riêng, nên chúng tôi làm sạch chúng. Vấn đề là việc bỏ bớt ký tự là một phép biến đổi gây mất mát thông tin, và một phép biến đổi gây mất mát thông tin trên một định danh thì không đơn ánh. acme-corpacmecorp đều cho ra acmecorp. ac-me-corpacme--corp cũng vậy. Các slug khác nhau, một không gian tên.

Không gian tên đó là tất cả ở phía sau. Bảng người dùng là {prefix}-Users. Còn khóa ký theo từng tenant thì, nguyên văn, là thế này:

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

Vậy nên acme-corpacmecorp không chỉ dùng chung một danh sách người dùng. Chúng ký token của mình bằng cùng một khóa signing-acmecorp trong Vault. Một token được tạo cho bên này, từng byte một, là một token được ký hợp lệ cho bên kia. Nếu bạn có thể đăng ký một slug rút gọn thành cùng tiền tố với một tenant đã tồn tại, bạn có thể tự cấp cho mình những token mà máy chủ auth của họ tin tưởng tuyệt đối. Chiếm đoạt tài khoản liên tenant, và cách khai thác chỉ là "đăng ký với một dấu gạch nối."

Vì sao không gì bắt được nó

Phần khiến người ta bất an là mọi tầng đều trông bình thường đến thế nào. Quá trình đăng ký xác thực slug và thấy một chuỗi mới mẻ, chưa dùng. Quá trình cấp phát tạo một bản ghi tenant được khóa theo slug đầy đủ, acme-corp, vốn thực sự khác biệt với acmecorp trong control plane. Quá trình xác thực token suy ra khóa từ slug và xác minh một cách vui vẻ. Mỗi thành phần làm đúng nhiệm vụ của mình trên chính đầu vào của nó.

Không gì trong hệ thống từng so sánh tiền tố của hai slug, vì không một thành phần đơn lẻ nào sở hữu bất biến "một slug ánh xạ tới đúng một không gian tên." Va chạm này sống trong khoảng trống giữa một slug, thứ mà control plane khóa theo, và một tiền tố, thứ mà bộ lưu trữ và khóa Vault khóa theo. Không ai đứng trong khoảng trống đó. Đây là dấu hiệu của loại lỗi nguy hiểm nhất: không phải một kiểm tra mà ai đó quên làm, mà là một giả định không ai biết mình đang đặt ra.

Va chạm thứ hai, kèm theo miễn phí

Cùng phép biến đổi gây mất mát thông tin ấy có một nạn nhân thứ hai. Các tenant hệ thống nội bộ của chúng tôi được đặt tên theo hậu tố: {slug}-admin chứa nhóm quản trị portal của một tenant, {slug}-sandbox chứa môi trường thử nghiệm của tenant đó. Cho những cái tên đó chạy qua cùng bộ bỏ gạch nối thì acme-admin trở thành acmeadmin. Nghĩa là một khách hàng đăng ký slug acmeadmin sẽ rút gọn xuống đúng tiền tố của tenant quản trị của Acme, chính cái nơi vốn dĩ phải có nhiều đặc quyền hơn khách hàng, chứ không phải dùng chung với một khách hàng.

Một hàm bỏ ký tự, hai ranh giới cô lập khác nhau bị đe dọa: tenant với tenant, và tenant với chính control plane của nó. Khi một dòng đơn lẻ đe dọa hai ranh giới không liên quan, đó là dấu hiệu của một lỗi tận gốc chứ không phải một lỗi bề mặt. Bản vá phải nhắm vào phép biến đổi, chứ không phải vào một trong hai triệu chứng.

Bản vá: đơn ánh ngay từ thiết kế

Bản năng là làm cho hàm tạo tiền tố thông minh hơn. Thoát ký tự cho các dấu gạch nối, băm slug, mã hóa base32. Mỗi cách trong số đó vẫn là một phép biến đổi mà bạn phải chứng minh là đơn ánh mãi mãi, trước mọi thay đổi trong tương lai, bởi một người có thể không hiểu vì sao điều đó quan trọng. Bản vá rẻ hơn và bền hơn nhiều là loại bỏ hoàn toàn sự tự do của phép biến đổi: ràng buộc đầu vào sao cho phép biến đổi trở thành ánh xạ đồng nhất.

// 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;

Slug bây giờ là chữ thường và chữ số, không gì khác. Khi không còn dấu gạch nối nào để bỏ, GetTablePrefix không còn việc gì để làm, prefix == slug đúng ngay từ thiết kế, và hai slug khác biệt không bao giờ có thể dùng chung một không gian tên nữa. Chúng tôi cũng từ chối các tên dành riêng và bất kỳ slug nào kết thúc bằng admin hoặc sandbox, qua đó đóng luôn va chạm với tenant hệ thống trong cùng một dòng. Bước kiểm tra tính hợp lệ là điểm hẹp nơi một slug lần đầu trở thành một không gian tên, nên đó chính xác là nơi đảm bảo một-một nên thuộc về.

Sau đó chúng tôi tái khẳng định cùng quy tắc đó thêm một lần nữa, ngay tại điểm thắt cổ chai của quá trình cấp phát. Có hai cánh cửa mà một slug có thể đi vào, đăng ký tự phục vụ và cấp phát do quản trị viên điều khiển, và một bất biến bảo mật chỉ được bảo vệ ở một trong hai cánh cửa thì coi như không được bảo vệ ở cả hai.

Vì sao chúng tôi kể cho bạn nghe

Chúng tôi tìm ra điều này trong một cuộc kiểm toán bảo mật trước khi ra mắt cho chính sản phẩm của mình, trước khi có một khách hàng trả tiền nào tồn tại, đó là thời điểm chấp nhận được duy nhất để tìm ra nó. Không tenant nào từng bị hợp nhất trong môi trường production. Nhưng đây là một lỗi khiến ta phải khiêm nhường, vì nó không phải là một kiểm tra bị thiếu hay một thuật toán yếu. Nó là một hàm trợ giúp làm một việc hoàn toàn hợp lý, làm sạch một chuỗi, ở một nơi mà "hợp lý" và "đơn ánh" hóa ra là hai từ khác nhau.

Bài học sống lâu hơn cả bản vá. Bất kỳ hàm nào biến đầu vào do người dùng kiểm soát thành tên của một ranh giới bảo mật, một bảng, một khóa, một không gian tên, một đường dẫn, đều phải đơn ánh, và bạn nên thực thi điều đó tại điểm hẹp nhất nơi ánh xạ được tạo ra, chứ đừng hy vọng nó sống sót qua từng tầng phía sau. Một bước chuẩn hóa, viết thường, cắt khoảng trắng, bỏ ký tự, gộp lại, chính là nơi hai danh tính âm thầm trở thành một.

Đó cũng chính là nguyên tắc mà cả sản phẩm được xây dựng trên đó. Bảo mật không phải là một bậc dịch vụ mà bạn tốt nghiệp lên; nó là cái nền. Mọi đảm bảo cô lập, mọi khóa ký, và mọi tính năng bảo mật chúng tôi cung cấp, SSO và SAML, SCIM, MFA, xuất nhật ký kiểm toán, đều bật ở mọi gói, vì giải pháp thay thế là loại chuyện bạn phát hiện ra lúc 2 giờ sáng thay vì trong một cuộc kiểm toán. Xem những gì được bao gồm.