mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 07:13:05 +00:00
feat: graduate Host Console to Community admins (#1669)
* feat: graduate Host Console to Community admins Make Host Console available to Community and Admiral admins (system:console), add host-console-community for mixed fleets, and keep opaque API tokens off the host shell. * docs: document Host Console deep links Cover root and stack-scoped Console URLs, correct the phone treatment note, and pin parse/build round-trips in senchoRoute tests. * fix: bind Host Console socket to the resolved node Treat unresolved activeNode as loading, target the WebSocket with an explicit nodeId, and wait for stack deep-link hydration so the shell cannot open on the wrong node or compose root. Add regression coverage for node/stack retargeting and fail-closed directory resolution. * fix: harden Host Console node binding, audit acting_as, and console_session tokens Reject unknown or malformed nodeIds before spawning a PTY. Record hub operators in audit_log.acting_as for remote console_session bridges. Path-scope and one-time-consume console_session JWTs so Host Console mints cannot open container exec or be replayed. * test: expect acting_as in audit CSV export header Align the CSV export assertion with the P0-2B acting_as column added to audit log exports.
This commit is contained in:
@@ -38,6 +38,7 @@ export const CAPABILITIES = [
|
||||
'notification-suppression',
|
||||
'notification-suppression-schedule',
|
||||
'host-console',
|
||||
'host-console-community',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
'scheduled-ops',
|
||||
@@ -71,6 +72,12 @@ export const CROSS_NODE_RBAC_CAPABILITY = 'cross-node-rbac';
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
/** Legacy Host Console advertisement (Admiral hubs still accept this on remotes). */
|
||||
export const HOST_CONSOLE_CAPABILITY = 'host-console' as const satisfies Capability;
|
||||
|
||||
/** Host Console works without a paid license on this node. */
|
||||
export const HOST_CONSOLE_COMMUNITY_CAPABILITY = 'host-console-community' as const satisfies Capability;
|
||||
|
||||
/** Remotes that evaluate weekly maintenance windows on mute/suppression replicas. */
|
||||
export const NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY =
|
||||
'notification-suppression-schedule' as const satisfies Capability;
|
||||
@@ -165,6 +172,7 @@ export function getActiveCapabilities(): readonly string[] {
|
||||
*/
|
||||
const PILOT_DISABLED_CAPABILITIES: readonly Capability[] = [
|
||||
'host-console',
|
||||
'host-console-community',
|
||||
];
|
||||
|
||||
/** Disable capabilities that require a central->pilot path that is not yet wired. */
|
||||
|
||||
@@ -613,6 +613,8 @@ export interface AuditLogEntry {
|
||||
node_id: number | null;
|
||||
ip_address: string;
|
||||
summary: string;
|
||||
/** Hub operator for remote console_session bridges; null/absent for direct sessions. */
|
||||
acting_as?: string | null;
|
||||
}
|
||||
|
||||
export interface SecretRow {
|
||||
@@ -1240,12 +1242,20 @@ export class DatabaseService {
|
||||
status_code INTEGER NOT NULL DEFAULT 0,
|
||||
node_id INTEGER,
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT ''
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
acting_as TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_username ON audit_log(username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS console_session_jtis (
|
||||
jti TEXT PRIMARY KEY,
|
||||
used_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_console_session_jtis_expires ON console_session_jtis(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
@@ -1788,6 +1798,21 @@ export class DatabaseService {
|
||||
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Remote Host Console bridges record the hub operator separately from
|
||||
// the console_session principal (username stays console_session).
|
||||
maybeAddCol('audit_log', 'acting_as', 'TEXT');
|
||||
// Cached INSERT may predate the column; rebuild on next flush.
|
||||
this.auditLogInsertStmt = null;
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS console_session_jtis (
|
||||
jti TEXT PRIMARY KEY,
|
||||
used_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_console_session_jtis_expires ON console_session_jtis(expires_at);
|
||||
`);
|
||||
|
||||
maybeAddCol('stack_update_recovery_generations', 'artifacts_retired', 'INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
// Distributed API model columns
|
||||
@@ -4462,6 +4487,21 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM pilot_enrollments WHERE node_id = ?').run(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time consume for a console_session JWT id. Inserts the jti on first
|
||||
* use; returns false when the jti was already recorded (replay).
|
||||
*/
|
||||
public consumeConsoleSessionJti(jti: string, expiresAtMs: number): boolean {
|
||||
const now = Date.now();
|
||||
return this.db.transaction(() => {
|
||||
this.db.prepare('DELETE FROM console_session_jtis WHERE expires_at < ?').run(now);
|
||||
const result = this.db.prepare(
|
||||
'INSERT OR IGNORE INTO console_session_jtis (jti, used_at, expires_at) VALUES (?, ?, ?)',
|
||||
).run(jti, now, expiresAtMs);
|
||||
return result.changes > 0;
|
||||
})();
|
||||
}
|
||||
|
||||
// --- Stack Update Status ---
|
||||
|
||||
/**
|
||||
@@ -5165,7 +5205,7 @@ export class DatabaseService {
|
||||
this.auditLogBuffer = [];
|
||||
if (!this.auditLogInsertStmt) {
|
||||
this.auditLogInsertStmt = this.db.prepare(
|
||||
'INSERT INTO audit_log (timestamp, username, method, path, status_code, node_id, ip_address, summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO audit_log (timestamp, username, method, path, status_code, node_id, ip_address, summary, acting_as) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
}
|
||||
const stmt = this.auditLogInsertStmt;
|
||||
@@ -5180,6 +5220,7 @@ export class DatabaseService {
|
||||
entry.node_id,
|
||||
entry.ip_address,
|
||||
entry.summary,
|
||||
entry.acting_as ?? null,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface ConsoleAuditContext {
|
||||
// path always supplies a concrete id.
|
||||
readonly nodeId: number | null;
|
||||
readonly ipAddress: string;
|
||||
/** Hub operator for remote console_session bridges; null for direct sessions. */
|
||||
readonly actingAs?: string | null;
|
||||
}
|
||||
|
||||
const CONSOLE_AUDIT_PATH = '/api/system/host-console';
|
||||
@@ -97,6 +99,7 @@ export class HostTerminalService {
|
||||
node_id: audit.nodeId,
|
||||
ip_address: audit.ipAddress,
|
||||
summary,
|
||||
acting_as: audit.actingAs ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[HostConsole] Failed to write session audit log:', err);
|
||||
|
||||
Reference in New Issue
Block a user