mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user