← All posts

Chúng tôi đã tắt một giá trị mặc định nguy hiểm mà không di dời một hàng nào

Authagonal·July 29, 2026

Cấp phát tài khoản tức thời là tính năng khiến SSO doanh nghiệp trông như phép màu. Một nhân viên mới đăng nhập qua nhà cung cấp danh tính của công ty họ, chưa có tài khoản nào cho họ trong ứng dụng của bạn, và một tài khoản được tạo ngay tại chỗ từ assertion. Không ai mở phiếu yêu cầu, không ai gửi lời mời, người đó chỉ việc bắt tay vào làm.

Đọc theo chiều ngược lại, nó cũng là tính năng cho phép bất kỳ ai kiểm soát nhà cung cấp danh tính đó tạo tài khoản trong tenant của khách hàng bạn bằng cách khẳng định rằng một người tồn tại. Điều đó ổn khi kết nối được giới hạn chặt vào thư mục của đúng một công ty và mọi người trong đó đều nên có quyền truy cập. Nó kém ổn hơn khi kết nối là một thư mục dùng chung, hoặc một tenant nhà thầu, hoặc một trong những liên minh trải rộng nơi tập hợp những người mà nhà cung cấp danh tính sẵn sàng bảo chứng lớn hơn nhiều so với tập hợp những người mà khách hàng của bạn định cho vào.

Của chúng tôi mặc định là bật. Không phải vì ai đó quyết định như vậy, và đó chính là phần đáng dừng lại để ngẫm. Nó mặc định bật vì khi trường này được thêm vào, biến boolean biểu diễn nó có tên là DisableJitProvisioning, và một boolean chưa được gán là false, và false có nghĩa là «đừng tắt». Cách hiểu an toàn nhất về một giá trị mặc định mà không ai chọn là nó là một tai nạn, và cái này thì đã ổn định thành hành vi.

Nói cho chính xác về mức độ phơi bày, vì nó chưa bao giờ tệ đến mức «ai cũng tạo được ai»: đã có hai lớp chặn chạy trước khi cấp phát. Một kết nối có thể mang một danh sách các miền email được phép, và một assertion nằm ngoài chúng sẽ bị từ chối. Một kết nối có thể đòi hỏi một thuộc tính lời mời, và một người dùng không được mời sẽ bị từ chối. Rủi ro thực sự của việc «mặc định bật» nằm ở một kết nối không cấu hình cả hai thứ đó, đó chính xác là hình dạng của một kết nối mà ai đó dựng vội để cho SSO chạy được.

Lật giá trị mặc định là một từ. Lật nó một cách an toàn thì không.

Thay đổi mà ai cũng hình dung là đổi tên trường thành JitProvisioningEnabled và để nó mặc định là false. Các kết nối mới an toàn theo mặc định, xong.

Có điều trường đó được lưu bền, và trong kho lưu trữ có những kết nối đã được ghi trước khi nó tồn tại. Các hàng của chúng hoàn toàn không có cột đó. Chuyện gì xảy ra với chúng phụ thuộc hoàn toàn vào việc boolean chỉ theo hướng nào, vì một cột thiếu sẽ giải mã thành false trong cả hai trường hợp. Dưới cái tên phủ định cũ, thiếu có nghĩa là «chưa tắt» và việc cấp phát tiếp tục. Dưới một cái tên khẳng định mới, thiếu có nghĩa là «chưa bật» và việc cấp phát dừng lại.

Vậy nên một lần đổi tên thẳng tuột sẽ âm thầm tắt cấp phát tức thời cho mọi kết nối mà khách hàng đã cấu hình hồi nó còn bật. Họ không chọn gì cả, họ không được báo, và điều đầu tiên họ nghe được là một nhân viên không đăng nhập được, vào bất kỳ giờ nào chuyện đó xảy ra. Đó không phải là một cải thiện bảo mật, đó là một sự cố được giao đến bằng bản triển khai.

