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
2427import { Client } from '@elastic/elasticsearch' ;
25- import type { TransportRequestOptions } from '@elastic/elasticsearch' ;
2628import { EMULATION_LOGS_INDEX_PATTERN } from '../index_template' ;
2729
2830const 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+
4657interface 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+
5581describe ( '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