fix(sso): enforce hub-only SSO config when remote node is active (#1865)

SSO configuration is control-plane state and must not follow the active
remote node. Add /api/sso/ to hub-only prefixes with case-insensitive
matching, hide the Settings section on remotes, and use localOnly on
every SSOSection fetch as defense in depth.
This commit is contained in:
Anso
2026-08-30 00:10:26 +00:00
committed by GitHub
parent 341511a2e0
commit c6d9fb98e5
9 changed files with 136 additions and 43 deletions
+8 -5
View File
@@ -93,6 +93,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
localOnly: true,
});
if (res.ok) {
toast.success('SSO configuration saved');
@@ -112,7 +113,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
setTesting(true);
setTestResult(null);
try {
const res = await apiFetch(`/sso/config/${providerId}/test`, { method: 'POST' });
const res = await apiFetch(`/sso/config/${providerId}/test`, { method: 'POST', localOnly: true });
const data = await res.json().catch(() => null);
if (!res.ok) {
const message = data?.error || data?.message || 'Connection test failed';
@@ -137,7 +138,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
const handleDelete = async () => {
try {
const res = await apiFetch(`/sso/config/${providerId}`, { method: 'DELETE' });
const res = await apiFetch(`/sso/config/${providerId}`, { method: 'DELETE', localOnly: true });
if (res.ok) {
toast.success('SSO provider removed');
setConfig({ enabled: false });
@@ -432,7 +433,7 @@ function RoleSyncToggle() {
let cancelled = false;
void (async () => {
try {
const res = await apiFetch('/sso/config/role-sync');
const res = await apiFetch('/sso/config/role-sync', { localOnly: true });
if (!res.ok) {
if (!cancelled) {
const data = await res.json().catch(() => null);
@@ -460,6 +461,7 @@ function RoleSyncToggle() {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: next }),
localOnly: true,
});
if (!res.ok) {
const data = await res.json().catch(() => null);
@@ -521,7 +523,7 @@ function AuthenticationModePanel({
const loadMode = async () => {
try {
const res = await apiFetch('/sso/auth-mode');
const res = await apiFetch('/sso/auth-mode', { localOnly: true });
if (!res.ok) {
const data = await res.json().catch(() => null);
toast.error(data?.error || 'Failed to load authentication mode');
@@ -551,6 +553,7 @@ function AuthenticationModePanel({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
localOnly: true,
});
const data = await res.json().catch(() => null);
if (!res.ok) {
@@ -676,7 +679,7 @@ export function SSOSection() {
const fetchConfigs = async () => {
try {
const res = await apiFetch('/sso/config');
const res = await apiFetch('/sso/config', { localOnly: true });
if (res.ok) {
setConfigs(await res.json());
} else {
@@ -181,7 +181,7 @@ describe('SSOSection role sync toggle', () => {
});
render(<SSOSection />);
await waitFor(() => {
expect(mockedFetch).toHaveBeenCalledWith('/sso/config/role-sync');
expect(mockedFetch).toHaveBeenCalledWith('/sso/config/role-sync', { localOnly: true });
});
await waitFor(() => {
const roleSyncToggle = getRoleSyncSwitch();
@@ -250,7 +250,7 @@ describe('SSOSection role sync toggle', () => {
render(<SSOSection />);
await waitFor(() => {
expect(mockedFetch).toHaveBeenCalledWith('/sso/config/role-sync');
expect(mockedFetch).toHaveBeenCalledWith('/sso/config/role-sync', { localOnly: true });
});
// Wait for the toggle to load, then click it
@@ -332,24 +332,18 @@ describe('SSOSection role sync toggle', () => {
});
});
it('active-instance targeting: role-sync apiFetch calls do not pass localOnly', async () => {
it('hub-local targeting: every apiFetch call passes localOnly: true', async () => {
mockBaseSsoLoad((path: string) => {
if (path === '/sso/config/role-sync') return res(true, { enabled: false });
return undefined;
});
render(<SSOSection />);
await waitFor(() => {
// Find the role-sync GET call
const calls = mockedFetch.mock.calls;
const roleSyncCalls = calls.filter(([path]) => path === '/sso/config/role-sync');
expect(roleSyncCalls.length).toBeGreaterThan(0);
// Assert no localOnly: true in any role-sync call (opts may be undefined
// on the mount GET, which is fine: the point is it never targets the hub)
for (const [, opts] of roleSyncCalls) {
const rest = opts as { localOnly?: boolean } | undefined;
expect(rest?.localOnly).not.toBe(true);
}
expect(mockedFetch.mock.calls.length).toBeGreaterThan(0);
});
for (const [, opts] of mockedFetch.mock.calls) {
expect((opts as { localOnly?: boolean } | undefined)?.localOnly).toBe(true);
}
});
it('exact payload: PUT sends only { enabled: boolean }', async () => {
@@ -372,7 +366,7 @@ describe('SSOSection role sync toggle', () => {
render(<SSOSection />);
await waitFor(() => {
expect(mockedFetch).toHaveBeenCalledWith('/sso/config/role-sync');
expect(mockedFetch).toHaveBeenCalledWith('/sso/config/role-sync', { localOnly: true });
});
// Wait for the ON toggle to load, then click to turn off
@@ -75,4 +75,9 @@ describe('settings section visibility by role', () => {
expect(isItemVisible(item, visibilityFor('admin'))).toBe(true);
}
});
it('hides SSO from admins when a remote node is active', () => {
const sso = SETTINGS_ITEMS.find(i => i.id === 'sso')!;
expect(isItemVisible(sso, visibilityFor('admin', { isRemote: true }))).toBe(false);
});
});
@@ -102,6 +102,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
tier: null,
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'api-tokens',
@@ -151,4 +151,9 @@ describe('reachability', () => {
expect(isSettingsSectionHidden('sso', nodeAdmin)).toBe(true);
expect(isSettingsSectionHidden('recovery', nodeAdmin)).toBe(true);
});
it('hides SSO for admins when a remote node is active', () => {
const adminRemote = ctx({ isAdmin: true, isRemote: true });
expect(isSettingsSectionHidden('sso', adminRemote)).toBe(true);
});
});