Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions example-apps/internal-knowledge-search/app-ui/.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
SEARCH_APP_NAME=some-search-application
SEARCH_APP_API_KEY=xxxxxxxxxxxxxxxxxxx
SEARCH_APP_ENDPOINT=https://some-search-end-point.co
REACT_APP_SEARCH_APP_NAME=changeme
REACT_APP_SEARCH_APP_ENDPOINT=http://localhost:9200
REACT_APP_SEARCH_USER=elastic
REACT_APP_SEARCH_PASSWORD=changeme
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry if I'm misunderstanding something here, but are we asking people to send their account password to the front end? I can understand that it is convenient to have the front end do everything without a back end, but this is not a practice that should be encouraged, I think?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree. See this slack thread: https://elastic.slack.com/archives/C01795T48LQ/p1702066837681769?thread_ts=1702066380.039599&cid=C01795T48LQ

specifically:

I don’t think we want to build an e2e app that is going to be the skeleton a customer could start from. But we want to give them some examples, using the sample app that exists already

and

An example application that customers could actually run with is not as small a thing.

This sounds to me like too big effort to start with.

Basically it came down to scope, and the fact that this isn't intended to be used for production, but to simply demo on fake data. Once this PR is in a clean place, I'll be starting on a blog/tutorial that will reference this example, but discuss how insecure it is and how a real architecture for DLS would need a backend server between Elasticsearch and the browser that could do some of these elements for you securely.

are we asking people to send their account password to the front end?

Even with the above context, I'd planned to use only API keys. Unfortunately, there's a 3-year-old "known issue" where ES API keys cannot be used to create API keys for other users/privileges. See this slack thread: https://elastic.slack.com/archives/C0D8ST60Y/p1703017952223129
So I was faced with either switching to basic auth, or introducing something like JWT as an additional realm and part of setup. And again since this isn't indended to be used for production but just as an example/demo, the extra scope does not seem worth it IMO.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue I have is that this is not demonstrating something that has any practical value. If you are going to show how to do something in a bad way that nobody should use, then why show it in the first place?

I have no doubt there are reasons why this is this way, but the reality is that the decision of wether this is supposed to be used in production or not is not ours to make. People who download this example will make this decision, and if you give them something that is badly designed but works, the majority of people will use it without any concerns, because they'll assume that if we do it we think it's okay for them to do it too.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue I have is that this is not demonstrating something that has any practical value. If you are going to show how to do something in a bad way that nobody should use, then why show it in the first place?

I think there's some distance between "not intended for production" and "no practical value". 🙂
The issue that we've seen is that DLS is really confusing for our Field folks. Despite a LOT of documentation we've written on the subject, they struggle to understand the moving pieces enough to be able to put together a POC. The goal here is to provide something similar to this existing demo, but populated by dynamic search personas, rather than hardcoded ones. CC @serenachou and @danajuratoni in case y'all would like to add some more context.

the majority of people will use it without any concerns

I think that's a bit of a leap, especially if we go out of our way to flag that this is not production-ready or secure. We could definitely go further than I have so far though - would it assuage some of your concerns if we added a banner to the top of the search experience to note that this is not for production? And/or if we added a large disclaimer at the top of the example app's README?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this knowledge base app - since it's not meant for production in current scope, let's clearly document the password strategy here with a warning that customers should look to more secure methologies.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main 2 goals of this initiative are

  1. provide customers and Field with guidance how to set up DLS for POCs. We've seen multiple instances where customers and Field colleagues were not able to successfully set up DLS after many days of research. Providing an example on how this can be achieved, has immense value.
  2. additionally, the example app should be an easy to reuse tool that @lio-p can use to create eden demos.

For DLS specifically, this example successfully shows how permissions should be set up. However, the goal wasn't to show a best practice for an entire search application architecture. The security concerns voiced out in this thread are valid concerns, and the question I see raised is: does the value of this example app that zooms in on setting up DLS justify deferring / separately addressing generic search experience design & development "best practices" concerns? cc: @serenachou
From the DLS POV, my strong opinion is that we need an example of how DLS works e2e. And this was brought up numerous times already. Sean's work is a great starting point we can use in our discussions, and this initiative should be continued to also showcase our recommendation for designing a search experience, as @serenachou and @radhapolisetty prioritize.

