Skip to content

Instantly share code, notes, and snippets.

@azasypkin
Last active February 27, 2025 14:15
Show Gist options
  • Save azasypkin/85e3903657e768d175e31f231320f88f to your computer and use it in GitHub Desktop.
Save azasypkin/85e3903657e768d175e31f231320f88f to your computer and use it in GitHub Desktop.
Natural Language User Interface (NLUI) for the Kibana Role Creation API
#!/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()}.`);
}
}
{
"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