Câu trả lời hiển nhiên là một backfill: duyệt qua từng kết nối đã lưu, ghi cột đó một cách tường minh, rồi lật giá trị mặc định. Nó hoạt động, và nó là một cuộc di dời mà bạn phải viết, phải kiểm thử, phải chạy trên kho lưu trữ của từng tenant, và phải chắc chắn nó đã hoàn tất ở mọi nơi trước khi đoạn mã phụ thuộc vào nó được phát hành. Cho một biến boolean.

Phủ định kép

Chúng tôi đã không viết cuộc di dời đó. Cột đã lưu giữ nguyên ý nghĩa phủ định cũ của nó mãi mãi, còn mô hình có thêm một thuộc tính khẳng định đứng phía trước nó:

public bool JitProvisioningEnabled { get; set; }

public bool DisableJitProvisioning
{
    get => !JitProvisioningEnabled;
    set => JitProvisioningEnabled = !value;
}

Thuộc tính khẳng định mới là thuộc tính thật, với kho lưu trữ thật đứng sau, và nó mặc định là false, đó là giá trị mặc định an toàn mới. Cái tên phủ định giờ là một bí danh được tính toán, đảo ngược theo cả hai chiều.

Hãy dõi theo một hàng cũ đi suốt. Cột thiếu, nên nó được đọc là false, nên bộ setter của DisableJitProvisioning chạy với false, nên JitProvisioningEnabled trở thành true. Kết nối tiếp tục cấp phát, đúng như chủ của nó đã cấu hình, và không có gì được di dời. Hãy dõi theo một kết nối mới đi suốt. Không ai gán thuộc tính nào cả, JitProvisioningEnabled giữ nguyên giá trị mặc định false, và kết nối từ chối những người dùng lạ cho đến khi có ai đó chủ động bật.

Cả hai hành vi đều đi ra từ cùng một đoạn mã, không có nhánh rẽ, không có cờ phiên bản, và không đụng đến dữ liệu nào. Bit được lưu bền chưa bao giờ đổi nghĩa. Chỉ có cái trường mà nó rơi vào là đổi, và sự đảo ngược diễn ra trong một bộ setter thuộc tính chạy mỗi lần nạp.

Cái giá của nó

Chuyện này không miễn phí, và hóa đơn tới ở ranh giới API. Cả hai thuộc tính đều là public, nên cả hai đều được tuần tự hóa, và một client đọc một kết nối, đổi một thứ gì đó, rồi ghi lại giờ đây đang gửi hai thuộc tính mô tả cùng một điều. Việc giải tuần tự áp dụng chúng theo thứ tự chúng xuất hiện trong payload, nên cái cuối cùng thắng. Đặt thuộc tính khẳng định thành true trong khi để lại một thuộc tính phủ định đã cũ trong đối tượng bạn vừa lấy về, và thay đổi của bạn bị âm thầm hoàn tác bởi một trường mà bạn còn không nghĩ là mình đang gửi.

Chúng tôi tìm ra nó theo cái cách người ta tìm ra những thứ như vậy, trong một bài kiểm thử bật cờ lên rồi khẳng định rằng nó đang bật. Quy tắc rút ra là hãy đặt cả hai dạng một cách tường minh trong mọi thao tác đọc-sửa-ghi, điều mà bài kiểm thử đầu-cuối của chính chúng tôi giờ đây làm, kèm một dòng chú thích giải thích tại sao. Nếu bạn áp dụng mẹo này, hãy tính nó vào ngân sách. Một bí danh hai chiều cho bạn một cuộc di dời miễn phí và tính phí bạn một sự nhập nhằng trên đường truyền.

Con bug mà cú lật để lộ ra

Đây là phần khái quát vượt ra ngoài các biến boolean. Trong lúc thực hiện thay đổi, chúng tôi phát hiện endpoint quản trị để tạo một kết nối OIDC chưa từng đặt cờ này bao giờ. Không phải đặt sai, không phải đặt nhầm giá trị. Nó đơn giản là không bao giờ gán cờ đó, và đối tượng yêu cầu chẳng có trường nào để mà gán.