@miguelgrinberg a followup question here is - is the goal to have all example apps in elasticsearch-labs follow specific design standards for search experiences? If so, what are these?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@danajuratoni Since this is a public repository where people come to get examples and ideas to help them build their own projects, I think it is reasonable to expect that we will be promoting safe and industry accepted patterns. I'm not setting a high bar here, just that we should follow standard practices, especially with regards to security.

The version of this application as modified in this PR asks the user to configure their Elastic account password in their front end, and uses it from the front end in a way that is intended to only be used in a server-to-server communication. This is unacceptable, we should never promote any applications that require having sensitive information available in the browser. Even if we feel this is just a demo, not for production, etc. the risk is too high, because people are not going to read the disclaimers and all the "buts" that we can come up with and will copy the insecure solution and run with it no matter the risk.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @miguelgrinberg, I'm going to pick this up and add a quick backend to this PR. Checking to make sure we're aligned on what we'd want in this demo: I think adding authentication and SSL to this backend would be overkill, but happy to hear your thoughts.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @sphilipse, yes, I would also consider SSL and authentication overkill for a demo, in fact we don't have them in the search tutorial and RAG examples either.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay cool, I added a backend in 3440e55, let me know if that matches your expectations @miguelgrinberg and then I think this should be close to ready for merging.

3 changes: 3 additions & 0 deletions example-apps/internal-knowledge-search/app-ui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# secrets
.env
21 changes: 20 additions & 1 deletion example-apps/internal-knowledge-search/app-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,24 @@ To be able to use the index filtering and sorting in the UI you should update th

### Setting the environment variables

You need to set `SEARCH_APP_NAME`, `SEARCH_APP_API_KEY` and `SEARCH_APP_ENDPOINT` inside [.env](.env) to the corresponding values, which you'll get when [creating a search application](https://www.elastic.co/guide/en/enterprise-search/current/search-applications.html).
You need to set `REACT_APP_SEARCH_APP_NAME`, `REACT_APP_SEARCH_APP_USER`, `REACT_APP_SEARCH_APP_PASSWORD` and `REACT_APP_SEARCH_APP_ENDPOINT` inside [.env](.env) to the corresponding values, which you'll get when [creating a search application](https://www.elastic.co/guide/en/enterprise-search/current/search-applications.html).

### Set up DLS with SPO
1. create a connector in kibana named `search-sharepoint`
2. start connectors-python, if using connector clients
3. enable DLS
4. run an access control sync
5. run a full sync
6. define mappings, as above in this README
7. create search application
8. enable cors: https://www.elastic.co/guide/en/elasticsearch/reference/master/search-application-security.html#search-application-security-cors-elasticsearch


### Make it go

When it's time to actually start it up:

```
npm install
npm run start
```
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a stop-gap until elastic/search-application-client#33 is merged and released

