Last active
February 27, 2025 14:15
-
-
Save azasypkin/85e3903657e768d175e31f231320f88f to your computer and use it in GitHub Desktop.
Natural Language User Interface (NLUI) for the Kibana Role Creation API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
'use strict'; | |
import { inspect } from 'util'; | |
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator'; | |
const KIBANA_HOST = process.env.KBN_HOST || 'http://localhost:5601'; | |
const KIBANA_USERNAME = process.env.KBN_USERNAME || 'elastic'; | |
const KIBANA_PASSWORD = process.env.KBN_PASSWORD || 'changeme'; | |
const KIBANA_CREDENTIALS = `Basic ${btoa(`${KIBANA_USERNAME}:${KIBANA_PASSWORD}`)}`; | |
const MODEL_ID = process.env.MODEL_ID || 'ollama/qwen2.5:14b'; | |
const OLLAMA_HOST = process.env.OLAMA_HOST || 'http://localhost:11434'; | |
const GEMINI_HOST = process.env.GEMINI_HOST || 'https://generativelanguage.googleapis.com'; | |
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ''; | |
const SUPPORTED_MODELS = new Map([[ | |
'ollama', new Set(['mistral-nemo:latest', 'mistral-small:latest', 'command-r:35b-08-2024-q2_k', 'qwen2.5:7b', 'qwen2.5:14b']) | |
], [ | |
'gemini', new Set(['gemini-1.5-flash', 'gemini-2.0-flash']) | |
]]); | |
(async function main() { | |
// 1. Validate the input. | |
const userPrompt = process.argv[2] || ''; | |
if (!userPrompt) { | |
throw new Error('User prompt is required.'); | |
} | |
const [modelProvider, modelId] = MODEL_ID.toLowerCase().split('/'); | |
const model = SUPPORTED_MODELS.get(modelProvider); | |
if (!model) { | |
throw new Error(`Model provider "${modelProvider}" is not supported.`); | |
} | |
if (!model.has(modelId)) { | |
throw new Error(`Model "${modelId}" (${modelProvider}) is not supported.`); | |
} | |
if (modelProvider === 'gemini' && !GEMINI_API_KEY) { | |
throw new Error('GEMINI_API_KEY is required for Gemini model provider.'); | |
} | |
// 2. Fetch all features. | |
const features = await (await fetch(`${KIBANA_HOST}/api/features`, { | |
headers: { "Authorization": KIBANA_CREDENTIALS } | |
})).json(); | |
// 3. Generate a system prompt. | |
const systemPrompt = ` | |
You are an expert in creating roles for the Elasticsearch & Kibana. | |
You are given a description of the permissions that the role should grant and based on that description, you will need | |
to come up with the JSON description of the role STRICTLY according to the following schema (especially "enums"), and | |
NO other text MUST be included (no thinking, no reasoning, no explanations, no comments), just plain JSON. | |
## Role Schema | |
\`\`\`json | |
{ | |
"type": "object", | |
"properties": { | |
"kibana": { | |
"type": "array", | |
"minItems": 0, | |
"items": { | |
"type": "object", | |
"properties": { | |
"id": { "enum": ${JSON.stringify(features.map((f) => f.id).concat(['base']))} }, | |
"access": { "enum": ["all", "read"] } | |
"space": { "type": "string" } | |
}, | |
"required": ["id", "access", "space"] | |
} | |
}, | |
"elasticsearch": { | |
"type": "array", | |
"minItems": 0, | |
"items": { | |
"type": "object", | |
"properties": { "index": { "type": "string" }, "access": { "enum": ["all", "read"] } }, | |
"required": ["index", "access"] | |
} | |
}, | |
"accessToSystemIndices": { "enum": ["all", "read", "none"] } | |
}, | |
"required": ["kibana", "elasticsearch", "accessToSystemIndices"] | |
} | |
\`\`\` | |
## The "kibana" role portion | |
The "kibana" role portion MUST ONLY contain a list of features related to Kibana, where "id" is feature ID, and "access" | |
is the privilege that will be granted to specified feature. Here's the list of available features with IDs, names and | |
descriptions that you should use to figure out which features are assumed in the query. You MUST pick feature IDs | |
ONLY from this list. | |
\`\`\`json | |
[ | |
${features.flatMap(({ id, name, app, privileges }) => { | |
if (!privileges) { | |
return []; | |
} | |
const apps = app.filter((app) => app !== 'kibana'); | |
return [JSON.stringify({ | |
id, | |
name, | |
supportedAccess: [...(!privileges.all.disabled ? ['all'] : []), ...(!privileges.read.disabled ? ['read'] : [])], | |
description: apps.length > 0 ? `Grants access to the following apps: ${apps.join(', ')}` : '' | |
})] | |
}).join(',\n')} | |
] | |
\`\`\` | |
When user mentions they want to have access to all *features* (meaning all applications in Kibana), use "base" as the | |
feature ID (that's a special keyword). Don't make new feature IDs, if you cannot match feature the user is asking for - | |
ask for clarification. | |
The "access" property defines a level of access, it can either be "all" (manage, write, all - all are aliases for "all", | |
that's the highest level of access to a certain feature) or "read" (read, view - all are aliases for "read"). The "access" | |
should be ONLY set to a value that's supported by the specified feature as declared in "supportedAccess" feature property. | |
If "supportedAccess" has ONLY "all" you should use "all" for "access", if "read", you should use "read" for "access", | |
no matter what user requested. | |
Access to the feature can usually be granted within a specific “space” (should always be in lowercase and whitespaces | |
should be replaced with _). If the user doesn’t mention a space or wants access in all spaces, you should set "space" | |
to "*" (a special keyword). When use mentions "default" space, use "default" as "space" value. The space is purely a | |
Kibana concept and is not related to Elasticsearch. | |
## The "elasticsearch" role portion | |
The "elasticsearch" portion ONLY contains a list of data indices that user should have access to, it can contain index | |
name or index pattern. The "access" is the privilege that will be granted to specified data index or index pattern. | |
When user mentions they want to have access to all indices, use "*" as the "index" (that's a special keyword). You should | |
use "read" access unless user explicitly mentions they want to have elevated access (full, all or write). | |
## The "accessToSystemIndices" role portion | |
The "accessToSystemIndices" property should be set to "none" by default unless user explicitly mentions that | |
they want to access ALL system or hidden indices without explicitly specifying their name. It can either be "all" | |
(manage, write, all - all are aliases for "all", that's the highest level of access to a certain feature) or "read" ( | |
read, view - all are aliases for "read"). | |
`; | |
console.log(`<|SYSTEM PROMPT|> ${systemPrompt}`); | |
console.log(`---`); | |
console.log(`<|USER PROMPT|> ${userPrompt}`); | |
console.log(`---`); | |
// 4. Query the model. | |
const llmResponse = await queryModel(modelProvider, modelId, systemPrompt, userPrompt); | |
console.log(`<|LLM RESPONSE (${MODEL_ID})|> ${inspect(llmResponse, { depth: 100 })}`); | |
console.log(`---`); | |
// 5. Generate the role name. | |
const roleName = uniqueNamesGenerator({ dictionaries: [adjectives, animals], length: 2 }); | |
// 6. Construct the role. | |
const role = constructRole(userPrompt, roleName, features, llmResponse); | |
console.log(`<|ROLE|> ${inspect(role, { depth: 100 })}`); | |
console.log(`---`); | |
// 7. Create the role in Kibana. | |
await createRole(roleName, role); | |
})(); | |
async function queryModel(modelProvider, modelId, systemPrompt, userPrompt) { | |
let promptResult; | |
if (modelProvider === 'ollama') { | |
promptResult = (await (await fetch(`${OLLAMA_HOST}/api/generate`, { | |
method: 'POST', | |
headers: { "Content-Type": "application/json" }, | |
body: JSON.stringify({ | |
model: modelId, | |
system: systemPrompt, | |
prompt: userPrompt, | |
format: 'json', | |
stream: false, | |
options: { num_ctx : 32000 } | |
}) | |
})).json()).response; | |
} else { | |
promptResult = (await (await fetch(`${GEMINI_HOST}/v1beta/models/${modelId}:generateContent?key=${GEMINI_API_KEY}`, { | |
method: 'POST', | |
headers: { "Content-Type": "application/json" }, | |
body: JSON.stringify({ | |
system_instruction: { parts: { text: systemPrompt } }, | |
contents: { parts: { text: userPrompt } }, | |
generationConfig: { response_mime_type: 'application/json' } | |
}) | |
})).json()).candidates?.[0].content.parts?.[0].text || ''; | |
} | |
try { | |
return JSON.parse(promptResult); | |
} catch (err) { | |
console.error(`Failed to parse LLM response (${MODEL_ID}): ${inspect(promptResult, { depth: 100 })}`); | |
throw err; | |
} | |
} | |
function constructRole(userPrompt, roleName, features, llmResponse) { | |
const indices = llmResponse.elasticsearch.map((es) => ({ | |
names: [es.index], | |
privileges: [es.access], | |
field_security: { grant: ['*'], except: [] }, | |
allow_restricted_indices: es.index.startsWith('.'), | |
})); | |
if (llmResponse.accessToSystemIndices !== 'none') { | |
indices.push({ | |
names: ['*'], | |
privileges: [llmResponse.accessToSystemIndices], | |
field_security: { grant: ['*'], except: [] }, | |
allow_restricted_indices: true, | |
}); | |
} | |
const kibana = []; | |
if (llmResponse.kibana.length > 0) { | |
const privilegesBySpace = llmResponse.kibana.reduce((acc, k) => { | |
const privileges = acc.get(k.space) || []; | |
privileges.push(k); | |
acc.set(k.space, privileges); | |
return acc | |
}, new Map()); | |
for (const [space, privileges] of privilegesBySpace) { | |
const basePrivilege = privileges.find((k) => k.id === 'base'); | |
const featurePrivileges = []; | |
if (!basePrivilege) { | |
for (const k of privileges) { | |
const validFeature = features.find((f) => f.id === k.id); | |
if (!validFeature) { | |
throw new Error(`Feature with ID "${k.id}" is not supported.`); | |
} | |
const access = validFeature.privileges.all.disabled | |
? 'read' | |
: (validFeature.privileges.read.disabled ? 'all' : k.access); | |
featurePrivileges.push([k.id, [access]]); | |
} | |
} | |
kibana.push({ | |
spaces: [space.toLowerCase().trim().replaceAll(' ', '_')], | |
base: basePrivilege ? [basePrivilege.access] : [], | |
feature: featurePrivileges.length > 0 ? Object.fromEntries(featurePrivileges) : {} | |
}); | |
} | |
} | |
return { | |
description: `${roleName}: ${userPrompt}`, | |
kibana, | |
elasticsearch: { | |
cluster: [], | |
indices, | |
run_as: [] | |
}, | |
}; | |
} | |
async function createRole(roleName, role) { | |
const response = await fetch(`${KIBANA_HOST}/api/security/role/${roleName}?createOnly=true`, { | |
method: 'PUT', | |
headers:{ | |
'Content-Type': 'application/json', | |
'Authorization': KIBANA_CREDENTIALS, | |
'kbn-version': '9.1.0', | |
'x-elastic-internal-origin': 'Kibana', | |
}, | |
body: JSON.stringify(role), | |
}); | |
if (response.status === 204) { | |
console.log(`<|ROLE CREATED (${roleName})|> ${KIBANA_HOST}/app/management/security/roles/edit/${roleName}.`); | |
} else { | |
throw new Error(`Failed to create role "${roleName}" (${response.status}): ${await response.text()}.`); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"version": "1.0.0", | |
"bin": "./index.js", | |
"name": "kibana-role-nlui", | |
"type": "module", | |
"dependencies": { | |
"unique-names-generator": "^4.7.1" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment