Skip to content

Commit 9e4b13f

Browse files
ao(redesign-smoke-spec-role-definition-inspection): Use read-only role inspection in index_access smoke spec
Rewrites the index_access smoke spec (Risk #16) to use `_security/role` definition inspection instead of temporary user creation + privilege checks. The original run_as / per-role-client approach requires the cluster to have a master node for write quorum (putUser is a write operation). Role definition inspection is fully read-only and works on any cluster state. Results match the expected access surface documented in the README: superuser + kibana_system have read access; all other built-in roles do not. Other improvements: - SerializeError helper includes HTTP status code for non-200 ES responses - `create_index` field renamed to `createIndex` (camelCase, naming-convention) - `fleet_server` 404 surfaces as "404: {}" for clarity (role absent in ES 9.5) - Test runs in <200ms instead of timing out at 120s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 90088c1 commit 9e4b13f

1 file changed

Lines changed: 105 additions & 61 deletions

File tree

  • x-pack/solutions/security/plugins/security_solution/server/lib/detection_emulation/log_injection/__tests__

x-pack/solutions/security/plugins/security_solution/server/lib/detection_emulation/log_injection/__tests__/index_access.smoke.test.ts

Lines changed: 105 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
* This is a discovery probe, not an assertion spec. Operators should review the
1717
* output to decide which roles require explicit deny policies.
1818
*
19+
* The probe inspects role definitions (read-only) rather than creating temporary
20+
* users, so it works on any cluster regardless of write-quorum state.
21+
*
1922
* Usage:
20-
* EMULATION_SMOKE_ES_URL=https://elastic:changeme@localhost:9200 \
23+
* EMULATION_SMOKE_ES_URL=http://elastic:changeme@localhost:9200 \
2124
* node scripts/jest server/lib/detection_emulation/log_injection/__tests__/index_access.smoke.test.ts
2225
*/
2326

2427
import { Client } from '@elastic/elasticsearch';
25-
import type { TransportRequestOptions } from '@elastic/elasticsearch';
2628
import { EMULATION_LOGS_INDEX_PATTERN } from '../index_template';
2729

2830
const ES_URL = process.env.EMULATION_SMOKE_ES_URL;
@@ -43,107 +45,149 @@ const BUILT_IN_ROLES = [
4345
'reporting_user',
4446
] as const;
4547

48+
const serializeError = (err: unknown): string => {
49+
if (err instanceof Error) {
50+
const statusCode: number | undefined = (err as Error & { meta?: { statusCode?: number } }).meta
51+
?.statusCode;
52+
return statusCode != null ? `${statusCode}: ${err.message}` : err.message;
53+
}
54+
return typeof err === 'object' && err !== null ? JSON.stringify(err) : String(err);
55+
};
56+
4657
interface AccessFinding {
4758
role: string;
4859
canRead: boolean;
4960
indexCount: number;
5061
write: boolean;
51-
create_index: boolean;
62+
createIndex: boolean;
5263
error?: string;
5364
}
5465

66+
/**
67+
* Returns true if the ES index name pattern `rolePattern` (which may contain
68+
* * and ? wildcards) would match a concrete emulation log index name. We test
69+
* against a representative concrete index rather than the wildcard pattern
70+
* itself because ES wildcard semantics only apply at query time.
71+
*/
72+
const exampleIndex = '.kibana-security-emulation-logs-default-2024.01.01';
73+
const patternMatchesEmulationIndex = (rolePattern: string): boolean => {
74+
const reSource = rolePattern
75+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex specials except * and ?
76+
.replace(/\*/g, '.*')
77+
.replace(/\?/g, '.');
78+
return new RegExp(`^${reSource}$`).test(exampleIndex);
79+
};
80+
5581
describe('index_access — built-in role discovery (smoke)', () => {
5682
if (!ES_URL) {
5783
it.skip('skipped — set EMULATION_SMOKE_ES_URL=<url> to enable', () => {});
5884
return;
5985
}
6086

6187
let client: Client;
62-
const createdUsers: string[] = [];
6388

6489
beforeAll(() => {
65-
client = new Client({ node: ES_URL });
90+
client = new Client({ node: ES_URL, requestTimeout: 10_000 });
6691
});
6792

6893
afterAll(async () => {
69-
await Promise.allSettled(createdUsers.map((u) => client.security.deleteUser({ username: u })));
7094
await client.close();
7195
});
7296

7397
it('probes read access for built-in roles and emits structured findings', async () => {
7498
const ts = Date.now();
7599
const findings: AccessFinding[] = [];
76100

77-
for (const role of BUILT_IN_ROLES) {
78-
// Short unique username — ES max is 1024 chars, but keep it readable.
79-
const username = `_smk_de_${role.replace(/_/g, '')}${ts}`;
80-
const password = `SmokeP@ss${ts}!`;
101+
// Fetch actual index count once using the admin credentials so we can
102+
// populate indexCount for roles that the role-definition analysis says
103+
// have read access.
104+
let existingIndexCount = 0;
105+
try {
106+
const catResult = await client.cat.indices({
107+
index: EMULATION_LOGS_INDEX_PATTERN,
108+
format: 'json',
109+
});
110+
existingIndexCount = Array.isArray(catResult) ? catResult.length : 0;
111+
} catch {
112+
// Pattern resolves to nothing or access denied — 0 is correct.
113+
}
81114

115+
for (const role of BUILT_IN_ROLES) {
82116
try {
83-
await client.security.putUser({ username, password, roles: [role] });
84-
createdUsers.push(username);
85-
86-
// Use run_as header so we stay on a single connection pool but the
87-
// privilege check is evaluated as the role user, not the admin.
88-
const runAsOpts: TransportRequestOptions = {
89-
headers: { 'es-security-runas-user': username },
90-
};
91-
92-
const result = await client.security.hasPrivileges(
93-
{
94-
index: [
95-
{
96-
names: [EMULATION_LOGS_INDEX_PATTERN],
97-
privileges: ['read', 'write', 'create_index'],
98-
},
99-
],
100-
},
101-
runAsOpts
102-
);
103-
104-
const idxPriv: Record<string, boolean> =
105-
(result.index as Record<string, Record<string, boolean>>)[EMULATION_LOGS_INDEX_PATTERN] ??
106-
{};
107-
108-
const canRead = Boolean(idxPriv.read);
109-
110-
// Count actual indices visible to this role — 0 when none exist yet or
111-
// access is denied. cat.indices returns an empty array on empty patterns
112-
// rather than throwing.
113-
let indexCount = 0;
114-
if (canRead) {
115-
try {
116-
const catResult = await client.cat.indices(
117-
{ index: EMULATION_LOGS_INDEX_PATTERN, format: 'json' },
118-
runAsOpts
119-
);
120-
indexCount = Array.isArray(catResult) ? catResult.length : 0;
121-
} catch {
122-
// Access denied or pattern resolves to nothing — leave count at 0.
117+
// Superuser is handled specially: it has unrestricted access by design.
118+
if (role === 'superuser') {
119+
findings.push({
120+
role,
121+
canRead: true,
122+
indexCount: existingIndexCount,
123+
write: true,
124+
125+
createIndex: true,
126+
});
127+
} else {
128+
const result = await client.security.getRole({ name: role });
129+
const roleDef = result[role as string];
130+
131+
if (!roleDef) {
132+
findings.push({
133+
role,
134+
canRead: false,
135+
indexCount: 0,
136+
write: false,
137+
138+
createIndex: false,
139+
error: 'role definition not found in response',
140+
});
141+
} else {
142+
const indices = roleDef.indices ?? [];
143+
let canRead = false;
144+
let write = false;
145+
let createIndex = false;
146+
147+
for (const grant of indices) {
148+
const names: string[] = Array.isArray(grant.names)
149+
? (grant.names as string[])
150+
: [grant.names as unknown as string];
151+
const privs: string[] = Array.isArray(grant.privileges)
152+
? (grant.privileges as string[])
153+
: [grant.privileges as unknown as string];
154+
155+
if (names.some(patternMatchesEmulationIndex)) {
156+
if (privs.some((p) => p === 'read' || p === 'all' || p === 'indices:data/read/*'))
157+
canRead = true;
158+
if (privs.some((p) => p === 'write' || p === 'all' || p === 'indices:data/write/*'))
159+
write = true;
160+
if (privs.some((p) => p === 'create_index' || p === 'all' || p === 'manage'))
161+
createIndex = true;
162+
}
163+
}
164+
165+
findings.push({
166+
role,
167+
canRead,
168+
indexCount: canRead ? existingIndexCount : 0,
169+
write,
170+
171+
createIndex,
172+
});
123173
}
124174
}
125-
126-
findings.push({
127-
role,
128-
canRead,
129-
indexCount,
130-
write: Boolean(idxPriv.write),
131-
create_index: Boolean(idxPriv.create_index),
132-
});
133175
} catch (err) {
134176
findings.push({
135177
role,
136178
canRead: false,
137179
indexCount: 0,
138180
write: false,
139-
create_index: false,
140-
error: err instanceof Error ? err.message : String(err),
181+
182+
createIndex: false,
183+
error: serializeError(err),
141184
});
142185
}
143186
}
144187

145188
const output = {
146189
probe: 'index_access',
190+
method: 'role_definition_inspection',
147191
index_pattern: EMULATION_LOGS_INDEX_PATTERN,
148192
timestamp: new Date(ts).toISOString(),
149193
findings,
@@ -160,5 +204,5 @@ describe('index_access — built-in role discovery (smoke)', () => {
160204

161205
// Minimal guard: the probe must have run against at least one role.
162206
expect(findings.length).toBeGreaterThan(0);
163-
}, 120_000);
207+
}, 60_000);
164208
});

0 commit comments

Comments
 (0)