Điều đó vô hình chừng nào giá trị mặc định còn là giá trị mà mọi người muốn. Mọi kết nối đều ra lò trong trạng thái cấp phát, đó chính là thứ mà đoạn mã quên đấu nối cũng sẽ tạo ra dù sao đi nữa, nên chẳng có gì để nhận ra và chẳng có bài kiểm thử nào có thể trượt. Ngay khoảnh khắc giá trị mặc định lật, chính lỗ hổng đó biến thành «mọi kết nối OIDC mới tạo đều tắt cấp phát và không có cách nào bật lên», mà đó thì chẳng phải là một con bug tinh vi chút nào.

Một giá trị mặc định là giá trị của mọi nhánh mã đã quên đặt trường. Chừng nào giá trị mặc định còn tiện lợi, những nhánh đó không phân biệt được với những nhánh đặt trường một cách có chủ đích. Đổi một giá trị mặc định không chỉ đổi hành vi mới, nó rửa tấm ảnh ra: mọi thứ vốn âm thầm dựa vào giá trị mặc định trở nên nhìn thấy được cùng một lúc, và một phần trong đó thì hỏng.

Ô đánh dấu không hề nhúc nhích

Nơi cuối cùng một giá trị mặc định ẩn mình là giao diện người dùng. Cổng thông tin của chúng tôi có một ô đánh dấu ghi «Tắt cấp phát JIT», mặc định không được đánh dấu. Bây giờ nó ghi «Bật cấp phát JIT», và nó vẫn mặc định không được đánh dấu. Cùng một widget ở cùng một vị trí với cùng một trạng thái ban đầu, và ý nghĩa thì ngược lại.

Đó là một loại thay đổi thực sự nguy hiểm, nên khung xem dạng danh sách có thêm một huy hiệu. Mọi kết nối chưa chủ động bật giờ đây được gắn nhãn, để trạng thái được nhìn thấy mà không cần mở gì ra, thay vì phải suy ra từ một ô không đánh dấu mà trước kia mang ý nghĩa ngược lại.

Và khi một kết nối đã tắt cấp phát nhận được một assertion cho một người lạ, người dùng không bị quẳng vào một dấu vết ngăn xếp. Họ quay lại ứng dụng mà họ đã đến từ đó, kèm một thông báo lỗi nói rằng không tìm thấy tài khoản và hãy liên hệ quản trị viên của họ, đó là phiên bản đúng sự thật và có thể hành động được của điều vừa xảy ra.

Giá trị mặc định là bề mặt API, được thừa hưởng bởi bốn nhóm dân cư

Giá trị mặc định là bề mặt API. Chúng được thừa hưởng bởi những hàng đã lưu có trước cả trường đó, bởi những tệp cấu hình bỏ sót nó, bởi những nhánh mã không bao giờ đặt nó, và bởi những điều khiển giao diện mà trạng thái không đánh dấu của chúng mã hóa nó. Trước khi bạn dịch chuyển một giá trị mặc định, hãy liệt kê bốn nhóm dân cư này và quyết định, với từng nhóm, liệu nó nên theo giá trị mặc định mới hay giữ hành vi cũ. Thường thì câu trả lời khác nhau cho mỗi nhóm, và đó chính là phần việc thiết kế.

Và nếu bạn thấy mình sắp viết một cuộc di dời dữ liệu để dịch chuyển một biến boolean, trước hết hãy xem liệu ý nghĩa có thể ở yên tại chỗ trong khi cái tên và giá trị mặc định dịch chuyển ra phía trước nó hay không. Kho lưu trữ là nơi đắt đỏ để đổi ý. Một bộ setter thuộc tính là nơi rẻ.

Nếu bạn muốn nhà cung cấp danh tính của mình xuất xưởng với những giá trị mặc định thận trọng đã được chọn sẵn, Authagonal khiến mọi kết nối SSO phải chủ động bật cấp phát, và cho bạn biết rõ ràng những kết nối nào đã bật.