Binary file not shown.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion example-apps/internal-knowledge-search/app-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@elastic/search-application-client": "^8.9.0-1",
"@elastic/search-application-client": "file:elastic-search-application-client-8.9.0-2.tgz",
"@fortawesome/react-fontawesome": "^0.2.0",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@reduxjs/toolkit": "^1.9.7",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions example-apps/internal-knowledge-search/app-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ function App() {
<SidebarMenu/>
<main className="flex-1 p-6">
<Routes>
<Route path="/" element={<SearchPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/" element={<SettingsPage/>}/>
<Route path="/search" element={<SearchPage/>}/>
</Routes>
</main>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,222 @@
import React from 'react';
import React, {useEffect, useState} from 'react';
import {useDispatch, useSelector} from "react-redux";
import {updateSettings} from "../store/slices/searchApplicationSettingsSlice";
import {useToast} from "../contexts/ToastContext";
import {MessageType} from "./Toast";
import {RootState} from "../store/store";



const SearchApplicationSettings: React.FC = () => {
const dispatch = useDispatch();
const {appName, apiKey, searchEndpoint} = useSelector((state: RootState) => state.searchApplicationSettings);
const {appName, appUser, appPassword, searchEndpoint, searchPersona, searchPersonaAPIKey} = useSelector((state: RootState) => state.searchApplicationSettings);
const {showToast} = useToast();

const fetchPersonaOptions = async () => {
try {
const identitiesIndex = await fetchIdentitiesIndex(await fetchSearchApplicationIndices())
const identitiesPath = searchEndpoint + "/" + identitiesIndex + "/_search"
const response = await fetch(identitiesPath, {
method: "POST",
headers: {
"Authorization": "Basic " + btoa(appUser + ":" + appPassword),
"Content-Type": "application/json"
},
body: JSON.stringify({
"size": 30 //TODO: fix magic number. This is just how many identities I have in my data
})
});
const jsonData = await response.json();
const ids = jsonData.hits.hits.map((hit) => hit._id)
return ids
} catch (e) {
console.log("Something went wrong tying to fetch ACL identities")
console.log(e)
return ["admin"]
}
}

const fetchSearchApplicationIndices = async () => {
try {
const searchApplicationPath = searchEndpoint + "/_application/search_application/" + appName
const response = await fetch(searchApplicationPath, {
headers: {
"Authorization": "Basic " + btoa(appUser + ":" + appPassword)
}
})
const jsonData = await response.json();
const indices = jsonData.indices
return indices
} catch (e) {
console.log("Something went wrong trying to fetch the Search Application underlying indices")
console.log(e)
return []
}
}

const fetchIdentitiesIndex = async(applicationIndices) => {
try {
const identitiesIndexPath = searchEndpoint + "/.search-acl-filter*"
const response = await fetch(identitiesIndexPath, {
headers: {
"Authorization": "Basic " + btoa(appUser + ":" + appPassword)
}
})
const jsonData = await response.json();
const identityIndices = Object.keys(jsonData)
const securedIndex = applicationIndices.find((applicationIndex) => identityIndices.includes(".search-acl-filter-"+applicationIndex))
return ".search-acl-filter-"+securedIndex
} catch (e) {
console.log("Something went wrong trying to fetch the Identities Index")
console.log(e)
return
}
}

const roleName = appName+"-key-role"
const defaultRoleDescriptor = {
[roleName]: {
"cluster": [],
"indices": [
{
"names": [
appName
],
"privileges": [
"read"
],
"allow_restricted_indices": false
}
],
"applications": [],
"run_as": [],
"metadata": {},
"transient_metadata": {
"enabled": true
},
"restriction": {
"workflows": [
"search_application_query"
]
}
}
}
const personaRoleDescriptor = async (persona) => {
const identitiesIndex = ".search-acl-filter-search-sharepoint" //TODO fix hardcoded
const identityPath = searchEndpoint + "/" + identitiesIndex + "/_doc/" + persona
const response = await fetch(identityPath, {headers: {"Authorization": "Basic " + btoa(appUser + ":" + appPassword)}});
const jsonData = await response.json();
const permissions = jsonData._source.query.template.params.access_control
return {
"dls-role": {
"cluster": ["all"],
"indices": [
{
"names": [appName],
"privileges": ["read"],
"query" : {
"template": {
"params": {
"access_control": permissions
},
"source" : `{
"bool": {
"filter": {
"bool": {
"should": [
{
"bool": {
"must_not": {
"exists": {
"field": "_allow_access_control"
}
}
}
},
{
"terms": {
"_allow_access_control.enum": {{#toJson}}access_control{{/toJson}}
}
}
]
}
}
}
}`
}
}
}
],
"restriction": {
"workflows": [
"search_application_query"
]
}
}
}
}
const createPersonaAPIKey = async(persona) => {
const roleDescriptor = persona == "admin" ? defaultRoleDescriptor : await personaRoleDescriptor(persona)
const apiKeyPath = searchEndpoint + "/_security/api_key"
const response = await fetch(apiKeyPath, {
method: "POST",
headers: {
"Authorization": "Basic " + btoa(appUser + ":" + appPassword),
"Content-Type": "application/json",
},
body: JSON.stringify({
"name": appName+"-internal-knowledge-search-example-"+persona,
"expiration": "1h",
"role_descriptors": roleDescriptor,
"metadata": {
"application": appName,
"createdBy": appUser
}
})
})
const jsonData = await response.json()
return jsonData.encoded
}

const [searchPersonaOptions, setSearchPersonaOptions] = useState(["admin"]);

useEffect(()=>{
(async()=>{
const fetchedPersonas = await fetchPersonaOptions()
setSearchPersonaOptions(fetchedPersonas)
if (searchPersonaAPIKey == "missing") {
const createdAPIKey = await createPersonaAPIKey(searchPersona)
updateSearchPersonaAPIKey(createdAPIKey)
}
})()
},[])

const [isOpen, setIsOpen] = useState(false);

const toggleDropdown = () => {
setIsOpen(!isOpen);
};

const handlePersonaChange = async (value: string) => {
updateSearchPersona(value, await createPersonaAPIKey(value));
};

const handleSave = () => {
dispatch(updateSettings({appName, apiKey, searchEndpoint}));
dispatch(updateSettings({appName, appUser, appPassword, searchEndpoint, searchPersona, searchPersonaAPIKey}));
showToast("Settings saved!", MessageType.Info);
};

const updateAppName = (e) => dispatch(updateSettings({appName: e.target.value, apiKey, searchEndpoint}))
const updateApiKey = (e) => dispatch(updateSettings({appName, apiKey: e.target.value, searchEndpoint}))
const updateSearchEndpoint = (e) => dispatch(updateSettings({appName, apiKey, searchEndpoint: e.target.value}))
const updateAppName = (e) => dispatch(updateSettings({appName: e.target.value, appUser, appPassword, searchEndpoint, searchPersona, searchPersonaAPIKey}))

const updateAppUser = (e) => dispatch(updateSettings({appName, appUser: e.target.value, appPassword, searchEndpoint, searchPersona, searchPersonaAPIKey}))

const updateAppPassword = (e) => dispatch(updateSettings({appName, appUser, appPassword: e.target.value, searchEndpoint, searchPersona, searchPersonaAPIKey}))

const updateSearchEndpoint = (e) => dispatch(updateSettings({appName, appUser, appPassword, searchEndpoint: e.target.value, searchPersona, searchPersonaAPIKey}))

const updateSearchPersona = (persona, apiKey) => dispatch(updateSettings({appName, appUser, appPassword, searchEndpoint, searchPersona: persona, searchPersonaAPIKey: apiKey}))

const updateSearchPersonaAPIKey = (apiKey) => dispatch(updateSettings({appName, appUser, appPassword, searchEndpoint, searchPersona, searchPersonaAPIKey: apiKey}))

return (
<div className="container mx-auto p-4 bg-white rounded shadow-md">
Expand Down Expand Up @@ -51,17 +250,34 @@ const SearchApplicationSettings: React.FC = () => {

<div className="text-left mb-6 p-4 border rounded bg-gray-50">
<label
htmlFor="apiKey"
htmlFor="appUser"
className="block text-sm font-medium mb-1 text-gray-700"
>
API Key:
Application Elasticsearch Username:
</label>
<p className="text-xs mb-2 text-gray-500">Your unique API key used for authentication.</p>
<p className="text-xs mb-2 text-gray-500">The Elasticsearch username to use to establish a connection with.</p>
<input
id="apiKey"
id="appUser"
type="text"
value={appUser}
onChange={updateAppUser}
className="p-2 w-full border rounded focus:outline-none focus:shadow-outline"
/>
</div>

<div className="text-left mb-6 p-4 border rounded bg-gray-50">
<label
htmlFor="appPassword"
className="block text-sm font-medium mb-1 text-gray-700"
>
Application Elasticsearch Password:
</label>
<p className="text-xs mb-2 text-gray-500">The Elasticsearch password to use to establish a connection with.</p>
<input
id="appPassword"
type="password"
value={apiKey}
onChange={updateApiKey}
value={appPassword}
onChange={updateAppPassword}
className="p-2 w-full border rounded focus:outline-none focus:shadow-outline"
/>
</div>
Expand All @@ -83,6 +299,33 @@ const SearchApplicationSettings: React.FC = () => {
/>
</div>

<div className="text-left mb-6 p-4 border rounded bg-gray-50">
<label
htmlFor="searchPersona"
className="block text-sm font-medium mb-1 text-gray-700"
>
Search Persona:
</label>
<p className="text-xs mb-2 text-gray-500">The persona on whose behalf searches will be executed</p>
<div className="relative">
<select
onChange={ async (event) => await handlePersonaChange(event.target.value)}
value={searchPersona}
className="flex items-center space-x-2 p-2 bg-white rounded border border-gray-300 focus:outline-none focus:border-blue-500"
>
{searchPersonaOptions.includes(searchPersona) ? "" : <option value={searchPersona} key={searchPersona} className="block text-left p-2 hover:bg-gray-100 cursor-pointer">
{searchPersona}
</option>}
{searchPersonaOptions.map((option, index) => (
<option value={option} key={option} className="block text-left p-2 hover:bg-gray-100 cursor-pointer">
{option}
</option>
))}
</select>
</div>
</div>


<button
onClick={handleSave}
className="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
Expand Down
Loading