FIX CL-548 - Encrypt JWT Token w/ Secrets Manager (#16861)

* encrypt jwt

* encrypt jwt

* Handle token revokes with SM

* #10192 - Validate both include and excludes filters

* #10192 - Validate both include and excludes filters

* docs
This commit is contained in:
Pere Miquel Brull 2024-07-11 09:16:48 +02:00 committed by GitHub
parent c3cc1a5e5b
commit 8d739563f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 535 additions and 85 deletions

View File

@ -20,6 +20,7 @@ import com.sun.codemodel.JDefinedClass;
import com.sun.codemodel.JFieldVar;
import com.sun.codemodel.JMethod;
import java.lang.reflect.Field;
import java.util.TreeMap;
import org.jsonschema2pojo.AbstractAnnotator;
/** Add {@link ExposedField} annotation to generated Java classes */
@ -59,8 +60,13 @@ public class ExposedAnnotator extends AbstractAnnotator {
Field outerClassField = JMethod.class.getDeclaredField("outer");
outerClassField.setAccessible(true);
JDefinedClass outerClass = (JDefinedClass) outerClassField.get(jMethod);
if (outerClass.fields().containsKey(propertyName)
&& outerClass.fields().get(propertyName).annotations().stream()
TreeMap<String, JFieldVar> insensitiveFieldsMap =
new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
insensitiveFieldsMap.putAll(outerClass.fields());
if (insensitiveFieldsMap.containsKey(propertyName)
&& insensitiveFieldsMap.get(propertyName).annotations().stream()
.anyMatch(
annotation ->
ExposedField.class.getName().equals(getAnnotationClassName(annotation)))) {

View File

@ -20,6 +20,7 @@ import com.sun.codemodel.JDefinedClass;
import com.sun.codemodel.JFieldVar;
import com.sun.codemodel.JMethod;
import java.lang.reflect.Field;
import java.util.TreeMap;
import org.jsonschema2pojo.AbstractAnnotator;
/** Add {@link MaskedField} annotation to generated Java classes */
@ -59,8 +60,13 @@ public class MaskedAnnotator extends AbstractAnnotator {
Field outerClassField = JMethod.class.getDeclaredField("outer");
outerClassField.setAccessible(true);
JDefinedClass outerClass = (JDefinedClass) outerClassField.get(jMethod);
if (outerClass.fields().containsKey(propertyName)
&& outerClass.fields().get(propertyName).annotations().stream()
TreeMap<String, JFieldVar> insensitiveFieldsMap =
new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
insensitiveFieldsMap.putAll(outerClass.fields());
if (insensitiveFieldsMap.containsKey(propertyName)
&& insensitiveFieldsMap.get(propertyName).annotations().stream()
.anyMatch(
annotation ->
MaskedField.class.getName().equals(getAnnotationClassName(annotation)))) {

View File

@ -20,6 +20,7 @@ import com.sun.codemodel.JDefinedClass;
import com.sun.codemodel.JFieldVar;
import com.sun.codemodel.JMethod;
import java.lang.reflect.Field;
import java.util.TreeMap;
import org.jsonschema2pojo.AbstractAnnotator;
/** Add {@link PasswordField} annotation to generated Java classes */
@ -60,8 +61,13 @@ public class PasswordAnnotator extends AbstractAnnotator {
Field outerClassField = JMethod.class.getDeclaredField("outer");
outerClassField.setAccessible(true);
JDefinedClass outerClass = (JDefinedClass) outerClassField.get(jMethod);
if (outerClass.fields().containsKey(propertyName)
&& outerClass.fields().get(propertyName).annotations().stream()
TreeMap<String, JFieldVar> insensitiveFieldsMap =
new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
insensitiveFieldsMap.putAll(outerClass.fields());
if (insensitiveFieldsMap.containsKey(propertyName)
&& insensitiveFieldsMap.get(propertyName).annotations().stream()
.anyMatch(
annotation ->
PasswordField.class.getName().equals(getAnnotationClassName(annotation)))) {

View File

@ -8,7 +8,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import pkgutil
import traceback
from pathlib import Path
@ -46,6 +46,32 @@ class DeployDagException(Exception):
"""
def dump_with_safe_jwt(ingestion_pipeline: IngestionPipeline) -> str:
"""
Get the dump of the IngestionPipeline but keeping the JWT token masked.
Since Pydantic V2, we had to handle the serialization of secrets when dumping
the data at model level, since we don't have anymore fine-grained control of
it at runtime as we did with V1.
This means that even if the JWT token is a secret, a model_dump or model_json_dump
will automatically show the secret value - picking it from the Secrets Manager if enabled.
With this workaround, we're dumping the model to JSON and then replacing the JWT token
with the secret, so that if we are using a Secret Manager, the resulting file
will have the secret ID `secret:/super/secret` instead of the actual value.
Then, the client will pick up the right secret when the workflow is triggered.
"""
pipeline_json = ingestion_pipeline.model_dump(mode="json", exclude_defaults=False)
pipeline_json["openMetadataServerConnection"]["securityConfig"][
"jwtToken"
] = ingestion_pipeline.openMetadataServerConnection.securityConfig.jwtToken.get_secret_value(
skip_secret_manager=True
)
return json.dumps(pipeline_json, ensure_ascii=True)
class DagDeployer:
"""
Helper class to store DAG config
@ -74,9 +100,7 @@ class DagDeployer:
logger.info(f"Saving file to {dag_config_file_path}")
with open(dag_config_file_path, "w") as outfile:
outfile.write(
self.ingestion_pipeline.model_dump_json(exclude_defaults=False)
)
outfile.write(dump_with_safe_jwt(self.ingestion_pipeline))
return {"workflow_config_file": str(dag_config_file_path)}

View File

@ -0,0 +1,67 @@
# Copyright 2021 Collate
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Test run automations
"""
from metadata.generated.schema.entity.teams.user import User
from metadata.ingestion.api.parser import parse_automation_workflow_gracefully
from metadata.ingestion.ometa.ometa_api import OpenMetadata
from metadata.utils.secrets.secrets_manager_factory import SecretsManagerFactory
JSON_REQUEST = {
"id": "6192f18e-5f0c-42e3-8568-0c3ce6e07ac5",
"name": "test-connection-Mysql-Prmeqo2t",
"fullyQualifiedName": "test-connection-Mysql-Prmeqo2t",
"workflowType": "TEST_CONNECTION",
"request": {
"connection": {
"config": {
"type": "Mysql",
"scheme": "mysql+pymysql",
"authType": {"password": "fernet:xyz"},
"hostPort": "mysql:3306",
"username": "openmetadata_user",
"databaseSchema": "openmetadata_db",
}
},
"serviceName": "mysql",
"serviceType": "Database",
"connectionType": "Mysql",
},
"openMetadataServerConnection": {
"clusterName": "openmetadata",
"type": "OpenMetadata",
"hostPort": "http://localhost:8585/api",
"authProvider": "openmetadata",
"securityConfig": {
"jwtToken": "eyJraWQiOiJHYjM4OWEtOWY3Ni1nZGpzLWE5MmotMDI0MmJrOTQzNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImlzQm90IjpmYWxzZSwiaXNzIjoib3Blbi1tZXRhZGF0YS5vcmciLCJpYXQiOjE2NjM5Mzg0NjIsImVtYWlsIjoiYWRtaW5Ab3Blbm1ldGFkYXRhLm9yZyJ9.tS8um_5DKu7HgzGBzS1VTA5uUjKWOCU0B_j08WXBiEC0mr0zNREkqVfwFDD-d24HlNEbrqioLsBuFRiwIWKc1m_ZlVQbG7P36RUxhuv2vbSp80FKyNM-Tj93FDzq91jsyNmsQhyNv_fNr3TXfzzSPjHt8Go0FMMP66weoKMgW2PbXlhVKwEuXUHyakLLzewm9UMeQaEiRzhiTMU3UkLXcKbYEJJvfNFcLwSl9W8JCO_l0Yj3ud-qt_nQYEZwqW6u5nfdQllN133iikV4fM5QZsMCnm8Rq1mvLR0y9bmJiD7fwM1tmJ791TUWqmKaTnP49U493VanKpUAfzIiOiIbhg"
},
},
}
def test_run_automation():
"""Follow the flow in run_automation and validate we're getting the right JWT"""
automation_workflow = parse_automation_workflow_gracefully(config_dict=JSON_REQUEST)
# we need to instantiate the secret manager in case secrets are passed
SecretsManagerFactory(
automation_workflow.openMetadataServerConnection.secretsManagerProvider,
automation_workflow.openMetadataServerConnection.secretsManagerLoader,
)
metadata = OpenMetadata(config=automation_workflow.openMetadataServerConnection)
# Check the token is valid
admin: User = metadata.get_by_name(entity=User, fqn="admin")
assert admin

View File

@ -0,0 +1,72 @@
# Copyright 2021 Collate
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Test Deploy
"""
import os
import uuid
from unittest.mock import patch
from openmetadata_managed_apis.operations.deploy import dump_with_safe_jwt
from metadata.generated.schema.entity.services.connections.metadata.openMetadataConnection import (
AuthProvider,
OpenMetadataConnection,
)
from metadata.generated.schema.entity.services.ingestionPipelines.ingestionPipeline import (
AirflowConfig,
IngestionPipeline,
PipelineType,
)
from metadata.generated.schema.metadataIngestion.workflow import SourceConfig
from metadata.generated.schema.security.client.openMetadataJWTClientConfig import (
OpenMetadataJWTClientConfig,
)
from metadata.generated.schema.security.secrets.secretsManagerClientLoader import (
SecretsManagerClientLoader,
)
from metadata.generated.schema.security.secrets.secretsManagerProvider import (
SecretsManagerProvider,
)
from metadata.generated.schema.type.basic import EntityName, Uuid
from metadata.utils.secrets.aws_secrets_manager import AWSSecretsManager
from metadata.utils.secrets.secrets_manager_factory import SecretsManagerFactory
SECRET_VALUE = "I am a secret value"
INGESTION_PIPELINE = IngestionPipeline(
id=Uuid(str(uuid.uuid4())),
name=EntityName("ingestion-pipeline"),
pipelineType=PipelineType.metadata,
sourceConfig=SourceConfig(),
airflowConfig=AirflowConfig(),
openMetadataServerConnection=OpenMetadataConnection(
hostPort="http://localhost:8585/api",
authProvider=AuthProvider.openmetadata,
securityConfig=OpenMetadataJWTClientConfig(jwtToken="secret:/super/secret"),
),
)
@patch.dict(os.environ, {"AWS_DEFAULT_REGION": "us-east-2"})
def test_deploy_ingestion_pipeline():
"""We can dump an ingestion pipeline to a file without exposing secrets"""
# Instantiate the Secrets Manager
SecretsManagerFactory.clear_all()
with patch.object(AWSSecretsManager, "get_string_value", return_value=SECRET_VALUE):
# Prep the singleton
SecretsManagerFactory(
SecretsManagerProvider.managed_aws,
SecretsManagerClientLoader.noop,
)
# Now we'll try to dump the ingestion pipeline
dumped = dump_with_safe_jwt(INGESTION_PIPELINE)
assert SECRET_VALUE not in dumped

View File

@ -113,28 +113,14 @@ We believe this update will bring greater consistency and clarity to our version
# Backward Incompatible Changes
## 1.4.0
## 1.5.0
### Tooling
- **Metadata Backup & Restore**: The Metadata Backup/Recovery has been deprecated, and no further support will be provided. Users are advised to use database-native tools to back up data and store it in their object store for recovery.
You can check the [docs](/deployment/backup-restore-metadata) for more information.
- **Metadata Docker CLI**: For the past releases, we have been updating the documentation to point users to directly run the docker quickstart
with the docker compose files in the release page ([docs](/quick-start/local-docker-deployment)). In this release, we're completely removing the support for `metadata docker`.
- **bootstrap_storage.sh**: We have deprecated `bootstrap/bootstrap_storage.sh` and replaced it with `bootstrap/openmetadata-ops.sh`. The documentation has been updated accordingly.
### Secrets Manager
- Starting with the release 1.5.0, the JWT Token for the bots will be sent to the Secrets Manager if you configured one.
It won't appear anymore in your `dag_generated_configs` in Airflow.
### UI
- **Activity Feed**: The Activity Feed has been improved with new, updated cards that display critical information such as data quality test case updates, descriptions, tag updates, or asset removal.
- **Lineage**: The Expand All button has been removed. A new Layers button is introduced in the bottom left corner. With the Layers button, you can add Column Level Lineage or Data Observability details to your Lineage view.
- **View Definition**: View Definition is now renamed to Schema Definition
- **Glossary**: Adding Glossary Term view has been improved. Now, we show glossary terms hierarchically, enabling a better understanding of how the terms are set up while labeling a table or dashboard.
- **Classifications**: users can set it to be mutually exclusive **only** at the time of creation. Once created, you cannot change it back to mutually non-exclusive or vice versa.
This is to prevent conflicts between adding multiple tags that belong to the same classification and later turning the mutually exclusive flag back to true.
### API
- **View Definition**: Table Schema's `ViewDefinition` is now renamed to `SchemaDefinition` to capture Tables' Create Schema.
- **Bulk Import**: Bulk Import API now creates entities if they are not present during the import.
- **Test Suites**: Table's `TestSuite` is migrated to an `EntityReference`. Previously it used to store entire payload of `TestSuite`.

View File

@ -89,11 +89,18 @@ The default credential will look for credentials in:
More info in [AWS SDK for Java](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html) and
[Boto3 Docs](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html)
### 3. Restart both servers
### 3. Migrate Secrets & restart both servers
After updating the configuration files, we are ready to restart both services. When the OM server starts, it will
automatically detect that a Secrets Manager has been configured and will migrate all our sensitive data and remove it
from our DB.
After updating the configuration files, we are ready to migrate the secrets and restart both services.
In order to ensure that the current sensitive information is properly migrated to the Secrets Manager, you need to
run the following command:
```bash
./bootstrap/openmetadata-ops.sh migrate-secrets
```
Make sure you are running it with the same environment variables required by the server.
If everything goes as planned, all the data would be displayed using the secrets names which starts with
`/openmetadata/...` in your AWS Secrets Manager console. The following image shows what it should look like:

View File

@ -87,11 +87,18 @@ default credential will look for credentials in:
More info in [AWS SDK for Java](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html) and
[Boto3 Docs](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html)
### 3. Restart both servers
### 3. Migrate Secrets & restart both servers
After updating the configuration files, we are ready to restart both services. When the OM server starts, it will
automatically detect that a Secrets Manager has been configured and will migrate all our sensitive data and remove it
from our DB.
After updating the configuration files, we are ready to migrate the secrets and restart both services.
In order to ensure that the current sensitive information is properly migrated to the Secrets Manager, you need to
run the following command:
```bash
./bootstrap/openmetadata-ops.sh migrate-secrets
```
Make sure you are running it with the same environment variables required by the server.
If everything goes as planned, all the data would be displayed using the parameters names which starts with
`/openmetadata/...` in your AWS Systems Manager Parameter Store console. The following image shows what it should look

View File

@ -163,11 +163,18 @@ serviceAccount:
{% /note %}
### 3. Restart both servers
### 3. Migrate Secrets & restart both servers
After updating the configuration files, we are ready to restart both services. When the OM server starts, it will
automatically detect that a Secrets Manager has been configured and will migrate all our sensitive data and remove it
from our DB.
After updating the configuration files, we are ready to migrate the secrets and restart both services.
In order to ensure that the current sensitive information is properly migrated to the Secrets Manager, you need to
run the following command:
```bash
./bootstrap/openmetadata-ops.sh migrate-secrets
```
Make sure you are running it with the same environment variables required by the server.
If everything goes as planned, all the data would be displayed using the parameters names which starts with
`openmetadata-...` in your Key Vault console.

View File

@ -22,4 +22,11 @@ This is our list of supported Secrets Manager implementations:
href="/deployment/secrets-manager/supported-implementations/aws-ssm-parameter-store" %}
AWS Systems Manager Parameter Store
{% /inlineCallout %}
{% inlineCallout
color="violet-70"
bold="Azure Key Vault"
icon="vpn_key"
href="/deployment/secrets-manager/supported-implementations/azure-key-vault" %}
Azure Key Vault
{% /inlineCallout %}
{% /inlineCalloutContainer %}

View File

@ -89,11 +89,18 @@ The default credential will look for credentials in:
More info in [AWS SDK for Java](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html) and
[Boto3 Docs](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html)
### 3. Restart both servers
### 3. Migrate Secrets & restart both servers
After updating the configuration files, we are ready to restart both services. When the OM server starts, it will
automatically detect that a Secrets Manager has been configured and will migrate all our sensitive data and remove it
from our DB.
After updating the configuration files, we are ready to migrate the secrets and restart both services.
In order to ensure that the current sensitive information is properly migrated to the Secrets Manager, you need to
run the following command:
```bash
./bootstrap/openmetadata-ops.sh migrate-secrets
```
Make sure you are running it with the same environment variables required by the server.
If everything goes as planned, all the data would be displayed using the secrets names which starts with
`/openmetadata/...` in your AWS Secrets Manager console. The following image shows what it should look like:

View File

@ -87,11 +87,18 @@ default credential will look for credentials in:
More info in [AWS SDK for Java](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html) and
[Boto3 Docs](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html)
### 3. Restart both servers
### 3. Migrate Secrets & restart both servers
After updating the configuration files, we are ready to restart both services. When the OM server starts, it will
automatically detect that a Secrets Manager has been configured and will migrate all our sensitive data and remove it
from our DB.
After updating the configuration files, we are ready to migrate the secrets and restart both services.
In order to ensure that the current sensitive information is properly migrated to the Secrets Manager, you need to
run the following command:
```bash
./bootstrap/openmetadata-ops.sh migrate-secrets
```
Make sure you are running it with the same environment variables required by the server.
If everything goes as planned, all the data would be displayed using the parameters names which starts with
`/openmetadata/...` in your AWS Systems Manager Parameter Store console. The following image shows what it should look

View File

@ -163,11 +163,18 @@ serviceAccount:
{% /note %}
### 3. Restart both servers
### 3. Migrate Secrets & restart both servers
After updating the configuration files, we are ready to restart both services. When the OM server starts, it will
automatically detect that a Secrets Manager has been configured and will migrate all our sensitive data and remove it
from our DB.
After updating the configuration files, we are ready to migrate the secrets and restart both services.
In order to ensure that the current sensitive information is properly migrated to the Secrets Manager, you need to
run the following command:
```bash
./bootstrap/openmetadata-ops.sh migrate-secrets
```
Make sure you are running it with the same environment variables required by the server.
If everything goes as planned, all the data would be displayed using the parameters names which starts with
`openmetadata-...` in your Key Vault console.

View File

@ -22,4 +22,11 @@ This is our list of supported Secrets Manager implementations:
href="/deployment/secrets-manager/supported-implementations/aws-ssm-parameter-store" %}
AWS Systems Manager Parameter Store
{% /inlineCallout %}
{% inlineCallout
color="violet-70"
bold="Azure Key Vault"
icon="vpn_key"
href="/deployment/secrets-manager/supported-implementations/azure-key-vault" %}
Azure Key Vault
{% /inlineCallout %}
{% /inlineCalloutContainer %}

View File

@ -163,6 +163,56 @@ workflowConfig:
# caCertificate: /local/path/to/certificate
```
#### JWT Token with Secrets Manager
If you are using the [Secrets Manager](/deployment/secrets-manager), you can let the Ingestion client to pick up
the JWT Token dynamically from the Secrets Manager at runtime. Let's show an example:
We have an OpenMetadata server running with the `managed-aws` Secrets Manager. Since we used the `OPENMETADATA_CLUSTER_NAME` env var
as `test`, our `ingestion-bot` JWT Token is safely stored under the secret ID `
/test/bot/ingestion-bot/config/jwttoken`.
Now, we can use the following workflow config to run the ingestion without having to pass the token, but just pointing to the secret itself:
```yaml
workflowConfig:
loggerLevel: INFO # DEBUG, INFO, WARNING or ERROR
openMetadataServerConfig:
hostPort: "http://localhost:8585/api"
authProvider: openmetadata
securityConfig:
jwtToken: "secret:/test/bot/ingestion-bot/config/jwttoken"
secretsManagerProvider: aws
secretsManagerLoader: env
```
Notice how:
1. We specify the `secretsManagerProvider` pointing to `aws`, since that's the manager we are using.
2. We set `secretsManagerLoader` as `env`. Since we're running this from our local, we'll let the AWS credentials to be
loaded from the local env vars. (When running this using the UI, note that the generated workflows will have this
value set as `airflow`!)
3. We set the `jwtToken` value as `secret:/test/bot/ingestion-bot/config/jwttoken`, which tells the client that
this value is a `secret` located under `/test/bot/ingestion-bot/config/jwttoken`.
Those are our env vars:
```
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_DEFAULT_REGION=...
```
And we can run this normally with `metadata ingest -c <path to yaml>`.
{% note %}
Note that **even if you are not using the Secrets Manager for the OpenMetadata Server**, you can still apply the same
approach by storing the JWT token manually to the secrets manager, and let the Ingestion client pick it up
from there automatically.
{% /note %}
## 3. (Optional) Ingestion Pipeline
Additionally, if you want to see your runs logged in the `Ingestions` tab of the connectors page in the UI as you would

View File

@ -89,11 +89,18 @@ The default credential will look for credentials in:
More info in [AWS SDK for Java](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html) and
[Boto3 Docs](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html)
### 3. Restart both servers
### 3. Migrate Secrets & restart both servers
After updating the configuration files, we are ready to restart both services. When the OM server starts, it will
automatically detect that a Secrets Manager has been configured and will migrate all our sensitive data and remove it
from our DB.
After updating the configuration files, we are ready to migrate the secrets and restart both services.
In order to ensure that the current sensitive information is properly migrated to the Secrets Manager, you need to
run the following command:
```bash
./bootstrap/openmetadata-ops.sh migrate-secrets
```
Make sure you are running it with the same environment variables required by the server.
If everything goes as planned, all the data would be displayed using the secrets names which starts with
`/openmetadata/...` in your AWS Secrets Manager console. The following image shows what it should look like:

View File

@ -87,11 +87,18 @@ default credential will look for credentials in:
More info in [AWS SDK for Java](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html) and
[Boto3 Docs](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html)
### 3. Restart both servers
### 3. Migrate Secrets & restart both servers
After updating the configuration files, we are ready to restart both services. When the OM server starts, it will
automatically detect that a Secrets Manager has been configured and will migrate all our sensitive data and remove it
from our DB.
After updating the configuration files, we are ready to migrate the secrets and restart both services.
In order to ensure that the current sensitive information is properly migrated to the Secrets Manager, you need to
run the following command:
```bash
./bootstrap/openmetadata-ops.sh migrate-secrets
```
Make sure you are running it with the same environment variables required by the server.
If everything goes as planned, all the data would be displayed using the parameters names which starts with
`/openmetadata/...` in your AWS Systems Manager Parameter Store console. The following image shows what it should look

View File

@ -163,11 +163,18 @@ serviceAccount:
{% /note %}
### 3. Restart both servers
### 3. Migrate Secrets & restart both servers
After updating the configuration files, we are ready to restart both services. When the OM server starts, it will
automatically detect that a Secrets Manager has been configured and will migrate all our sensitive data and remove it
from our DB.
After updating the configuration files, we are ready to migrate the secrets and restart both services.
In order to ensure that the current sensitive information is properly migrated to the Secrets Manager, you need to
run the following command:
```bash
./bootstrap/openmetadata-ops.sh migrate-secrets
```
Make sure you are running it with the same environment variables required by the server.
If everything goes as planned, all the data would be displayed using the parameters names which starts with
`openmetadata-...` in your Key Vault console.

View File

@ -67,11 +67,18 @@ ADC will look for credentials in:
More info in [Set up Application Default Credentials](https://cloud.google.com/docs/authentication/provide-credentials-adc)
### 3. Restart both servers
### 3. Migrate Secrets & restart both servers
After updating the configuration files, we are ready to restart both services. When the OM server starts, it will
automatically detect that a Secrets Manager has been configured and will migrate all our sensitive data and remove it
from our DB.
After updating the configuration files, we are ready to migrate the secrets and restart both services.
In order to ensure that the current sensitive information is properly migrated to the Secrets Manager, you need to
run the following command:
```bash
./bootstrap/openmetadata-ops.sh migrate-secrets
```
Make sure you are running it with the same environment variables required by the server.
If everything goes as planned, all the data would be displayed using the parameters names which starts with
`/openmetadata/...` in your GCP Secret Manager console. The following image shows what it should look

View File

@ -22,4 +22,18 @@ This is our list of supported Secrets Manager implementations:
href="/deployment/secrets-manager/supported-implementations/aws-ssm-parameter-store" %}
AWS Systems Manager Parameter Store
{% /inlineCallout %}
{% inlineCallout
color="violet-70"
bold="Azure Key Vault"
icon="vpn_key"
href="/deployment/secrets-manager/supported-implementations/azure-key-vault" %}
Azure Key Vault
{% /inlineCallout %}
{% inlineCallout
color="violet-70"
bold="GCP Secrets Manager"
icon="vpn_key"
href="/deployment/secrets-manager/supported-implementations/gcp-secret-manager" %}
GCP Secrets Manager
{% /inlineCallout %}
{% /inlineCalloutContainer %}

View File

@ -25,6 +25,7 @@ import static org.openmetadata.schema.entity.teams.AuthenticationMechanism.AuthT
import static org.openmetadata.schema.type.Include.ALL;
import static org.openmetadata.service.exception.CatalogExceptionMessage.EMAIL_SENDING_ISSUE;
import static org.openmetadata.service.jdbi3.UserRepository.AUTH_MECHANISM_FIELD;
import static org.openmetadata.service.secrets.ExternalSecretsManager.NULL_SECRET_STRING;
import static org.openmetadata.service.security.jwt.JWTTokenGenerator.getExpiryDate;
import static org.openmetadata.service.util.UserUtil.getRoleListFromUser;
import static org.openmetadata.service.util.UserUtil.getRolesFromAuthorizationToken;
@ -51,6 +52,7 @@ import java.time.ZoneId;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@ -1564,7 +1566,8 @@ public class UserResource extends EntityResource<User, UserRepository> {
switch (authType) {
case JWT -> {
User original = retrieveBotUser(user, uriInfo);
if (original == null || !hasAJWTAuthMechanism(original.getAuthenticationMechanism())) {
if (original == null
|| !hasAJWTAuthMechanism(user, original.getAuthenticationMechanism())) {
JWTAuthMechanism jwtAuthMechanism =
JsonUtils.convertValue(authMechanism.getConfig(), JWTAuthMechanism.class);
authMechanism.setConfig(
@ -1615,12 +1618,15 @@ public class UserResource extends EntityResource<User, UserRepository> {
new AuthenticationMechanism().withAuthType(BASIC).withConfig(newAuthForUser));
}
private boolean hasAJWTAuthMechanism(AuthenticationMechanism authMechanism) {
private boolean hasAJWTAuthMechanism(User user, AuthenticationMechanism authMechanism) {
if (authMechanism != null && JWT.equals(authMechanism.getAuthType())) {
SecretsManager secretsManager = SecretsManagerFactory.getSecretsManager();
secretsManager.decryptAuthenticationMechanism(user.getName(), authMechanism);
JWTAuthMechanism jwtAuthMechanism =
JsonUtils.convertValue(authMechanism.getConfig(), JWTAuthMechanism.class);
return jwtAuthMechanism != null
&& jwtAuthMechanism.getJWTToken() != null
&& !Objects.equals(jwtAuthMechanism.getJWTToken(), NULL_SECRET_STRING)
&& !StringUtils.EMPTY.equals(jwtAuthMechanism.getJWTToken());
}
return false;

View File

@ -71,7 +71,7 @@ public class AWSSecretsManager extends AWSBasedSecretsManager {
UpdateSecretRequest.builder()
.secretId(secretName)
.description("This secret was created by OpenMetadata")
.secretString(Objects.isNull(secretValue) ? NULL_SECRET_STRING : secretValue)
.secretString(cleanNullOrEmpty(secretValue))
.build();
this.secretsClient.updateSecret(updateSecretRequest);
}

View File

@ -84,7 +84,7 @@ public class AzureKVSecretsManager extends ExternalSecretsManager {
@Override
void storeSecret(String secretName, String secretValue) {
client.setSecret(
new KeyVaultSecret(secretName, secretValue)
new KeyVaultSecret(secretName, cleanNullOrEmpty(secretValue))
.setProperties(
new SecretProperties().setTags(SecretsManager.getTags(getSecretsConfig()))));
}

View File

@ -38,4 +38,9 @@ public class DBSecretsManager extends SecretsManager {
// Nothing to delete on the Noop SM. We only delete on External SM
@Override
protected void deleteSecretInternal(String secretName) {}
@Override
String getSecret(String secretName) {
return secretName;
}
}

View File

@ -14,12 +14,12 @@
package org.openmetadata.service.secrets;
import java.util.Locale;
import java.util.Objects;
import org.openmetadata.schema.security.secrets.SecretsManagerProvider;
import org.openmetadata.service.exception.UnhandledServerException;
public abstract class ExternalSecretsManager extends SecretsManager {
public static final String NULL_SECRET_STRING = "null";
public static final String SECRET_FIELD_PREFIX = "secret:";
private final long waitTimeBetweenStoreCalls;
protected ExternalSecretsManager(
@ -34,7 +34,7 @@ public abstract class ExternalSecretsManager extends SecretsManager {
protected String storeValue(String fieldName, String value, String secretId, boolean store) {
String fieldSecretId = buildSecretId(false, secretId, fieldName.toLowerCase(Locale.ROOT));
// check if value does not start with 'config:' only String can have password annotation
if (!value.startsWith(SECRET_FIELD_PREFIX)) {
if (Boolean.FALSE.equals(isSecret(value))) {
if (store) {
upsertSecret(fieldSecretId, value);
}
@ -68,8 +68,6 @@ public abstract class ExternalSecretsManager extends SecretsManager {
abstract void updateSecret(String secretName, String secretValue);
abstract String getSecret(String secretName);
private void sleep() {
// delay reaching secrets manager quotas
if (waitTimeBetweenStoreCalls > 0) {
@ -81,4 +79,8 @@ public abstract class ExternalSecretsManager extends SecretsManager {
}
}
}
public String cleanNullOrEmpty(String secretValue) {
return Objects.isNull(secretValue) || secretValue.isEmpty() ? NULL_SECRET_STRING : secretValue;
}
}

View File

@ -60,7 +60,7 @@ public class GCPSecretsManager extends ExternalSecretsManager {
} catch (IOException e) {
throw new SecretsManagerUpdateException(e.getMessage(), e);
}
updateSecret(secretId, secretValue);
updateSecret(secretId, cleanNullOrEmpty(secretValue));
}
@Override

View File

@ -30,6 +30,7 @@ import lombok.extern.slf4j.Slf4j;
import org.openmetadata.annotations.PasswordField;
import org.openmetadata.common.utils.CommonUtil;
import org.openmetadata.schema.auth.BasicAuthMechanism;
import org.openmetadata.schema.auth.JWTAuthMechanism;
import org.openmetadata.schema.entity.automations.Workflow;
import org.openmetadata.schema.entity.services.ServiceType;
import org.openmetadata.schema.entity.services.ingestionPipelines.IngestionPipeline;
@ -48,6 +49,8 @@ import org.openmetadata.service.util.ReflectionUtil;
@Slf4j
public abstract class SecretsManager {
public static final String SECRET_FIELD_PREFIX = "secret:";
public record SecretsConfig(
String clusterName, String prefix, List<String> tags, Parameters parameters) {}
@ -72,6 +75,20 @@ public abstract class SecretsManager {
this.secretsIdConfig = builSecretsIdConfig();
}
public Boolean isSecret(String string) {
return string.startsWith(SECRET_FIELD_PREFIX);
}
public String getSecretValue(String secretWithPrefix) {
String secretName = secretWithPrefix.split(SECRET_FIELD_PREFIX, 2)[1];
return getSecret(secretName);
}
/**
* GET a secret using the SM implementation if the string starts with `secret:/`
*/
abstract String getSecret(String secretName);
/**
* Override this method in any Secrets Manager implementation
* that has other requirements
@ -124,32 +141,43 @@ public abstract class SecretsManager {
}
}
public void encryptAuthenticationMechanism(
public Object encryptAuthenticationMechanism(
String name, AuthenticationMechanism authenticationMechanism) {
if (authenticationMechanism != null) {
AuthenticationMechanismBuilder.addDefinedConfig(authenticationMechanism);
try {
encryptPasswordFields(authenticationMechanism, buildSecretId(true, "bot", name), true);
return encryptPasswordFields(
authenticationMechanism, buildSecretId(true, "bot", name), true);
} catch (Exception e) {
throw new SecretsManagerException(
Response.Status.BAD_REQUEST,
String.format("Failed to encrypt user bot instance [%s]", name));
}
}
return null;
}
public void decryptAuthenticationMechanism(
/**
* This is used to handle the JWT Token internally, in the JWTFilter, when
* calling for the auth-mechanism in the UI, etc.
* If using SM, we need to decrypt and GET the secret to ensure we are comparing
* the right values.
*/
public AuthenticationMechanism decryptAuthenticationMechanism(
String name, AuthenticationMechanism authenticationMechanism) {
if (authenticationMechanism != null) {
AuthenticationMechanismBuilder.addDefinedConfig(authenticationMechanism);
try {
decryptPasswordFields(authenticationMechanism);
AuthenticationMechanism fernetDecrypted =
(AuthenticationMechanism) decryptPasswordFields(authenticationMechanism);
return (AuthenticationMechanism) getSecretFields(fernetDecrypted);
} catch (Exception e) {
throw new SecretsManagerException(
Response.Status.BAD_REQUEST,
String.format("Failed to decrypt user bot instance [%s]", name));
}
}
return null;
}
public void encryptIngestionPipeline(IngestionPipeline ingestionPipeline) {
@ -261,6 +289,22 @@ public abstract class SecretsManager {
return null;
}
/**
* Used only in the OM Connection Builder, which sends the credentials to Ingestion Workflows
*/
public JWTAuthMechanism decryptJWTAuthMechanism(JWTAuthMechanism authMechanism) {
if (authMechanism != null) {
try {
decryptPasswordFields(authMechanism);
} catch (Exception e) {
throw new SecretsManagerException(
Response.Status.BAD_REQUEST, "Failed to decrypt OpenMetadataConnection instance.");
}
return authMechanism;
}
return null;
}
private Object encryptPasswordFields(Object toEncryptObject, String secretId, boolean store) {
try {
if (!DO_NOT_ENCRYPT_CLASSES.contains(toEncryptObject.getClass())) {
@ -339,6 +383,45 @@ public abstract class SecretsManager {
}
}
/**
* Get the object and use the secrets manager to get the right value to show
*/
private Object getSecretFields(Object toDecryptObject) {
try {
// for each get method
Arrays.stream(toDecryptObject.getClass().getMethods())
.filter(ReflectionUtil::isGetMethodOfObject)
.forEach(
method -> {
Object obj = ReflectionUtil.getObjectFromMethod(method, toDecryptObject);
String fieldName = method.getName().replaceFirst("get", "");
// if the object matches the package of openmetadata
if (Boolean.TRUE.equals(CommonUtil.isOpenMetadataObject(obj))) {
// encryptPasswordFields
getSecretFields(obj);
// check if it has annotation
} else if (obj != null && method.getAnnotation(PasswordField.class) != null) {
String fieldValue = (String) obj;
// get setMethod
Method toSet = ReflectionUtil.getToSetMethod(toDecryptObject, obj, fieldName);
// set new value
ReflectionUtil.setValueInMethod(
toDecryptObject,
Boolean.TRUE.equals(isSecret(fieldValue))
? getSecretValue(fieldValue)
: fieldValue,
toSet);
}
});
return toDecryptObject;
} catch (Exception e) {
throw new SecretsManagerException(
String.format(
"Error trying to GET secret [%s] due to [%s]",
toDecryptObject.toString(), e.getMessage()));
}
}
protected abstract String storeValue(
String fieldName, String value, String secretId, boolean store);

View File

@ -15,6 +15,7 @@ package org.openmetadata.service.secrets.masker;
import java.util.Set;
import org.openmetadata.schema.auth.BasicAuthMechanism;
import org.openmetadata.schema.auth.JWTAuthMechanism;
import org.openmetadata.schema.entity.automations.Workflow;
import org.openmetadata.schema.entity.services.ServiceType;
import org.openmetadata.schema.entity.services.ingestionPipelines.IngestionPipeline;
@ -24,7 +25,7 @@ import org.openmetadata.schema.security.client.OpenMetadataJWTClientConfig;
public abstract class EntityMasker {
protected static final Set<Class<?>> DO_NOT_MASK_CLASSES =
Set.of(OpenMetadataJWTClientConfig.class, BasicAuthMechanism.class);
Set.of(OpenMetadataJWTClientConfig.class, JWTAuthMechanism.class, BasicAuthMechanism.class);
public abstract Object maskServiceConnectionConfig(
Object connectionConfig, String connectionType, ServiceType serviceType);

View File

@ -17,6 +17,8 @@ import org.openmetadata.schema.entity.teams.User;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.UserRepository;
import org.openmetadata.service.resources.teams.UserResource;
import org.openmetadata.service.secrets.SecretsManager;
import org.openmetadata.service.secrets.SecretsManagerFactory;
import org.openmetadata.service.util.EntityUtil.Fields;
import org.openmetadata.service.util.JsonUtils;
@ -64,6 +66,8 @@ public class BotTokenCache {
NON_DELETED,
true);
AuthenticationMechanism authenticationMechanism = user.getAuthenticationMechanism();
SecretsManager secretsManager = SecretsManagerFactory.getSecretsManager();
secretsManager.decryptAuthenticationMechanism(user.getName(), authenticationMechanism);
if (authenticationMechanism != null) {
JWTAuthMechanism jwtAuthMechanism =
JsonUtils.convertValue(authenticationMechanism.getConfig(), JWTAuthMechanism.class);

View File

@ -36,6 +36,7 @@ import org.openmetadata.service.exception.EntityNotFoundException;
import org.openmetadata.service.jdbi3.BotRepository;
import org.openmetadata.service.jdbi3.IngestionPipelineRepository;
import org.openmetadata.service.jdbi3.UserRepository;
import org.openmetadata.service.secrets.SecretsManager;
import org.openmetadata.service.secrets.SecretsManagerFactory;
import org.openmetadata.service.util.EntityUtil.Fields;
@ -52,6 +53,7 @@ public class OpenMetadataConnectionBuilder {
private Object openMetadataSSLConfig;
BotRepository botRepository;
UserRepository userRepository;
SecretsManager secretsManager;
public OpenMetadataConnectionBuilder(
OpenMetadataApplicationConfig openMetadataApplicationConfig) {
@ -128,7 +130,8 @@ public class OpenMetadataConnectionBuilder {
clusterName = openMetadataApplicationConfig.getClusterName();
secretsManagerLoader = pipelineServiceClientConfiguration.getSecretsManagerLoader();
secretsManagerProvider = SecretsManagerFactory.getSecretsManager().getSecretsManagerProvider();
secretsManager = SecretsManagerFactory.getSecretsManager();
secretsManagerProvider = secretsManager.getSecretsManagerProvider();
}
private void initializeBotUser(String botName) {
@ -157,6 +160,7 @@ public class OpenMetadataConnectionBuilder {
== AuthenticationMechanism.AuthType.JWT) {
JWTAuthMechanism jwtAuthMechanism =
JsonUtils.convertValue(authMechanism.getConfig(), JWTAuthMechanism.class);
secretsManager.decryptJWTAuthMechanism(jwtAuthMechanism);
return new OpenMetadataJWTClientConfig().withJwtToken(jwtAuthMechanism.getJWTToken());
}
throw new IllegalArgumentException(

View File

@ -108,7 +108,7 @@ public class OpenMetadataOperations implements Callable<Integer> {
public Integer call() {
LOG.info(
"Subcommand needed: 'info', 'validate', 'repair', 'check-connection', "
+ "'drop-create', 'migrate', 'reindex', 'deploy-pipelines'");
+ "'drop-create', 'changelog', 'migrate', 'migrate-secrets', 'reindex', 'deploy-pipelines'");
return 0;
}

View File

@ -15,10 +15,13 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.schema.api.services.DatabaseConnection;
import org.openmetadata.schema.auth.JWTAuthMechanism;
import org.openmetadata.schema.auth.JWTTokenExpiry;
import org.openmetadata.schema.entity.automations.TestServiceConnectionRequest;
import org.openmetadata.schema.entity.automations.Workflow;
import org.openmetadata.schema.entity.automations.WorkflowType;
import org.openmetadata.schema.entity.services.ServiceType;
import org.openmetadata.schema.entity.teams.AuthenticationMechanism;
import org.openmetadata.schema.services.connections.database.MysqlConnection;
import org.openmetadata.schema.services.connections.database.common.basicAuth;
import org.openmetadata.service.exception.SecretsManagerException;
@ -47,6 +50,30 @@ public class SecretsManagerLifecycleTest {
secretsManager.setFernet(fernet);
}
@Test
void testJWTTokenEncryption() {
AuthenticationMechanism authenticationMechanism =
new AuthenticationMechanism()
.withAuthType(AuthenticationMechanism.AuthType.JWT)
.withConfig(
new JWTAuthMechanism()
.withJWTToken("token")
.withJWTTokenExpiry(JWTTokenExpiry.Unlimited));
AuthenticationMechanism encrypted =
(AuthenticationMechanism)
secretsManager.encryptAuthenticationMechanism("ingestion-bot", authenticationMechanism);
// Validate that the JWT Token gets properly encrypted
JWTAuthMechanism encryptedAuth = (JWTAuthMechanism) encrypted.getConfig();
assertEquals(ENCRYPTED_VALUE, encryptedAuth.getJWTToken());
AuthenticationMechanism decrypted =
secretsManager.decryptAuthenticationMechanism("ingestion-bot", encrypted);
JWTAuthMechanism decryptedAuth = (JWTAuthMechanism) decrypted.getConfig();
assertEquals(DECRYPTED_VALUE, decryptedAuth.getJWTToken());
}
@Test
void testDatabaseServiceConnectionConfigLifecycle() {
String password = "openmetadata-test";

View File

@ -38,8 +38,11 @@
},
"properties": {
"JWTToken": {
"title": "JWT Token",
"description": "JWT Auth Token.",
"type": "string"
"type": "string",
"format": "password",
"expose": true
},
"JWTTokenExpiry": {
"$ref": "#/definitions/JWTTokenExpiry"