mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
feat(fleet-sync): hide other replicas' identity-scoped policies on a replica (#973)
GET /api/security/policies on a replica now returns only the policies
that apply to THIS replica. Replicated rows with a node_identity
targeting a sibling replica are filtered out so an operator cannot
enumerate the names and rules of policies meant for another node in
the fleet. Defense in depth: the security panel is admin-only, but a
backend filter is bypass-proof and matches Sencho's privacy posture.
Internal evaluators (getMatchingPolicy, evaluateScanAgainstPolicies)
keep using the unfiltered list because they already enforce identity
matching at evaluation time.
CVE suppressions are fleet-wide on every replica (no node_identity
column) so no analogous filter is needed.
Public surface:
- DatabaseService.getScanPoliciesForUi(role, selfIdentity): the
filtered variant, called from securityRouter.get('/policies').
Tests:
- 3 new vitest cases: control sees full set; replica hides
other-replica scoped rows; replica always includes locally created
rows.
- Full backend suite: 1795 pass / 5 skipped.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Pins the replica-side filtering of `getScanPoliciesForUi`.
|
||||
*
|
||||
* On a replica the security-settings panel must only render policies that
|
||||
* apply to that replica. Replicated rows with a node_identity targeting a
|
||||
* sibling replica are filtered out so an operator cannot enumerate other
|
||||
* replicas' identity-scoped rules.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const db = DatabaseService.getInstance();
|
||||
// Wipe all replicated and local rows between tests so each scenario starts clean.
|
||||
db.clearReplicatedRows();
|
||||
for (const p of db.getScanPolicies()) {
|
||||
db.deleteScanPolicy(p.id);
|
||||
}
|
||||
});
|
||||
|
||||
function seedFleetWideReplicated(name: string): void {
|
||||
DatabaseService.getInstance().replaceReplicatedScanPolicies([
|
||||
{
|
||||
id: 0,
|
||||
name,
|
||||
node_id: null,
|
||||
node_identity: '',
|
||||
stack_pattern: '*',
|
||||
max_severity: 'HIGH',
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
replicated_from_control: 1,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function seedReplicaScopedReplicated(name: string, nodeIdentity: string): void {
|
||||
const db = DatabaseService.getInstance();
|
||||
// Append-style: read existing replicated rows + the new scoped one.
|
||||
const existing = db.getScanPolicies().filter((p) => p.replicated_from_control === 1);
|
||||
db.replaceReplicatedScanPolicies([
|
||||
...existing,
|
||||
{
|
||||
id: 0,
|
||||
name,
|
||||
node_id: null,
|
||||
node_identity: nodeIdentity,
|
||||
stack_pattern: '*',
|
||||
max_severity: 'CRITICAL',
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
replicated_from_control: 1,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
describe('getScanPoliciesForUi', () => {
|
||||
it('returns the full set on a control instance', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
seedFleetWideReplicated('fleet-wide');
|
||||
seedReplicaScopedReplicated('targets-other', 'https://other.example');
|
||||
const result = db.getScanPoliciesForUi('control', 'local');
|
||||
expect(result.map((p) => p.name).sort()).toEqual(['fleet-wide', 'targets-other']);
|
||||
});
|
||||
|
||||
it('hides identity-scoped replicated rows that target a different replica', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
seedFleetWideReplicated('fleet-wide');
|
||||
seedReplicaScopedReplicated('targets-other', 'https://other.example');
|
||||
seedReplicaScopedReplicated('targets-self', 'https://me.example');
|
||||
const result = db.getScanPoliciesForUi('replica', 'https://me.example');
|
||||
const names = result.map((p) => p.name).sort();
|
||||
expect(names).toEqual(['fleet-wide', 'targets-self']);
|
||||
});
|
||||
|
||||
it('always includes locally created rows on a replica', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
seedReplicaScopedReplicated('targets-other', 'https://other.example');
|
||||
db.createScanPolicy({
|
||||
name: 'local-on-replica',
|
||||
node_id: null,
|
||||
node_identity: '',
|
||||
stack_pattern: null,
|
||||
max_severity: 'CRITICAL',
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
replicated_from_control: 0,
|
||||
});
|
||||
const result = db.getScanPoliciesForUi('replica', 'https://me.example');
|
||||
const names = result.map((p) => p.name).sort();
|
||||
expect(names).toContain('local-on-replica');
|
||||
expect(names).not.toContain('targets-other');
|
||||
});
|
||||
});
|
||||
@@ -410,7 +410,12 @@ securityRouter.get(
|
||||
|
||||
securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
res.json(DatabaseService.getInstance().getScanPolicies());
|
||||
// Replicas see only policies that apply to themselves: local-only rows plus
|
||||
// fleet-wide and self-identity-matched replicated rows. Identity-scoped
|
||||
// rows targeting other replicas are filtered out at the SQL boundary.
|
||||
const policies = DatabaseService.getInstance()
|
||||
.getScanPoliciesForUi(FleetSyncService.getRole(), FleetSyncService.getSelfIdentity());
|
||||
res.json(policies);
|
||||
});
|
||||
|
||||
securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
|
||||
@@ -3526,6 +3526,33 @@ export class DatabaseService {
|
||||
.all() as ScanPolicy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of `getScanPolicies` for the security-settings UI.
|
||||
*
|
||||
* On a control instance: returns the full set, identical to
|
||||
* `getScanPolicies`.
|
||||
*
|
||||
* On a replica: returns only the policies that apply to THIS replica.
|
||||
* Replicated rows scoped to a different replica's identity (the
|
||||
* `node_identity` of a sibling node in the fleet) are filtered out so
|
||||
* an operator on Replica A cannot enumerate the names of identity-scoped
|
||||
* policies meant for Replica B. Internal evaluators
|
||||
* (`getMatchingPolicy`, `evaluateScanAgainstPolicies`) keep using the
|
||||
* unfiltered list because they already enforce identity matching at
|
||||
* evaluation time.
|
||||
*/
|
||||
public getScanPoliciesForUi(role: 'control' | 'replica', selfIdentity: string): ScanPolicy[] {
|
||||
const all = this.getScanPolicies();
|
||||
if (role === 'control') return all;
|
||||
return all.filter((p) => {
|
||||
if (p.replicated_from_control === 0) return true;
|
||||
// Fleet-wide replicated rows have an empty node_identity and
|
||||
// apply on every replica.
|
||||
if (!p.node_identity) return true;
|
||||
return p.node_identity === selfIdentity;
|
||||
});
|
||||
}
|
||||
|
||||
public getScanPolicy(id: number): ScanPolicy | null {
|
||||
return (
|
||||
(this.db
|
||||
|
||||
Reference in New Issue
Block a user