mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
fix(auth): honor SSO-only CLI recovery without restart (#1810)
This commit is contained in:
@@ -92,13 +92,40 @@ describe('GET /api/auth/status', () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
db.getDb().prepare('DELETE FROM global_settings WHERE key = ?').run('authentication_mode');
|
||||
// Bust the settings cache so the next read rebuilds without the deleted key.
|
||||
const cpu = db.getDb().prepare('SELECT value FROM global_settings WHERE key = ?').get('host_cpu_limit') as { value: string };
|
||||
db.updateGlobalSetting('host_cpu_limit', cpu.value);
|
||||
const res = await request(app).get('/api/auth/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.localLoginEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('honors a sidecar CLI write to authentication_mode without clearing the settings cache', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const { setAuthenticationMode, getAuthenticationMode, isLocalLoginEnabled } = await import('../helpers/authenticationMode');
|
||||
setAuthenticationMode('sso_only');
|
||||
const db = DatabaseService.getInstance();
|
||||
// Warm the process cache so a naive getGlobalSettings() read would still
|
||||
// report sso_only after a direct SQLite write (the enableLocalLogin /
|
||||
// disableSso sidecar path).
|
||||
expect(db.getGlobalSettings().authentication_mode).toBe('sso_only');
|
||||
db.getDb()
|
||||
.prepare('INSERT OR REPLACE INTO global_settings (key, value) VALUES (?, ?)')
|
||||
.run('authentication_mode', 'local_and_sso');
|
||||
expect(db.getGlobalSettings().authentication_mode).toBe('sso_only');
|
||||
expect(getAuthenticationMode()).toBe('local_and_sso');
|
||||
expect(isLocalLoginEnabled()).toBe(true);
|
||||
|
||||
const status = await request(app).get('/api/auth/status');
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body.localLoginEnabled).toBe(true);
|
||||
expect(status.body.authenticationMode).toBe('local_and_sso');
|
||||
|
||||
const login = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
|
||||
// Restore via the normal path so later tests see a coherent cache.
|
||||
setAuthenticationMode('local_and_sso');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -167,6 +167,7 @@ describe('disableSso', () => {
|
||||
db.upsertSSOConfig('oidc_custom', true, '{"clientId":"abc"}');
|
||||
const result = disableSso();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.message).toMatch(/no restart required/i);
|
||||
expect(db.getGlobalSettings().authentication_mode).toBe('local_and_sso');
|
||||
expect(db.getEnabledSSOConfigs()).toHaveLength(0);
|
||||
});
|
||||
@@ -178,7 +179,7 @@ describe('enableLocalLogin', () => {
|
||||
db.updateGlobalSetting('authentication_mode', 'sso_only');
|
||||
const result = enableLocalLogin();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.message).toMatch(/Restart Sencho/i);
|
||||
expect(result.message).toMatch(/no restart is required/i);
|
||||
expect(db.getGlobalSettings().authentication_mode).toBe('local_and_sso');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,10 +79,14 @@ export function disableSso(provider?: string): CliResult {
|
||||
}
|
||||
|
||||
const enabled = db.getEnabledSSOConfigs();
|
||||
const wasSsoOnly = getAuthenticationMode(db) === 'sso_only';
|
||||
const modeNote = wasSsoOnly
|
||||
? ' Local password login is available again (no restart required).'
|
||||
: '';
|
||||
if (enabled.length === 0) {
|
||||
const modeError = restoreLocalLoginIfNeeded(db);
|
||||
if (modeError) return modeError;
|
||||
return { ok: true, message: 'No SSO providers are currently enabled.' };
|
||||
return { ok: true, message: `No SSO providers are currently enabled.${modeNote}` };
|
||||
}
|
||||
|
||||
const modeError = restoreLocalLoginIfNeeded(db);
|
||||
@@ -93,7 +97,10 @@ export function disableSso(provider?: string): CliResult {
|
||||
}
|
||||
const names = enabled.map(c => c.provider).join(', ');
|
||||
auditCli(db, '/cli/disable-sso', `CLI disabled all SSO providers (${enabled.length})`);
|
||||
return { ok: true, message: `Disabled ${enabled.length} SSO provider(s): ${names}. Configurations were preserved.` };
|
||||
return {
|
||||
ok: true,
|
||||
message: `Disabled ${enabled.length} SSO provider(s): ${names}. Configurations were preserved.${modeNote}`,
|
||||
};
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* docker compose exec sencho node dist/cli/enableLocalLogin.js
|
||||
*
|
||||
* Requires local shell or Docker-host access. Does not contact the identity
|
||||
* provider. Written to the audit log with actor `cli`. Restart Sencho after
|
||||
* running so the in-process settings cache picks up the change.
|
||||
* provider. Written to the audit log with actor `cli`. Takes effect on the
|
||||
* next login/status request without restarting Sencho (getAuthenticationMode
|
||||
* reads this key uncached).
|
||||
*/
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ export function enableLocalLogin(): CliResult {
|
||||
return {
|
||||
ok: true,
|
||||
message:
|
||||
'Local login re-enabled. Restart Sencho for the change to take effect: docker compose restart sencho',
|
||||
'Local login re-enabled. Sign in with a local administrator password; no restart is required.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,15 @@ export type AuthenticationMode = (typeof AUTHENTICATION_MODES)[number];
|
||||
export const AUTHENTICATION_MODE_KEY = 'authentication_mode';
|
||||
export const DEFAULT_AUTHENTICATION_MODE: AuthenticationMode = 'local_and_sso';
|
||||
|
||||
/** Read the cached global setting; missing or unknown values default to local_and_sso. */
|
||||
/**
|
||||
* Read authentication_mode with a fresh SQLite lookup. Must not use the
|
||||
* getGlobalSettings() process cache: enableLocalLogin / disableSso write this
|
||||
* key from a sidecar CLI process, and a stale sso_only cache would keep
|
||||
* rejecting local password login after recovery until Sencho restarts.
|
||||
* Missing or unknown values default to local_and_sso.
|
||||
*/
|
||||
export function getAuthenticationMode(db: DatabaseService = DatabaseService.getInstance()): AuthenticationMode {
|
||||
const raw = db.getGlobalSettings()[AUTHENTICATION_MODE_KEY];
|
||||
const raw = db.getGlobalSettingFresh(AUTHENTICATION_MODE_KEY);
|
||||
if (raw === 'sso_only') return 'sso_only';
|
||||
return DEFAULT_AUTHENTICATION_MODE;
|
||||
}
|
||||
|
||||
@@ -1090,6 +1090,9 @@ export class DatabaseService {
|
||||
// so the round-trip to SQLite is worth eliminating. Assumes this
|
||||
// process is the sole writer to global_settings; sidecar tools that
|
||||
// edit the row directly will not invalidate the cache.
|
||||
// Sidecar CLI writes still leave this cache stale. authentication_mode
|
||||
// recovery works because getAuthenticationMode uses getGlobalSettingFresh,
|
||||
// not because this key is omitted from the cache.
|
||||
private cachedGlobalSettings: Readonly<Record<string, string>> | null = null;
|
||||
private auditLogBuffer: Array<Omit<AuditLogEntry, 'id'>> = [];
|
||||
private auditLogFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -3470,6 +3473,20 @@ export class DatabaseService {
|
||||
this.cachedGlobalSettings = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one global_settings value straight from SQLite, ignoring the
|
||||
* in-process cache. Emergency CLIs (enableLocalLogin, disableSso) write
|
||||
* from a sidecar Node process; the running server's getGlobalSettings()
|
||||
* cache cannot see those writes. Use this for settings that must honor
|
||||
* out-of-process recovery without a restart.
|
||||
*/
|
||||
public getGlobalSettingFresh(key: string): string | undefined {
|
||||
const row = this.db.prepare('SELECT value FROM global_settings WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
// --- System State (operational/runtime values - not user-defined config) ---
|
||||
|
||||
public getSystemState(key: string): string | null {
|
||||
|
||||
Reference in New Issue
Block a user