mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
feat(lighthouse): Support Amazon Bedrock Long-Term API Key (#9343)
Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
cb84bd0f94
commit
26fd7d3adc
@@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
### Added
|
||||
- New endpoint to retrieve an overview of the attack surfaces [(#9309)](https://github.com/prowler-cloud/prowler/pull/9309)
|
||||
- Lighthouse AI support for Amazon Bedrock API key [(#9343)](https://github.com/prowler-cloud/prowler/pull/9343)
|
||||
- Exception handler for provider deletions during scans [(#9414)](https://github.com/prowler-cloud/prowler/pull/9414)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -12655,26 +12655,46 @@ components:
|
||||
pattern: ^sk-[\w-]+$
|
||||
required:
|
||||
- api_key
|
||||
- type: object
|
||||
title: AWS Bedrock Credentials
|
||||
properties:
|
||||
access_key_id:
|
||||
type: string
|
||||
description: AWS access key ID.
|
||||
pattern: ^AKIA[0-9A-Z]{16}$
|
||||
secret_access_key:
|
||||
type: string
|
||||
description: AWS secret access key.
|
||||
pattern: ^[A-Za-z0-9/+=]{40}$
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- access_key_id
|
||||
- secret_access_key
|
||||
- region
|
||||
- title: AWS Bedrock Credentials
|
||||
oneOf:
|
||||
- title: IAM Access Key Pair
|
||||
type: object
|
||||
description: Authenticate with AWS access key and secret key. Recommended
|
||||
when you manage IAM users or roles.
|
||||
properties:
|
||||
access_key_id:
|
||||
type: string
|
||||
description: AWS access key ID.
|
||||
pattern: ^AKIA[0-9A-Z]{16}$
|
||||
secret_access_key:
|
||||
type: string
|
||||
description: AWS secret access key.
|
||||
pattern: ^[A-Za-z0-9/+=]{40}$
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- access_key_id
|
||||
- secret_access_key
|
||||
- region
|
||||
- title: Amazon Bedrock API Key
|
||||
type: object
|
||||
description: Authenticate with an Amazon Bedrock API key (bearer
|
||||
token). Region is still required.
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: Amazon Bedrock API key (bearer token).
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- api_key
|
||||
- region
|
||||
- type: object
|
||||
title: OpenAI Compatible Credentials
|
||||
properties:
|
||||
@@ -12742,26 +12762,46 @@ components:
|
||||
pattern: ^sk-[\w-]+$
|
||||
required:
|
||||
- api_key
|
||||
- type: object
|
||||
title: AWS Bedrock Credentials
|
||||
properties:
|
||||
access_key_id:
|
||||
type: string
|
||||
description: AWS access key ID.
|
||||
pattern: ^AKIA[0-9A-Z]{16}$
|
||||
secret_access_key:
|
||||
type: string
|
||||
description: AWS secret access key.
|
||||
pattern: ^[A-Za-z0-9/+=]{40}$
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- access_key_id
|
||||
- secret_access_key
|
||||
- region
|
||||
- title: AWS Bedrock Credentials
|
||||
oneOf:
|
||||
- title: IAM Access Key Pair
|
||||
type: object
|
||||
description: Authenticate with AWS access key and secret key.
|
||||
Recommended when you manage IAM users or roles.
|
||||
properties:
|
||||
access_key_id:
|
||||
type: string
|
||||
description: AWS access key ID.
|
||||
pattern: ^AKIA[0-9A-Z]{16}$
|
||||
secret_access_key:
|
||||
type: string
|
||||
description: AWS secret access key.
|
||||
pattern: ^[A-Za-z0-9/+=]{40}$
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- access_key_id
|
||||
- secret_access_key
|
||||
- region
|
||||
- title: Amazon Bedrock API Key
|
||||
type: object
|
||||
description: Authenticate with an Amazon Bedrock API key (bearer
|
||||
token). Region is still required.
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: Amazon Bedrock API key (bearer token).
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- api_key
|
||||
- region
|
||||
- type: object
|
||||
title: OpenAI Compatible Credentials
|
||||
properties:
|
||||
@@ -12847,26 +12887,46 @@ components:
|
||||
pattern: ^sk-[\w-]+$
|
||||
required:
|
||||
- api_key
|
||||
- type: object
|
||||
title: AWS Bedrock Credentials
|
||||
properties:
|
||||
access_key_id:
|
||||
type: string
|
||||
description: AWS access key ID.
|
||||
pattern: ^AKIA[0-9A-Z]{16}$
|
||||
secret_access_key:
|
||||
type: string
|
||||
description: AWS secret access key.
|
||||
pattern: ^[A-Za-z0-9/+=]{40}$
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- access_key_id
|
||||
- secret_access_key
|
||||
- region
|
||||
- title: AWS Bedrock Credentials
|
||||
oneOf:
|
||||
- title: IAM Access Key Pair
|
||||
type: object
|
||||
description: Authenticate with AWS access key and secret key. Recommended
|
||||
when you manage IAM users or roles.
|
||||
properties:
|
||||
access_key_id:
|
||||
type: string
|
||||
description: AWS access key ID.
|
||||
pattern: ^AKIA[0-9A-Z]{16}$
|
||||
secret_access_key:
|
||||
type: string
|
||||
description: AWS secret access key.
|
||||
pattern: ^[A-Za-z0-9/+=]{40}$
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- access_key_id
|
||||
- secret_access_key
|
||||
- region
|
||||
- title: Amazon Bedrock API Key
|
||||
type: object
|
||||
description: Authenticate with an Amazon Bedrock API key (bearer
|
||||
token). Region is still required.
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: Amazon Bedrock API key (bearer token).
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- api_key
|
||||
- region
|
||||
- type: object
|
||||
title: OpenAI Compatible Credentials
|
||||
properties:
|
||||
@@ -14289,26 +14349,46 @@ components:
|
||||
pattern: ^sk-[\w-]+$
|
||||
required:
|
||||
- api_key
|
||||
- type: object
|
||||
title: AWS Bedrock Credentials
|
||||
properties:
|
||||
access_key_id:
|
||||
type: string
|
||||
description: AWS access key ID.
|
||||
pattern: ^AKIA[0-9A-Z]{16}$
|
||||
secret_access_key:
|
||||
type: string
|
||||
description: AWS secret access key.
|
||||
pattern: ^[A-Za-z0-9/+=]{40}$
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- access_key_id
|
||||
- secret_access_key
|
||||
- region
|
||||
- title: AWS Bedrock Credentials
|
||||
oneOf:
|
||||
- title: IAM Access Key Pair
|
||||
type: object
|
||||
description: Authenticate with AWS access key and secret key.
|
||||
Recommended when you manage IAM users or roles.
|
||||
properties:
|
||||
access_key_id:
|
||||
type: string
|
||||
description: AWS access key ID.
|
||||
pattern: ^AKIA[0-9A-Z]{16}$
|
||||
secret_access_key:
|
||||
type: string
|
||||
description: AWS secret access key.
|
||||
pattern: ^[A-Za-z0-9/+=]{40}$
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- access_key_id
|
||||
- secret_access_key
|
||||
- region
|
||||
- title: Amazon Bedrock API Key
|
||||
type: object
|
||||
description: Authenticate with an Amazon Bedrock API key (bearer
|
||||
token). Region is still required.
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: Amazon Bedrock API key (bearer token).
|
||||
region:
|
||||
type: string
|
||||
description: 'AWS region identifier where Bedrock is available.
|
||||
Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.'
|
||||
pattern: ^[a-z]{2}-[a-z]+-\d+$
|
||||
required:
|
||||
- api_key
|
||||
- region
|
||||
- type: object
|
||||
title: OpenAI Compatible Credentials
|
||||
properties:
|
||||
|
||||
@@ -10571,6 +10571,540 @@ class TestLighthouseProviderConfigViewSet:
|
||||
# Unrelated entries should remain untouched
|
||||
assert cfg.default_models.get("other") == "model-x"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"credentials",
|
||||
[
|
||||
{}, # empty credentials
|
||||
{
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE"
|
||||
}, # missing secret_access_key and region
|
||||
{
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
}, # missing access_key_id and region
|
||||
{
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}, # missing region
|
||||
{ # invalid access_key_id format (not starting with AKIA)
|
||||
"access_key_id": "ABCD0123456789ABCDEF",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
},
|
||||
{ # invalid access_key_id format (wrong length)
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPL",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
},
|
||||
{ # invalid secret_access_key format (wrong length)
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEK",
|
||||
"region": "us-east-1",
|
||||
},
|
||||
{ # invalid region format
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "invalid-region",
|
||||
},
|
||||
{ # invalid region format (uppercase)
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "US-EAST-1",
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_bedrock_invalid_credentials(self, authenticated_client, credentials):
|
||||
"""Bedrock provider with invalid credentials should error"""
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_bedrock_valid_credentials_success(self, authenticated_client):
|
||||
"""Bedrock provider with valid AWS credentials should succeed and mask credentials"""
|
||||
valid_credentials = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
}
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": valid_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp.status_code == status.HTTP_201_CREATED
|
||||
data = resp.json()["data"]
|
||||
|
||||
# Verify credentials are returned masked
|
||||
masked_creds = data["attributes"].get("credentials")
|
||||
assert masked_creds is not None
|
||||
assert "access_key_id" in masked_creds
|
||||
assert "secret_access_key" in masked_creds
|
||||
assert "region" in masked_creds
|
||||
# Verify all characters are masked with asterisks
|
||||
assert all(c == "*" for c in masked_creds["access_key_id"])
|
||||
assert all(c == "*" for c in masked_creds["secret_access_key"])
|
||||
|
||||
def test_bedrock_provider_duplicate_per_tenant(self, authenticated_client):
|
||||
"""Creating a second Bedrock provider for same tenant should fail"""
|
||||
valid_credentials = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-west-2",
|
||||
}
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": valid_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
# First creation succeeds
|
||||
resp1 = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp1.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Second creation should fail with validation error
|
||||
resp2 = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp2.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "already exists" in str(resp2.json()).lower()
|
||||
|
||||
def test_bedrock_patch_credentials_and_fields_filter(self, authenticated_client):
|
||||
"""PATCH credentials and verify fields filter returns decrypted values"""
|
||||
valid_credentials = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "eu-west-1",
|
||||
}
|
||||
create_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": valid_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
create_resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=create_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert create_resp.status_code == status.HTTP_201_CREATED
|
||||
provider_id = create_resp.json()["data"]["id"]
|
||||
|
||||
# Update credentials with new valid ones
|
||||
new_credentials = {
|
||||
"access_key_id": "AKIAZZZZZZZZZZZZZZZZ",
|
||||
"secret_access_key": "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/==",
|
||||
"region": "ap-south-1",
|
||||
}
|
||||
patch_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"id": provider_id,
|
||||
"attributes": {
|
||||
"credentials": new_credentials,
|
||||
"is_active": False,
|
||||
},
|
||||
}
|
||||
}
|
||||
patch_resp = authenticated_client.patch(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
|
||||
data=patch_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert patch_resp.status_code == status.HTTP_200_OK
|
||||
updated = patch_resp.json()["data"]["attributes"]
|
||||
assert updated["is_active"] is False
|
||||
|
||||
# Default GET should return masked credentials
|
||||
get_resp = authenticated_client.get(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
|
||||
)
|
||||
assert get_resp.status_code == status.HTTP_200_OK
|
||||
masked = get_resp.json()["data"]["attributes"]["credentials"]
|
||||
assert all(c == "*" for c in masked["access_key_id"])
|
||||
assert all(c == "*" for c in masked["secret_access_key"])
|
||||
|
||||
# Fields filter should return decrypted credentials
|
||||
get_full = authenticated_client.get(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
|
||||
+ "?fields[lighthouse-providers]=credentials"
|
||||
)
|
||||
assert get_full.status_code == status.HTTP_200_OK
|
||||
creds = get_full.json()["data"]["attributes"]["credentials"]
|
||||
assert creds["access_key_id"] == new_credentials["access_key_id"]
|
||||
assert creds["secret_access_key"] == new_credentials["secret_access_key"]
|
||||
assert creds["region"] == new_credentials["region"]
|
||||
|
||||
def test_bedrock_partial_credential_update(self, authenticated_client):
|
||||
"""Test partial update of Bedrock credentials (e.g., only region)"""
|
||||
# Create provider with full credentials
|
||||
initial_credentials = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
}
|
||||
create_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": initial_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
create_resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=create_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert create_resp.status_code == status.HTTP_201_CREATED
|
||||
provider_id = create_resp.json()["data"]["id"]
|
||||
|
||||
# Update only the region field
|
||||
partial_update = {
|
||||
"region": "eu-west-1",
|
||||
}
|
||||
patch_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"id": provider_id,
|
||||
"attributes": {
|
||||
"credentials": partial_update,
|
||||
},
|
||||
}
|
||||
}
|
||||
patch_resp = authenticated_client.patch(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
|
||||
data=patch_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert patch_resp.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify credentials with fields filter - region should be updated, keys preserved
|
||||
get_full = authenticated_client.get(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
|
||||
+ "?fields[lighthouse-providers]=credentials"
|
||||
)
|
||||
assert get_full.status_code == status.HTTP_200_OK
|
||||
creds = get_full.json()["data"]["attributes"]["credentials"]
|
||||
|
||||
# Original keys should be preserved
|
||||
assert creds["access_key_id"] == initial_credentials["access_key_id"]
|
||||
assert creds["secret_access_key"] == initial_credentials["secret_access_key"]
|
||||
# Region should be updated
|
||||
assert creds["region"] == "eu-west-1"
|
||||
|
||||
def test_bedrock_valid_api_key_credentials_success(self, authenticated_client):
|
||||
"""Bedrock provider with valid API key + region should succeed and return masked credentials"""
|
||||
valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110)
|
||||
api_credentials = {
|
||||
"api_key": valid_api_key,
|
||||
"region": "us-east-1",
|
||||
}
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": api_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp.status_code == status.HTTP_201_CREATED
|
||||
data = resp.json()["data"]
|
||||
|
||||
# Verify credentials are returned masked
|
||||
masked_creds = data["attributes"].get("credentials")
|
||||
assert masked_creds is not None
|
||||
assert "api_key" in masked_creds
|
||||
assert "region" in masked_creds
|
||||
assert all(c == "*" for c in masked_creds["api_key"])
|
||||
|
||||
def test_bedrock_mixed_api_key_and_access_keys_invalid_on_create(
|
||||
self, authenticated_client
|
||||
):
|
||||
"""Bedrock provider with both API key and access keys should fail validation on create"""
|
||||
valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110)
|
||||
mixed_credentials = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"api_key": valid_api_key,
|
||||
"region": "us-east-1",
|
||||
}
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": mixed_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
error_body = str(resp.json()).lower()
|
||||
assert "either access key + secret key or api key" in error_body
|
||||
|
||||
def test_bedrock_cannot_switch_from_api_key_to_access_keys_on_update(
|
||||
self, authenticated_client
|
||||
):
|
||||
"""If created with API key, switching to access keys via update should be rejected"""
|
||||
valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110)
|
||||
create_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": {
|
||||
"api_key": valid_api_key,
|
||||
"region": "us-east-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
create_resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=create_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert create_resp.status_code == status.HTTP_201_CREATED
|
||||
provider_id = create_resp.json()["data"]["id"]
|
||||
|
||||
# Attempt to introduce access keys on update
|
||||
patch_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"id": provider_id,
|
||||
"attributes": {
|
||||
"credentials": {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
patch_resp = authenticated_client.patch(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
|
||||
data=patch_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
error_body = str(patch_resp.json()).lower()
|
||||
assert "cannot change bedrock authentication method from api key" in error_body
|
||||
|
||||
def test_bedrock_cannot_switch_from_access_keys_to_api_key_on_update(
|
||||
self, authenticated_client
|
||||
):
|
||||
"""If created with access keys, switching to API key via update should be rejected"""
|
||||
valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110)
|
||||
initial_credentials = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
}
|
||||
create_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": initial_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
create_resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=create_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert create_resp.status_code == status.HTTP_201_CREATED
|
||||
provider_id = create_resp.json()["data"]["id"]
|
||||
|
||||
# Attempt to introduce API key on update
|
||||
patch_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"id": provider_id,
|
||||
"attributes": {
|
||||
"credentials": {
|
||||
"api_key": valid_api_key,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
patch_resp = authenticated_client.patch(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
|
||||
data=patch_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
error_body = str(patch_resp.json()).lower()
|
||||
assert (
|
||||
"cannot change bedrock authentication method from access key" in error_body
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attributes",
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"provider_type": "openai_compatible",
|
||||
"credentials": {"api_key": "compat-key"},
|
||||
},
|
||||
id="missing",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"provider_type": "openai_compatible",
|
||||
"credentials": {"api_key": "compat-key"},
|
||||
"base_url": "",
|
||||
},
|
||||
id="empty",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_openai_compatible_missing_base_url(self, authenticated_client, attributes):
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": attributes,
|
||||
}
|
||||
}
|
||||
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
error_detail = str(resp.json()).lower()
|
||||
assert "base_url" in error_detail
|
||||
|
||||
def test_openai_compatible_invalid_credentials(self, authenticated_client):
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "openai_compatible",
|
||||
"base_url": "https://compat.example/v1",
|
||||
"credentials": {"api_key": ""},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
errors = resp.json().get("errors", [])
|
||||
assert any(
|
||||
error.get("source", {}).get("pointer")
|
||||
== "/data/attributes/credentials/api_key"
|
||||
for error in errors
|
||||
)
|
||||
assert any(
|
||||
"may not be blank" in error.get("detail", "").lower() for error in errors
|
||||
)
|
||||
|
||||
def test_openai_compatible_patch_credentials_and_fields(self, authenticated_client):
|
||||
create_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "openai_compatible",
|
||||
"base_url": "https://compat.example/v1",
|
||||
"credentials": {"api_key": "compat-key-123"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
create_resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=create_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert create_resp.status_code == status.HTTP_201_CREATED
|
||||
provider_id = create_resp.json()["data"]["id"]
|
||||
|
||||
updated_base_url = "https://compat.example/v2"
|
||||
updated_api_key = "compat-key-456"
|
||||
patch_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"id": provider_id,
|
||||
"attributes": {
|
||||
"base_url": updated_base_url,
|
||||
"credentials": {"api_key": updated_api_key},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
patch_resp = authenticated_client.patch(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
|
||||
data=patch_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert patch_resp.status_code == status.HTTP_200_OK
|
||||
updated_attrs = patch_resp.json()["data"]["attributes"]
|
||||
assert updated_attrs["base_url"] == updated_base_url
|
||||
assert updated_attrs["credentials"]["api_key"] == "*" * len(updated_api_key)
|
||||
|
||||
get_resp = authenticated_client.get(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
|
||||
)
|
||||
assert get_resp.status_code == status.HTTP_200_OK
|
||||
masked = get_resp.json()["data"]["attributes"]["credentials"]["api_key"]
|
||||
assert masked == "*" * len(updated_api_key)
|
||||
|
||||
get_full = authenticated_client.get(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
|
||||
+ "?fields[lighthouse-providers]=credentials"
|
||||
)
|
||||
assert get_full.status_code == status.HTTP_200_OK
|
||||
creds = get_full.json()["data"]["attributes"]["credentials"]
|
||||
assert creds["api_key"] == updated_api_key
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMuteRuleViewSet:
|
||||
@@ -11124,380 +11658,3 @@ class TestMuteRuleViewSet:
|
||||
assert len(data) == len(mute_rules_fixture)
|
||||
for rule_data in data:
|
||||
assert rule_data["id"] != str(other_rule.id)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"credentials",
|
||||
[
|
||||
{}, # empty credentials
|
||||
{
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE"
|
||||
}, # missing secret_access_key and region
|
||||
{
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
}, # missing access_key_id and region
|
||||
{
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}, # missing region
|
||||
{ # invalid access_key_id format (not starting with AKIA)
|
||||
"access_key_id": "ABCD0123456789ABCDEF",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
},
|
||||
{ # invalid access_key_id format (wrong length)
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPL",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
},
|
||||
{ # invalid secret_access_key format (wrong length)
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEK",
|
||||
"region": "us-east-1",
|
||||
},
|
||||
{ # invalid region format
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "invalid-region",
|
||||
},
|
||||
{ # invalid region format (uppercase)
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "US-EAST-1",
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_bedrock_invalid_credentials(self, authenticated_client, credentials):
|
||||
"""Bedrock provider with invalid credentials should error"""
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_bedrock_valid_credentials_success(self, authenticated_client):
|
||||
"""Bedrock provider with valid AWS credentials should succeed and mask credentials"""
|
||||
valid_credentials = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
}
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": valid_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp.status_code == status.HTTP_201_CREATED
|
||||
data = resp.json()["data"]
|
||||
|
||||
# Verify credentials are returned masked
|
||||
masked_creds = data["attributes"].get("credentials")
|
||||
assert masked_creds is not None
|
||||
assert "access_key_id" in masked_creds
|
||||
assert "secret_access_key" in masked_creds
|
||||
assert "region" in masked_creds
|
||||
# Verify all characters are masked with asterisks
|
||||
assert all(c == "*" for c in masked_creds["access_key_id"])
|
||||
assert all(c == "*" for c in masked_creds["secret_access_key"])
|
||||
|
||||
def test_bedrock_provider_duplicate_per_tenant(self, authenticated_client):
|
||||
"""Creating a second Bedrock provider for same tenant should fail"""
|
||||
valid_credentials = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-west-2",
|
||||
}
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": valid_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
# First creation succeeds
|
||||
resp1 = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp1.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Second creation should fail with validation error
|
||||
resp2 = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp2.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "already exists" in str(resp2.json()).lower()
|
||||
|
||||
def test_bedrock_patch_credentials_and_fields_filter(self, authenticated_client):
|
||||
"""PATCH credentials and verify fields filter returns decrypted values"""
|
||||
valid_credentials = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "eu-west-1",
|
||||
}
|
||||
create_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": valid_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
create_resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=create_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert create_resp.status_code == status.HTTP_201_CREATED
|
||||
provider_id = create_resp.json()["data"]["id"]
|
||||
|
||||
# Update credentials with new valid ones
|
||||
new_credentials = {
|
||||
"access_key_id": "AKIAZZZZZZZZZZZZZZZZ",
|
||||
"secret_access_key": "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/==",
|
||||
"region": "ap-south-1",
|
||||
}
|
||||
patch_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"id": provider_id,
|
||||
"attributes": {
|
||||
"credentials": new_credentials,
|
||||
"is_active": False,
|
||||
},
|
||||
}
|
||||
}
|
||||
patch_resp = authenticated_client.patch(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
|
||||
data=patch_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert patch_resp.status_code == status.HTTP_200_OK
|
||||
updated = patch_resp.json()["data"]["attributes"]
|
||||
assert updated["is_active"] is False
|
||||
|
||||
# Default GET should return masked credentials
|
||||
get_resp = authenticated_client.get(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
|
||||
)
|
||||
assert get_resp.status_code == status.HTTP_200_OK
|
||||
masked = get_resp.json()["data"]["attributes"]["credentials"]
|
||||
assert all(c == "*" for c in masked["access_key_id"])
|
||||
assert all(c == "*" for c in masked["secret_access_key"])
|
||||
|
||||
# Fields filter should return decrypted credentials
|
||||
get_full = authenticated_client.get(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
|
||||
+ "?fields[lighthouse-providers]=credentials"
|
||||
)
|
||||
assert get_full.status_code == status.HTTP_200_OK
|
||||
creds = get_full.json()["data"]["attributes"]["credentials"]
|
||||
assert creds["access_key_id"] == new_credentials["access_key_id"]
|
||||
assert creds["secret_access_key"] == new_credentials["secret_access_key"]
|
||||
assert creds["region"] == new_credentials["region"]
|
||||
|
||||
def test_bedrock_partial_credential_update(self, authenticated_client):
|
||||
"""Test partial update of Bedrock credentials (e.g., only region)"""
|
||||
# Create provider with full credentials
|
||||
initial_credentials = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
}
|
||||
create_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "bedrock",
|
||||
"credentials": initial_credentials,
|
||||
},
|
||||
}
|
||||
}
|
||||
create_resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=create_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert create_resp.status_code == status.HTTP_201_CREATED
|
||||
provider_id = create_resp.json()["data"]["id"]
|
||||
|
||||
# Update only the region field
|
||||
partial_update = {
|
||||
"region": "eu-west-1",
|
||||
}
|
||||
patch_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"id": provider_id,
|
||||
"attributes": {
|
||||
"credentials": partial_update,
|
||||
},
|
||||
}
|
||||
}
|
||||
patch_resp = authenticated_client.patch(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
|
||||
data=patch_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert patch_resp.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify credentials with fields filter - region should be updated, keys preserved
|
||||
get_full = authenticated_client.get(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
|
||||
+ "?fields[lighthouse-providers]=credentials"
|
||||
)
|
||||
assert get_full.status_code == status.HTTP_200_OK
|
||||
creds = get_full.json()["data"]["attributes"]["credentials"]
|
||||
|
||||
# Original keys should be preserved
|
||||
assert creds["access_key_id"] == initial_credentials["access_key_id"]
|
||||
assert creds["secret_access_key"] == initial_credentials["secret_access_key"]
|
||||
# Region should be updated
|
||||
assert creds["region"] == "eu-west-1"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attributes",
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"provider_type": "openai_compatible",
|
||||
"credentials": {"api_key": "compat-key"},
|
||||
},
|
||||
id="missing",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"provider_type": "openai_compatible",
|
||||
"credentials": {"api_key": "compat-key"},
|
||||
"base_url": "",
|
||||
},
|
||||
id="empty",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_openai_compatible_missing_base_url(self, authenticated_client, attributes):
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": attributes,
|
||||
}
|
||||
}
|
||||
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
error_detail = str(resp.json()).lower()
|
||||
assert "base_url" in error_detail
|
||||
|
||||
def test_openai_compatible_invalid_credentials(self, authenticated_client):
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "openai_compatible",
|
||||
"base_url": "https://compat.example/v1",
|
||||
"credentials": {"api_key": ""},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
errors = resp.json().get("errors", [])
|
||||
assert any(
|
||||
error.get("source", {}).get("pointer")
|
||||
== "/data/attributes/credentials/api_key"
|
||||
for error in errors
|
||||
)
|
||||
assert any(
|
||||
"may not be blank" in error.get("detail", "").lower() for error in errors
|
||||
)
|
||||
|
||||
def test_openai_compatible_patch_credentials_and_fields(self, authenticated_client):
|
||||
create_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "openai_compatible",
|
||||
"base_url": "https://compat.example/v1",
|
||||
"credentials": {"api_key": "compat-key-123"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
create_resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=create_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert create_resp.status_code == status.HTTP_201_CREATED
|
||||
provider_id = create_resp.json()["data"]["id"]
|
||||
|
||||
updated_base_url = "https://compat.example/v2"
|
||||
updated_api_key = "compat-key-456"
|
||||
patch_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"id": provider_id,
|
||||
"attributes": {
|
||||
"base_url": updated_base_url,
|
||||
"credentials": {"api_key": updated_api_key},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
patch_resp = authenticated_client.patch(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
|
||||
data=patch_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert patch_resp.status_code == status.HTTP_200_OK
|
||||
updated_attrs = patch_resp.json()["data"]["attributes"]
|
||||
assert updated_attrs["base_url"] == updated_base_url
|
||||
assert updated_attrs["credentials"]["api_key"] == "*" * len(updated_api_key)
|
||||
|
||||
get_resp = authenticated_client.get(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
|
||||
)
|
||||
assert get_resp.status_code == status.HTTP_200_OK
|
||||
masked = get_resp.json()["data"]["attributes"]["credentials"]["api_key"]
|
||||
assert masked == "*" * len(updated_api_key)
|
||||
|
||||
get_full = authenticated_client.get(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
|
||||
+ "?fields[lighthouse-providers]=credentials"
|
||||
)
|
||||
assert get_full.status_code == status.HTTP_200_OK
|
||||
creds = get_full.json()["data"]["attributes"]["credentials"]
|
||||
assert creds["api_key"] == updated_api_key
|
||||
|
||||
@@ -40,11 +40,16 @@ class BedrockCredentialsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for AWS Bedrock credentials validation.
|
||||
|
||||
Validates long-term AWS credentials (AKIA) and region format.
|
||||
Supports two authentication methods:
|
||||
1. AWS access key + secret key
|
||||
2. Bedrock API key (bearer token)
|
||||
|
||||
In both cases, region is mandatory.
|
||||
"""
|
||||
|
||||
access_key_id = serializers.CharField()
|
||||
secret_access_key = serializers.CharField()
|
||||
access_key_id = serializers.CharField(required=False, allow_blank=False)
|
||||
secret_access_key = serializers.CharField(required=False, allow_blank=False)
|
||||
api_key = serializers.CharField(required=False, allow_blank=False)
|
||||
region = serializers.CharField()
|
||||
|
||||
def validate_access_key_id(self, value: str) -> str:
|
||||
@@ -65,6 +70,15 @@ class BedrockCredentialsSerializer(serializers.Serializer):
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_api_key(self, value: str) -> str:
|
||||
"""
|
||||
Validate Bedrock API key (bearer token).
|
||||
"""
|
||||
pattern = r"^ABSKQmVkcm9ja0FQSUtleS[A-Za-z0-9+/=]{110}$"
|
||||
if not re.match(pattern, value or ""):
|
||||
raise serializers.ValidationError("Invalid Bedrock API key format.")
|
||||
return value
|
||||
|
||||
def validate_region(self, value: str) -> str:
|
||||
"""Validate AWS region format."""
|
||||
pattern = r"^[a-z]{2}-[a-z]+-\d+$"
|
||||
@@ -74,6 +88,50 @@ class BedrockCredentialsSerializer(serializers.Serializer):
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Enforce either:
|
||||
- access_key_id + secret_access_key + region
|
||||
OR
|
||||
- api_key + region
|
||||
"""
|
||||
access_key_id = attrs.get("access_key_id")
|
||||
secret_access_key = attrs.get("secret_access_key")
|
||||
api_key = attrs.get("api_key")
|
||||
region = attrs.get("region")
|
||||
|
||||
errors = {}
|
||||
|
||||
if not region:
|
||||
errors["region"] = ["Region is required."]
|
||||
|
||||
using_access_keys = bool(access_key_id or secret_access_key)
|
||||
using_api_key = api_key is not None and api_key != ""
|
||||
|
||||
if using_access_keys and using_api_key:
|
||||
errors["non_field_errors"] = [
|
||||
"Provide either access key + secret key OR api key, not both."
|
||||
]
|
||||
elif not using_access_keys and not using_api_key:
|
||||
errors["non_field_errors"] = [
|
||||
"You must provide either access key + secret key OR api key."
|
||||
]
|
||||
elif using_access_keys:
|
||||
# Both access_key_id and secret_access_key must be present together
|
||||
if not access_key_id:
|
||||
errors.setdefault("access_key_id", []).append(
|
||||
"AWS access key ID is required when using access key authentication."
|
||||
)
|
||||
if not secret_access_key:
|
||||
errors.setdefault("secret_access_key", []).append(
|
||||
"AWS secret access key is required when using access key authentication."
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
|
||||
return attrs
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""Check for unknown fields before DRF filters them out."""
|
||||
if not isinstance(data, dict):
|
||||
@@ -111,6 +169,15 @@ class BedrockCredentialsUpdateSerializer(BedrockCredentialsSerializer):
|
||||
for field in self.fields.values():
|
||||
field.required = False
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
For updates, this serializer only checks individual fields.
|
||||
It does NOT enforce the "either access keys OR api key" rule.
|
||||
That rule is applied later, after merging with existing stored
|
||||
credentials, in LighthouseProviderConfigUpdateSerializer.
|
||||
"""
|
||||
return attrs
|
||||
|
||||
|
||||
class OpenAICompatibleCredentialsSerializer(serializers.Serializer):
|
||||
"""
|
||||
@@ -168,27 +235,51 @@ class OpenAICompatibleCredentialsSerializer(serializers.Serializer):
|
||||
"required": ["api_key"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "AWS Bedrock Credentials",
|
||||
"properties": {
|
||||
"access_key_id": {
|
||||
"type": "string",
|
||||
"description": "AWS access key ID.",
|
||||
"pattern": "^AKIA[0-9A-Z]{16}$",
|
||||
"oneOf": [
|
||||
{
|
||||
"title": "IAM Access Key Pair",
|
||||
"type": "object",
|
||||
"description": "Authenticate with AWS access key and secret key. Recommended when you manage IAM users or roles.",
|
||||
"properties": {
|
||||
"access_key_id": {
|
||||
"type": "string",
|
||||
"description": "AWS access key ID.",
|
||||
"pattern": "^AKIA[0-9A-Z]{16}$",
|
||||
},
|
||||
"secret_access_key": {
|
||||
"type": "string",
|
||||
"description": "AWS secret access key.",
|
||||
"pattern": "^[A-Za-z0-9/+=]{40}$",
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "AWS region identifier where Bedrock is available. Examples: us-east-1, "
|
||||
"us-west-2, eu-west-1, ap-northeast-1.",
|
||||
"pattern": "^[a-z]{2}-[a-z]+-\\d+$",
|
||||
},
|
||||
},
|
||||
"required": ["access_key_id", "secret_access_key", "region"],
|
||||
},
|
||||
"secret_access_key": {
|
||||
"type": "string",
|
||||
"description": "AWS secret access key.",
|
||||
"pattern": "^[A-Za-z0-9/+=]{40}$",
|
||||
{
|
||||
"title": "Amazon Bedrock API Key",
|
||||
"type": "object",
|
||||
"description": "Authenticate with an Amazon Bedrock API key (bearer token). Region is still required.",
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "Amazon Bedrock API key (bearer token).",
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "AWS region identifier where Bedrock is available. Examples: us-east-1, "
|
||||
"us-west-2, eu-west-1, ap-northeast-1.",
|
||||
"pattern": "^[a-z]{2}-[a-z]+-\\d+$",
|
||||
},
|
||||
},
|
||||
"required": ["api_key", "region"],
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "AWS region identifier where Bedrock is available. Examples: us-east-1, "
|
||||
"us-west-2, eu-west-1, ap-northeast-1.",
|
||||
"pattern": "^[a-z]{2}-[a-z]+-\\d+$",
|
||||
},
|
||||
},
|
||||
"required": ["access_key_id", "secret_access_key", "region"],
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
|
||||
@@ -3299,6 +3299,19 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
|
||||
and provider_type
|
||||
== LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK
|
||||
):
|
||||
# For updates, enforce that the authentication method (access keys vs API key)
|
||||
# is immutable. To switch methods, the UI must delete and recreate the provider.
|
||||
existing_credentials = (
|
||||
self.instance.credentials_decoded if self.instance else {}
|
||||
) or {}
|
||||
|
||||
existing_uses_api_key = "api_key" in existing_credentials
|
||||
existing_uses_access_keys = any(
|
||||
k in existing_credentials
|
||||
for k in ("access_key_id", "secret_access_key")
|
||||
)
|
||||
|
||||
# First run field-level validation on the partial payload
|
||||
try:
|
||||
BedrockCredentialsUpdateSerializer(data=credentials).is_valid(
|
||||
raise_exception=True
|
||||
@@ -3309,6 +3322,31 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
|
||||
e.detail[f"credentials/{key}"] = value
|
||||
del e.detail[key]
|
||||
raise e
|
||||
|
||||
# Then enforce invariants about not changing the auth method
|
||||
# If the existing config uses an API key, forbid introducing access keys.
|
||||
if existing_uses_api_key and any(
|
||||
k in credentials for k in ("access_key_id", "secret_access_key")
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"credentials/non_field_errors": [
|
||||
"Cannot change Bedrock authentication method from API key "
|
||||
"to access key via update. Delete and recreate the provider instead."
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# If the existing config uses access keys, forbid introducing an API key.
|
||||
if existing_uses_access_keys and "api_key" in credentials:
|
||||
raise ValidationError(
|
||||
{
|
||||
"credentials/non_field_errors": [
|
||||
"Cannot change Bedrock authentication method from access key "
|
||||
"to API key via update. Delete and recreate the provider instead."
|
||||
]
|
||||
}
|
||||
)
|
||||
elif (
|
||||
credentials is not None
|
||||
and provider_type
|
||||
|
||||
@@ -2,6 +2,8 @@ from typing import Dict
|
||||
|
||||
import boto3
|
||||
import openai
|
||||
from botocore import UNSIGNED
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
@@ -56,21 +58,39 @@ def _extract_bedrock_credentials(
|
||||
"""
|
||||
Safely extract AWS Bedrock credentials from a provider configuration.
|
||||
|
||||
Supports two authentication methods:
|
||||
1. AWS access key + secret key + region
|
||||
2. Bedrock API key (bearer token) + region
|
||||
|
||||
Args:
|
||||
provider_cfg (LighthouseProviderConfiguration): The provider configuration instance
|
||||
containing the credentials.
|
||||
|
||||
Returns:
|
||||
Dict[str, str] | None: Dictionary with 'access_key_id', 'secret_access_key', and
|
||||
'region' if present and valid, otherwise None.
|
||||
Dict[str, str] | None: Dictionary with either:
|
||||
- 'access_key_id', 'secret_access_key', and 'region' for access key auth
|
||||
- 'api_key' and 'region' for API key (bearer token) auth
|
||||
Returns None if credentials are invalid or missing.
|
||||
"""
|
||||
creds = provider_cfg.credentials_decoded
|
||||
if not isinstance(creds, dict):
|
||||
return None
|
||||
|
||||
region = creds.get("region")
|
||||
if not isinstance(region, str) or not region:
|
||||
return None
|
||||
|
||||
# Check for API key authentication first
|
||||
api_key = creds.get("api_key")
|
||||
if isinstance(api_key, str) and api_key:
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"region": region,
|
||||
}
|
||||
|
||||
# Fall back to access key authentication
|
||||
access_key_id = creds.get("access_key_id")
|
||||
secret_access_key = creds.get("secret_access_key")
|
||||
region = creds.get("region")
|
||||
|
||||
# Validate all required fields are present and are strings
|
||||
if (
|
||||
@@ -78,8 +98,6 @@ def _extract_bedrock_credentials(
|
||||
or not access_key_id
|
||||
or not isinstance(secret_access_key, str)
|
||||
or not secret_access_key
|
||||
or not isinstance(region, str)
|
||||
or not region
|
||||
):
|
||||
return None
|
||||
|
||||
@@ -90,6 +108,51 @@ def _extract_bedrock_credentials(
|
||||
}
|
||||
|
||||
|
||||
def _create_bedrock_client(
|
||||
bedrock_creds: Dict[str, str], service_name: str = "bedrock"
|
||||
):
|
||||
"""
|
||||
Create a boto3 Bedrock client with the appropriate authentication method.
|
||||
|
||||
Supports two authentication methods:
|
||||
1. API key (bearer token) - uses unsigned requests with Authorization header
|
||||
2. AWS access key + secret key - uses standard SigV4 signing
|
||||
|
||||
Args:
|
||||
bedrock_creds: Dictionary with either:
|
||||
- 'api_key' and 'region' for API key (bearer token) auth
|
||||
- 'access_key_id', 'secret_access_key', and 'region' for access key auth
|
||||
service_name: The Bedrock service name. Use 'bedrock' for control plane
|
||||
operations (list_foundation_models, etc.) or 'bedrock-runtime' for
|
||||
inference operations.
|
||||
|
||||
Returns:
|
||||
boto3 client configured for the specified Bedrock service.
|
||||
"""
|
||||
region = bedrock_creds["region"]
|
||||
|
||||
if "api_key" in bedrock_creds:
|
||||
bearer_token = bedrock_creds["api_key"]
|
||||
client = boto3.client(
|
||||
service_name=service_name,
|
||||
region_name=region,
|
||||
config=Config(signature_version=UNSIGNED),
|
||||
)
|
||||
|
||||
def inject_bearer_token(request, **kwargs):
|
||||
request.headers["Authorization"] = f"Bearer {bearer_token}"
|
||||
|
||||
client.meta.events.register("before-send.*.*", inject_bearer_token)
|
||||
return client
|
||||
|
||||
return boto3.client(
|
||||
service_name=service_name,
|
||||
region_name=region,
|
||||
aws_access_key_id=bedrock_creds["access_key_id"],
|
||||
aws_secret_access_key=bedrock_creds["secret_access_key"],
|
||||
)
|
||||
|
||||
|
||||
def check_lighthouse_provider_connection(provider_config_id: str) -> Dict:
|
||||
"""
|
||||
Validate a Lighthouse provider configuration by calling the provider API and
|
||||
@@ -141,12 +204,7 @@ def check_lighthouse_provider_connection(provider_config_id: str) -> Dict:
|
||||
}
|
||||
|
||||
# Test connection by listing foundation models
|
||||
bedrock_client = boto3.client(
|
||||
"bedrock",
|
||||
aws_access_key_id=bedrock_creds["access_key_id"],
|
||||
aws_secret_access_key=bedrock_creds["secret_access_key"],
|
||||
region_name=bedrock_creds["region"],
|
||||
)
|
||||
bedrock_client = _create_bedrock_client(bedrock_creds)
|
||||
_ = bedrock_client.list_foundation_models()
|
||||
|
||||
elif (
|
||||
@@ -232,105 +290,219 @@ def _fetch_openai_compatible_models(base_url: str, api_key: str) -> Dict[str, st
|
||||
return available_models
|
||||
|
||||
|
||||
def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]:
|
||||
def _get_region_prefix(region: str) -> str:
|
||||
"""
|
||||
Fetch available models from AWS Bedrock with entitlement verification.
|
||||
Determine geographic prefix for AWS region.
|
||||
|
||||
This function:
|
||||
1. Lists foundation models with TEXT modality support
|
||||
2. Lists inference profiles with TEXT modality support
|
||||
3. Verifies user has entitlement access to each model
|
||||
Examples: ap-south-1 -> apac, us-east-1 -> us, eu-west-1 -> eu
|
||||
"""
|
||||
if region.startswith(("us-", "ca-", "sa-")):
|
||||
return "us"
|
||||
elif region.startswith("eu-"):
|
||||
return "eu"
|
||||
elif region.startswith("ap-"):
|
||||
return "apac"
|
||||
return "global"
|
||||
|
||||
Args:
|
||||
bedrock_creds: Dictionary with 'access_key_id', 'secret_access_key', and 'region'.
|
||||
|
||||
def _clean_inference_profile_name(profile_name: str) -> str:
|
||||
"""
|
||||
Remove geographic prefix from inference profile name.
|
||||
|
||||
AWS includes geographic prefixes in profile names which are redundant
|
||||
since the profile ID already contains this information.
|
||||
|
||||
Examples:
|
||||
"APAC Anthropic Claude 3.5 Sonnet" -> "Anthropic Claude 3.5 Sonnet"
|
||||
"GLOBAL Claude Sonnet 4.5" -> "Claude Sonnet 4.5"
|
||||
"US Anthropic Claude 3 Haiku" -> "Anthropic Claude 3 Haiku"
|
||||
"""
|
||||
prefixes = ["APAC ", "GLOBAL ", "US ", "EU ", "APAC-", "GLOBAL-", "US-", "EU-"]
|
||||
|
||||
for prefix in prefixes:
|
||||
if profile_name.upper().startswith(prefix.upper()):
|
||||
return profile_name[len(prefix) :].strip()
|
||||
|
||||
return profile_name
|
||||
|
||||
|
||||
def _supports_text_modality(input_modalities: list, output_modalities: list) -> bool:
|
||||
"""Check if model supports TEXT for both input and output."""
|
||||
return "TEXT" in input_modalities and "TEXT" in output_modalities
|
||||
|
||||
|
||||
def _get_foundation_model_modalities(
|
||||
bedrock_client, model_id: str
|
||||
) -> tuple[list, list] | None:
|
||||
"""
|
||||
Fetch input and output modalities for a foundation model.
|
||||
|
||||
Returns:
|
||||
Dict mapping model_id to model_name for all accessible models.
|
||||
|
||||
Raises:
|
||||
BotoCoreError, ClientError: If AWS API calls fail.
|
||||
(input_modalities, output_modalities) or None if fetch fails
|
||||
"""
|
||||
bedrock_client = boto3.client(
|
||||
"bedrock",
|
||||
aws_access_key_id=bedrock_creds["access_key_id"],
|
||||
aws_secret_access_key=bedrock_creds["secret_access_key"],
|
||||
region_name=bedrock_creds["region"],
|
||||
)
|
||||
try:
|
||||
model_info = bedrock_client.get_foundation_model(modelIdentifier=model_id)
|
||||
model_details = model_info.get("modelDetails", {})
|
||||
input_mods = model_details.get("inputModalities", [])
|
||||
output_mods = model_details.get("outputModalities", [])
|
||||
return (input_mods, output_mods)
|
||||
except (BotoCoreError, ClientError) as e:
|
||||
logger.debug("Could not fetch model details for %s: %s", model_id, str(e))
|
||||
return None
|
||||
|
||||
models_to_check: Dict[str, str] = {}
|
||||
|
||||
# Step 1: Get foundation models with TEXT modality
|
||||
def _extract_foundation_model_ids(profile_models: list) -> list[str]:
|
||||
"""
|
||||
Extract foundation model IDs from inference profile model ARNs.
|
||||
|
||||
Args:
|
||||
profile_models: List of model references from inference profile
|
||||
|
||||
Returns:
|
||||
List of foundation model IDs extracted from ARNs
|
||||
"""
|
||||
model_ids = []
|
||||
for model_ref in profile_models:
|
||||
model_arn = model_ref.get("modelArn", "")
|
||||
if "foundation-model/" in model_arn:
|
||||
model_id = model_arn.split("foundation-model/")[1]
|
||||
model_ids.append(model_id)
|
||||
return model_ids
|
||||
|
||||
|
||||
def _build_inference_profile_map(
|
||||
bedrock_client, region: str
|
||||
) -> Dict[str, tuple[str, str]]:
|
||||
"""
|
||||
Build map of foundation_model_id -> best inference profile.
|
||||
|
||||
Returns:
|
||||
Dict mapping foundation_model_id to (profile_id, profile_name)
|
||||
Only includes profiles with TEXT modality support
|
||||
Prefers region-matched profiles over others
|
||||
"""
|
||||
region_prefix = _get_region_prefix(region)
|
||||
model_to_profile: Dict[str, tuple[str, str]] = {}
|
||||
|
||||
try:
|
||||
response = bedrock_client.list_inference_profiles()
|
||||
profiles = response.get("inferenceProfileSummaries", [])
|
||||
|
||||
for profile in profiles:
|
||||
profile_id = profile.get("inferenceProfileId")
|
||||
profile_name = profile.get("inferenceProfileName")
|
||||
|
||||
if not profile_id or not profile_name:
|
||||
continue
|
||||
|
||||
profile_models = profile.get("models", [])
|
||||
if not profile_models:
|
||||
continue
|
||||
|
||||
foundation_model_ids = _extract_foundation_model_ids(profile_models)
|
||||
if not foundation_model_ids:
|
||||
continue
|
||||
|
||||
modalities = _get_foundation_model_modalities(
|
||||
bedrock_client, foundation_model_ids[0]
|
||||
)
|
||||
if not modalities:
|
||||
continue
|
||||
|
||||
input_mods, output_mods = modalities
|
||||
if not _supports_text_modality(input_mods, output_mods):
|
||||
continue
|
||||
|
||||
is_preferred = profile_id.startswith(f"{region_prefix}.")
|
||||
clean_name = _clean_inference_profile_name(profile_name)
|
||||
|
||||
for foundation_model_id in foundation_model_ids:
|
||||
if foundation_model_id not in model_to_profile:
|
||||
model_to_profile[foundation_model_id] = (profile_id, clean_name)
|
||||
elif is_preferred and not model_to_profile[foundation_model_id][
|
||||
0
|
||||
].startswith(f"{region_prefix}."):
|
||||
model_to_profile[foundation_model_id] = (profile_id, clean_name)
|
||||
|
||||
except (BotoCoreError, ClientError) as e:
|
||||
logger.info("Could not fetch inference profiles in %s: %s", region, str(e))
|
||||
|
||||
return model_to_profile
|
||||
|
||||
|
||||
def _check_on_demand_availability(bedrock_client, model_id: str) -> bool:
|
||||
"""Check if an ON_DEMAND foundation model is entitled and available."""
|
||||
try:
|
||||
availability = bedrock_client.get_foundation_model_availability(
|
||||
modelId=model_id
|
||||
)
|
||||
entitlement = availability.get("entitlementAvailability")
|
||||
return entitlement == "AVAILABLE"
|
||||
except (BotoCoreError, ClientError) as e:
|
||||
logger.debug("Could not check availability for %s: %s", model_id, str(e))
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]:
|
||||
"""
|
||||
Fetch available models from AWS Bedrock, preferring inference profiles over ON_DEMAND.
|
||||
|
||||
Strategy:
|
||||
1. Build map of foundation_model -> best_inference_profile (with TEXT validation)
|
||||
2. For each TEXT-capable foundation model:
|
||||
- Use inference profile ID if available (preferred - better throughput)
|
||||
- Fallback to foundation model ID if only ON_DEMAND available
|
||||
3. Verify entitlement for ON_DEMAND models
|
||||
|
||||
Args:
|
||||
bedrock_creds: Dict with 'region' and auth credentials
|
||||
|
||||
Returns:
|
||||
Dict mapping model_id to model_name. IDs can be:
|
||||
- Inference profile IDs (e.g., "apac.anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
- Foundation model IDs (e.g., "anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
"""
|
||||
bedrock_client = _create_bedrock_client(bedrock_creds)
|
||||
region = bedrock_creds["region"]
|
||||
|
||||
model_to_profile = _build_inference_profile_map(bedrock_client, region)
|
||||
|
||||
foundation_response = bedrock_client.list_foundation_models()
|
||||
model_summaries = foundation_response.get("modelSummaries", [])
|
||||
|
||||
for model in model_summaries:
|
||||
# Check if model supports TEXT input and output modality
|
||||
input_modalities = model.get("inputModalities", [])
|
||||
output_modalities = model.get("outputModalities", [])
|
||||
models_to_return: Dict[str, str] = {}
|
||||
on_demand_models: set[str] = set()
|
||||
|
||||
if "TEXT" not in input_modalities or "TEXT" not in output_modalities:
|
||||
for model in model_summaries:
|
||||
input_mods = model.get("inputModalities", [])
|
||||
output_mods = model.get("outputModalities", [])
|
||||
|
||||
if not _supports_text_modality(input_mods, output_mods):
|
||||
continue
|
||||
|
||||
model_id = model.get("modelId")
|
||||
if not model_id:
|
||||
model_name = model.get("modelName")
|
||||
|
||||
if not model_id or not model_name:
|
||||
continue
|
||||
|
||||
inference_types = model.get("inferenceTypesSupported", [])
|
||||
if model_id in model_to_profile:
|
||||
profile_id, profile_name = model_to_profile[model_id]
|
||||
models_to_return[profile_id] = profile_name
|
||||
else:
|
||||
inference_types = model.get("inferenceTypesSupported", [])
|
||||
if "ON_DEMAND" in inference_types:
|
||||
models_to_return[model_id] = model_name
|
||||
on_demand_models.add(model_id)
|
||||
|
||||
# Only include models with ON_DEMAND inference support
|
||||
if "ON_DEMAND" in inference_types:
|
||||
models_to_check[model_id] = model["modelName"]
|
||||
|
||||
# Step 2: Get inference profiles
|
||||
try:
|
||||
inference_profiles_response = bedrock_client.list_inference_profiles()
|
||||
inference_profiles = inference_profiles_response.get(
|
||||
"inferenceProfileSummaries", []
|
||||
)
|
||||
|
||||
for profile in inference_profiles:
|
||||
# Check if profile supports TEXT modality
|
||||
input_modalities = profile.get("inputModalities", [])
|
||||
output_modalities = profile.get("outputModalities", [])
|
||||
|
||||
if "TEXT" not in input_modalities or "TEXT" not in output_modalities:
|
||||
continue
|
||||
|
||||
profile_id = profile.get("inferenceProfileId")
|
||||
if profile_id:
|
||||
models_to_check[profile_id] = profile["inferenceProfileName"]
|
||||
|
||||
except (BotoCoreError, ClientError) as e:
|
||||
logger.info(
|
||||
"Could not fetch inference profiles in %s: %s",
|
||||
bedrock_creds["region"],
|
||||
str(e),
|
||||
)
|
||||
|
||||
# Step 3: Verify entitlement availability for each model
|
||||
available_models: Dict[str, str] = {}
|
||||
|
||||
for model_id, model_name in models_to_check.items():
|
||||
try:
|
||||
availability = bedrock_client.get_foundation_model_availability(
|
||||
modelId=model_id
|
||||
)
|
||||
|
||||
entitlement = availability.get("entitlementAvailability")
|
||||
|
||||
# Only include models user has access to
|
||||
if entitlement == "AVAILABLE":
|
||||
for model_id, model_name in models_to_return.items():
|
||||
if model_id in on_demand_models:
|
||||
if _check_on_demand_availability(bedrock_client, model_id):
|
||||
available_models[model_id] = model_name
|
||||
else:
|
||||
logger.debug(
|
||||
"Skipping model %s - entitlement status: %s", model_id, entitlement
|
||||
)
|
||||
|
||||
except (BotoCoreError, ClientError) as e:
|
||||
logger.debug(
|
||||
"Could not check availability for model %s: %s", model_id, str(e)
|
||||
)
|
||||
continue
|
||||
else:
|
||||
available_models[model_id] = model_name
|
||||
|
||||
return available_models
|
||||
|
||||
@@ -359,7 +531,6 @@ def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict:
|
||||
provider_cfg = LighthouseProviderConfiguration.objects.get(pk=provider_config_id)
|
||||
fetched_models: Dict[str, str] = {}
|
||||
|
||||
# Fetch models from the appropriate provider
|
||||
try:
|
||||
if (
|
||||
provider_cfg.provider_type
|
||||
|
||||
@@ -4,6 +4,10 @@ from unittest.mock import MagicMock, patch
|
||||
import openai
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from tasks.jobs.lighthouse_providers import (
|
||||
_create_bedrock_client,
|
||||
_extract_bedrock_credentials,
|
||||
)
|
||||
from tasks.tasks import (
|
||||
_perform_scan_complete_tasks,
|
||||
check_integrations_task,
|
||||
@@ -21,6 +25,198 @@ from api.models import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestExtractBedrockCredentials:
|
||||
"""Unit tests for _extract_bedrock_credentials helper function."""
|
||||
|
||||
def test_extract_access_key_credentials(self, tenants_fixture):
|
||||
"""Test extraction of access key + secret key credentials."""
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
|
||||
is_active=True,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
}
|
||||
provider_cfg.save()
|
||||
|
||||
result = _extract_bedrock_credentials(provider_cfg)
|
||||
|
||||
assert result is not None
|
||||
assert result["access_key_id"] == "AKIAIOSFODNN7EXAMPLE"
|
||||
assert result["secret_access_key"] == "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
assert result["region"] == "us-east-1"
|
||||
assert "api_key" not in result
|
||||
|
||||
def test_extract_api_key_credentials(self, tenants_fixture):
|
||||
"""Test extraction of API key (bearer token) credentials."""
|
||||
valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110)
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
|
||||
is_active=True,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {
|
||||
"api_key": valid_api_key,
|
||||
"region": "us-west-2",
|
||||
}
|
||||
provider_cfg.save()
|
||||
|
||||
result = _extract_bedrock_credentials(provider_cfg)
|
||||
|
||||
assert result is not None
|
||||
assert result["api_key"] == valid_api_key
|
||||
assert result["region"] == "us-west-2"
|
||||
assert "access_key_id" not in result
|
||||
assert "secret_access_key" not in result
|
||||
|
||||
def test_api_key_takes_precedence_over_access_keys(self, tenants_fixture):
|
||||
"""Test that API key is preferred when both auth methods are present."""
|
||||
valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("B" * 110)
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
|
||||
is_active=True,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {
|
||||
"api_key": valid_api_key,
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "eu-west-1",
|
||||
}
|
||||
provider_cfg.save()
|
||||
|
||||
result = _extract_bedrock_credentials(provider_cfg)
|
||||
|
||||
assert result is not None
|
||||
assert result["api_key"] == valid_api_key
|
||||
assert result["region"] == "eu-west-1"
|
||||
assert "access_key_id" not in result
|
||||
|
||||
def test_missing_region_returns_none(self, tenants_fixture):
|
||||
"""Test that missing region returns None."""
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
|
||||
is_active=True,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {
|
||||
"api_key": "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110),
|
||||
}
|
||||
provider_cfg.save()
|
||||
|
||||
result = _extract_bedrock_credentials(provider_cfg)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_empty_credentials_returns_none(self, tenants_fixture):
|
||||
"""Test that empty credentials dict returns None (region only is not enough)."""
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
|
||||
is_active=True,
|
||||
)
|
||||
# Only region, no auth credentials - should return None
|
||||
provider_cfg.credentials_decoded = {
|
||||
"region": "us-east-1",
|
||||
}
|
||||
provider_cfg.save()
|
||||
|
||||
result = _extract_bedrock_credentials(provider_cfg)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_non_dict_credentials_returns_none(self, tenants_fixture):
|
||||
"""Test that non-dict credentials returns None."""
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
|
||||
is_active=True,
|
||||
)
|
||||
# Store valid credentials first to pass model validation
|
||||
provider_cfg.credentials_decoded = {
|
||||
"api_key": "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110),
|
||||
"region": "us-east-1",
|
||||
}
|
||||
provider_cfg.save()
|
||||
|
||||
# Mock the credentials_decoded property to return a non-dict value
|
||||
# This simulates corrupted/invalid stored data
|
||||
with patch.object(
|
||||
type(provider_cfg),
|
||||
"credentials_decoded",
|
||||
new_callable=lambda: property(lambda self: "invalid"),
|
||||
):
|
||||
result = _extract_bedrock_credentials(provider_cfg)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCreateBedrockClient:
|
||||
"""Unit tests for _create_bedrock_client helper function."""
|
||||
|
||||
@patch("tasks.jobs.lighthouse_providers.boto3.client")
|
||||
def test_create_client_with_access_keys(self, mock_boto_client):
|
||||
"""Test creating client with access key authentication."""
|
||||
mock_client = MagicMock()
|
||||
mock_boto_client.return_value = mock_client
|
||||
|
||||
creds = {
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"region": "us-east-1",
|
||||
}
|
||||
|
||||
result = _create_bedrock_client(creds)
|
||||
|
||||
assert result == mock_client
|
||||
mock_boto_client.assert_called_once_with(
|
||||
service_name="bedrock",
|
||||
region_name="us-east-1",
|
||||
aws_access_key_id="AKIAIOSFODNN7EXAMPLE",
|
||||
aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.lighthouse_providers.Config")
|
||||
@patch("tasks.jobs.lighthouse_providers.boto3.client")
|
||||
def test_create_client_with_api_key(self, mock_boto_client, mock_config):
|
||||
"""Test creating client with API key authentication."""
|
||||
mock_client = MagicMock()
|
||||
mock_events = MagicMock()
|
||||
mock_client.meta.events = mock_events
|
||||
mock_boto_client.return_value = mock_client
|
||||
mock_config_instance = MagicMock()
|
||||
mock_config.return_value = mock_config_instance
|
||||
valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110)
|
||||
|
||||
creds = {
|
||||
"api_key": valid_api_key,
|
||||
"region": "us-west-2",
|
||||
}
|
||||
|
||||
result = _create_bedrock_client(creds)
|
||||
|
||||
assert result == mock_client
|
||||
mock_boto_client.assert_called_once_with(
|
||||
service_name="bedrock",
|
||||
region_name="us-west-2",
|
||||
config=mock_config_instance,
|
||||
)
|
||||
mock_events.register.assert_called_once()
|
||||
call_args = mock_events.register.call_args
|
||||
assert call_args[0][0] == "before-send.*.*"
|
||||
|
||||
# Verify handler injects bearer token
|
||||
handler_fn = call_args[0][1]
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {}
|
||||
handler_fn(mock_request)
|
||||
assert mock_request.headers["Authorization"] == f"Bearer {valid_api_key}"
|
||||
|
||||
|
||||
# TODO Move this to outputs/reports jobs
|
||||
@pytest.mark.django_db
|
||||
class TestGenerateOutputs:
|
||||
@@ -1152,6 +1348,16 @@ class TestCheckLighthouseProviderConnectionTask:
|
||||
None,
|
||||
{"connected": True, "error": None},
|
||||
),
|
||||
# Bedrock API key authentication
|
||||
(
|
||||
LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
|
||||
{
|
||||
"api_key": "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110),
|
||||
"region": "us-east-1",
|
||||
},
|
||||
None,
|
||||
{"connected": True, "error": None},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_check_connection_success_all_providers(
|
||||
@@ -1220,6 +1426,24 @@ class TestCheckLighthouseProviderConnectionTask:
|
||||
"list_foundation_models",
|
||||
),
|
||||
),
|
||||
# Bedrock API key authentication failure
|
||||
(
|
||||
LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
|
||||
{
|
||||
"api_key": "ABSKQmVkcm9ja0FQSUtleS" + ("X" * 110),
|
||||
"region": "us-east-1",
|
||||
},
|
||||
None,
|
||||
ClientError(
|
||||
{
|
||||
"Error": {
|
||||
"Code": "UnrecognizedClientException",
|
||||
"Message": "Invalid API key",
|
||||
}
|
||||
},
|
||||
"list_foundation_models",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_check_connection_api_failure(
|
||||
@@ -1344,6 +1568,17 @@ class TestRefreshLighthouseProviderModelsTask:
|
||||
{"openai.gpt-oss-120b-1:0": "gpt-oss-120b"},
|
||||
1,
|
||||
),
|
||||
# Bedrock API key authentication
|
||||
(
|
||||
LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK,
|
||||
{
|
||||
"api_key": "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110),
|
||||
"region": "us-east-1",
|
||||
},
|
||||
None,
|
||||
{"anthropic.claude-v3": "Claude 3"},
|
||||
1,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_refresh_models_create_new(
|
||||
|
||||
@@ -62,17 +62,42 @@ To connect a provider:
|
||||
</Tab>
|
||||
|
||||
<Tab title="Amazon Bedrock">
|
||||
### Required Information
|
||||
Prowler connects to Amazon Bedrock using either [Amazon Bedrock API keys](https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started-api-keys.html) or IAM credentials.
|
||||
|
||||
To connect Amazon Bedrock, you need:
|
||||
<Note>
|
||||
Amazon Bedrock models depend on AWS region and account entitlements. Lighthouse AI displays only accessible models that support tool calling and text input/output.
|
||||
</Note>
|
||||
|
||||
### Amazon Bedrock API Key
|
||||
|
||||
<Warning>
|
||||
Recommended only for exploration of Amazon Bedrock with Lighthouse.
|
||||
</Warning>
|
||||
|
||||
Amazon Bedrock API keys provide simpler authentication with automatically assigned permissions.
|
||||
|
||||
#### Required Information
|
||||
|
||||
- **Bedrock API Key**: Amazon Bedrock API key
|
||||
- **AWS Region**: Region where Bedrock is available
|
||||
|
||||
<Note>
|
||||
Amazon Bedrock API keys are automatically assigned the necessary permissions (`AmazonBedrockLimitedAccess` policy).
|
||||
|
||||
Learn more: [Getting Started with Amazon Bedrock API Keys](https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started-api-keys.html)
|
||||
</Note>
|
||||
|
||||
### AWS IAM Access Keys
|
||||
|
||||
Standard AWS IAM credentials can be used as an alternative authentication method.
|
||||
|
||||
#### Required Information
|
||||
|
||||
- **AWS Access Key ID**: AWS access key ID
|
||||
- **AWS Secret Access Key**: AWS secret access key
|
||||
- **AWS Region**: Region where Bedrock is available (e.g., `us-east-1`, `us-west-2`)
|
||||
- **AWS Region**: Region where Bedrock is available
|
||||
|
||||
Available models are region dependent. See [Amazon Bedrock models by region](https://docs.aws.amazon.com/bedrock/latest/userguide/models-regions.html) for details.
|
||||
|
||||
### Required Permissions
|
||||
#### Required Permissions
|
||||
|
||||
The AWS IAM user must have the `AmazonBedrockLimitedAccess` managed policy attached:
|
||||
|
||||
@@ -84,12 +109,6 @@ To connect a provider:
|
||||
Access to all Amazon Bedrock foundation models is enabled by default. When you select a model or invoke it for the first time (using Prowler or otherwise), you agree to Amazon's EULA. More info: [Amazon Bedrock Model Access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html)
|
||||
</Note>
|
||||
|
||||
Prowler provides flexibility to select available models. When you select a model, you are subscribed in the Amazon Marketplace, but you are charged only on a usage basis.
|
||||
|
||||
<Note>
|
||||
Lighthouse AI automatically filters Amazon Bedrock models to display only those that support text input, text output, and tool calling capabilities. This ensures all available models are compatible with Lighthouse AI features.
|
||||
</Note>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="OpenAI Compatible">
|
||||
|
||||
@@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
- Risk Pipeline component with Sankey chart to Overview page [(#9317)](https://github.com/prowler-cloud/prowler/pull/9317)
|
||||
- Threat Map component to Overview Page [(#9324)](https://github.com/prowler-cloud/prowler/pull/9324)
|
||||
- MongoDB Atlas provider support [(#9253)](https://github.com/prowler-cloud/prowler/pull/9253)
|
||||
- Lighthouse AI support for Amazon Bedrock API key [(#9343)](https://github.com/prowler-cloud/prowler/pull/9343)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,18 +4,42 @@ import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ConnectLLMProvider } from "@/components/lighthouse/connect-llm-provider";
|
||||
import { SelectBedrockAuthMethod } from "@/components/lighthouse/select-bedrock-auth-method";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse";
|
||||
|
||||
const BEDROCK_AUTH_MODES = {
|
||||
IAM: "iam",
|
||||
API_KEY: "api_key",
|
||||
} as const;
|
||||
|
||||
type BedrockAuthMode =
|
||||
(typeof BEDROCK_AUTH_MODES)[keyof typeof BEDROCK_AUTH_MODES];
|
||||
|
||||
function ConnectContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const provider = searchParams.get("provider") as LighthouseProvider | null;
|
||||
const mode = searchParams.get("mode") || "create";
|
||||
const auth = searchParams.get("auth") as BedrockAuthMode | null;
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ConnectLLMProvider provider={provider} mode={mode} />;
|
||||
const isBedrockCreateMode = provider === "bedrock" && mode !== "edit";
|
||||
|
||||
if (isBedrockCreateMode && !auth) {
|
||||
return <SelectBedrockAuthMethod />;
|
||||
}
|
||||
|
||||
const initialAuthMode = isBedrockCreateMode && auth ? auth : undefined;
|
||||
|
||||
return (
|
||||
<ConnectLLMProvider
|
||||
provider={provider}
|
||||
mode={mode}
|
||||
initialAuthMode={initialAuthMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConnectLLMProviderPage() {
|
||||
|
||||
@@ -14,21 +14,47 @@ import type { LighthouseProvider } from "@/types/lighthouse";
|
||||
import { getMainFields, getProviderConfig } from "./llm-provider-registry";
|
||||
import {
|
||||
isProviderFormValid,
|
||||
type LLMCredentialsFormData,
|
||||
shouldTestConnection,
|
||||
testAndRefreshModels,
|
||||
} from "./llm-provider-utils";
|
||||
|
||||
const BEDROCK_CREDENTIAL_MODES = {
|
||||
API_KEY: "api_key",
|
||||
IAM: "iam",
|
||||
} as const;
|
||||
|
||||
type BedrockCredentialMode =
|
||||
(typeof BEDROCK_CREDENTIAL_MODES)[keyof typeof BEDROCK_CREDENTIAL_MODES];
|
||||
|
||||
const CONNECTION_STATUS = {
|
||||
IDLE: "idle",
|
||||
CONNECTING: "connecting",
|
||||
VERIFYING: "verifying",
|
||||
LOADING_MODELS: "loading-models",
|
||||
} as const;
|
||||
|
||||
type ConnectionStatus =
|
||||
(typeof CONNECTION_STATUS)[keyof typeof CONNECTION_STATUS];
|
||||
|
||||
const STATUS_MESSAGES: Record<Exclude<ConnectionStatus, "idle">, string> = {
|
||||
[CONNECTION_STATUS.CONNECTING]: "Connecting...",
|
||||
[CONNECTION_STATUS.VERIFYING]: "Verifying...",
|
||||
[CONNECTION_STATUS.LOADING_MODELS]: "Loading models...",
|
||||
};
|
||||
|
||||
interface ConnectLLMProviderProps {
|
||||
provider: LighthouseProvider;
|
||||
mode?: string;
|
||||
initialAuthMode?: BedrockCredentialMode;
|
||||
}
|
||||
|
||||
type FormData = Record<string, string>;
|
||||
type Status = "idle" | "connecting" | "verifying" | "loading-models";
|
||||
|
||||
export const ConnectLLMProvider = ({
|
||||
provider,
|
||||
mode = "create",
|
||||
initialAuthMode,
|
||||
}: ConnectLLMProviderProps) => {
|
||||
const router = useRouter();
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
@@ -38,9 +64,17 @@ export const ConnectLLMProvider = ({
|
||||
const [existingProviderId, setExistingProviderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [status, setStatus] = useState<ConnectionStatus>(
|
||||
CONNECTION_STATUS.IDLE,
|
||||
);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bedrockMode, setBedrockMode] = useState<BedrockCredentialMode>(() => {
|
||||
if (provider === "bedrock" && mode !== "edit" && initialAuthMode) {
|
||||
return initialAuthMode;
|
||||
}
|
||||
return BEDROCK_CREDENTIAL_MODES.API_KEY;
|
||||
});
|
||||
|
||||
// Fetch existing provider ID in edit mode
|
||||
useEffect(() => {
|
||||
@@ -56,6 +90,25 @@ export const ConnectLLMProvider = ({
|
||||
);
|
||||
}
|
||||
setExistingProviderId(result.data.id);
|
||||
|
||||
// For Bedrock, detect existing credential mode (API key vs IAM)
|
||||
if (provider === "bedrock") {
|
||||
const attributes = (result.data as any)?.attributes;
|
||||
const credentials = attributes?.credentials as
|
||||
| LLMCredentialsFormData
|
||||
| undefined;
|
||||
|
||||
if (credentials) {
|
||||
if (credentials.api_key) {
|
||||
setBedrockMode(BEDROCK_CREDENTIAL_MODES.API_KEY);
|
||||
} else if (
|
||||
credentials.access_key_id ||
|
||||
credentials.secret_access_key
|
||||
) {
|
||||
setBedrockMode(BEDROCK_CREDENTIAL_MODES.IAM);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -66,13 +119,30 @@ export const ConnectLLMProvider = ({
|
||||
fetchProvider();
|
||||
}, [isEditMode, provider, providerConfig]);
|
||||
|
||||
const buildPayload = (): Record<string, any> => {
|
||||
if (!providerConfig) return {};
|
||||
const buildBedrockPayload = (): Record<string, any> => {
|
||||
const credentials: LLMCredentialsFormData = {};
|
||||
|
||||
if (bedrockMode === BEDROCK_CREDENTIAL_MODES.API_KEY) {
|
||||
if (formData.api_key) credentials.api_key = formData.api_key;
|
||||
if (formData.region) credentials.region = formData.region;
|
||||
} else {
|
||||
if (formData.access_key_id) {
|
||||
credentials.access_key_id = formData.access_key_id;
|
||||
}
|
||||
if (formData.secret_access_key) {
|
||||
credentials.secret_access_key = formData.secret_access_key;
|
||||
}
|
||||
if (formData.region) credentials.region = formData.region;
|
||||
}
|
||||
|
||||
return Object.keys(credentials).length > 0 ? { credentials } : {};
|
||||
};
|
||||
|
||||
const buildGenericPayload = (): Record<string, any> => {
|
||||
const credentials: Record<string, string> = {};
|
||||
const otherFields: Record<string, string> = {};
|
||||
|
||||
providerConfig.fields.forEach((field) => {
|
||||
providerConfig?.fields.forEach((field) => {
|
||||
if (formData[field.name]) {
|
||||
if (field.requiresConnectionTest) {
|
||||
credentials[field.name] = formData[field.name];
|
||||
@@ -88,11 +158,18 @@ export const ConnectLLMProvider = ({
|
||||
};
|
||||
};
|
||||
|
||||
const buildPayload = (): Record<string, any> => {
|
||||
if (!providerConfig) return {};
|
||||
return provider === "bedrock"
|
||||
? buildBedrockPayload()
|
||||
: buildGenericPayload();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!providerConfig) return;
|
||||
|
||||
setStatus("connecting");
|
||||
setStatus(CONNECTION_STATUS.CONNECTING);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
@@ -125,11 +202,23 @@ export const ConnectLLMProvider = ({
|
||||
setExistingProviderId(providerId);
|
||||
}
|
||||
|
||||
const shouldTestBedrock =
|
||||
(bedrockMode === BEDROCK_CREDENTIAL_MODES.API_KEY &&
|
||||
!!formData.api_key?.trim()) ||
|
||||
(bedrockMode === BEDROCK_CREDENTIAL_MODES.IAM &&
|
||||
(!!formData.access_key_id?.trim() ||
|
||||
!!formData.secret_access_key?.trim()));
|
||||
|
||||
const shouldTest =
|
||||
provider === "bedrock"
|
||||
? shouldTestBedrock
|
||||
: shouldTestConnection(provider, formData);
|
||||
|
||||
// Test connection if credentials provided
|
||||
if (shouldTestConnection(provider, formData)) {
|
||||
setStatus("verifying");
|
||||
if (shouldTest) {
|
||||
setStatus(CONNECTION_STATUS.VERIFYING);
|
||||
await testAndRefreshModels(providerId);
|
||||
setStatus("loading-models");
|
||||
setStatus(CONNECTION_STATUS.LOADING_MODELS);
|
||||
}
|
||||
|
||||
// Navigate to model selection on success
|
||||
@@ -138,7 +227,7 @@ export const ConnectLLMProvider = ({
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setStatus("idle");
|
||||
setStatus(CONNECTION_STATUS.IDLE);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,16 +242,44 @@ export const ConnectLLMProvider = ({
|
||||
};
|
||||
|
||||
const getLoadingText = () => {
|
||||
if (status === "idle") return "Connecting...";
|
||||
|
||||
const statusText: Record<Exclude<Status, "idle">, string> = {
|
||||
connecting: "Connecting...",
|
||||
verifying: "Verifying...",
|
||||
"loading-models": "Loading models...",
|
||||
};
|
||||
return statusText[status] || "Connecting...";
|
||||
if (status === CONNECTION_STATUS.IDLE) {
|
||||
return "";
|
||||
}
|
||||
return (
|
||||
STATUS_MESSAGES[status] || STATUS_MESSAGES[CONNECTION_STATUS.CONNECTING]
|
||||
);
|
||||
};
|
||||
|
||||
const renderFormField = (
|
||||
id: string,
|
||||
label: string,
|
||||
type: string,
|
||||
placeholder: string,
|
||||
required = true,
|
||||
) => (
|
||||
<div>
|
||||
<label htmlFor={id} className="mb-2 block text-sm font-medium">
|
||||
{label}{" "}
|
||||
{!isEditMode && required && <span className="text-text-error">*</span>}
|
||||
{isEditMode && (
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
(leave empty to keep existing)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={formData[id] || ""}
|
||||
onChange={(e) => handleFieldChange(id, e.target.value)}
|
||||
placeholder={
|
||||
isEditMode ? `Enter new ${label} or leave empty` : placeholder
|
||||
}
|
||||
className="border-border-neutral-primary bg-bg-neutral-primary w-full rounded-lg border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!providerConfig) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@@ -184,8 +301,29 @@ export const ConnectLLMProvider = ({
|
||||
}
|
||||
|
||||
const mainFields = getMainFields(provider);
|
||||
const isFormValid = isProviderFormValid(provider, formData, isEditMode);
|
||||
const isLoading = status !== "idle";
|
||||
|
||||
const isBedrockProvider = provider === "bedrock";
|
||||
|
||||
const isBedrockFormValid = (): boolean => {
|
||||
if (isEditMode) return true;
|
||||
|
||||
const hasRegion = !!formData.region?.trim();
|
||||
|
||||
if (bedrockMode === BEDROCK_CREDENTIAL_MODES.API_KEY) {
|
||||
return !!formData.api_key?.trim() && hasRegion;
|
||||
}
|
||||
|
||||
return (
|
||||
!!formData.access_key_id?.trim() &&
|
||||
!!formData.secret_access_key?.trim() &&
|
||||
hasRegion
|
||||
);
|
||||
};
|
||||
|
||||
const isFormValid = isBedrockProvider
|
||||
? isBedrockFormValid()
|
||||
: isProviderFormValid(provider, formData, isEditMode);
|
||||
const isLoading = status !== CONNECTION_STATUS.IDLE;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
@@ -209,40 +347,92 @@ export const ConnectLLMProvider = ({
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{mainFields.map((field) => (
|
||||
<div key={field.name}>
|
||||
<label
|
||||
htmlFor={field.name}
|
||||
className="mb-2 block text-sm font-medium"
|
||||
>
|
||||
{field.label}{" "}
|
||||
{!isEditMode && field.required && (
|
||||
<span className="text-text-error">*</span>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
(leave empty to keep existing)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id={field.name}
|
||||
type={field.type}
|
||||
value={formData[field.name] || ""}
|
||||
onChange={(e) => handleFieldChange(field.name, e.target.value)}
|
||||
placeholder={
|
||||
isEditMode
|
||||
? `Enter new ${field.label.toLowerCase()} or leave empty`
|
||||
: field.placeholder
|
||||
}
|
||||
className="border-border-neutral-primary bg-bg-neutral-primary w-full rounded-lg border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{isBedrockProvider ? (
|
||||
<>
|
||||
{bedrockMode === BEDROCK_CREDENTIAL_MODES.API_KEY && (
|
||||
<div className="border-border-warning-primary bg-bg-warning-secondary rounded-lg border p-4">
|
||||
<p className="text-text-warning text-sm font-medium">
|
||||
Recommended only for exploration of Amazon Bedrock.
|
||||
</p>
|
||||
<p className="text-text-warning mt-1 text-xs">
|
||||
Please ensure you're using long-term Bedrock API keys.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{bedrockMode === BEDROCK_CREDENTIAL_MODES.API_KEY ? (
|
||||
<>
|
||||
{renderFormField(
|
||||
"api_key",
|
||||
"API key (long-term)",
|
||||
"password",
|
||||
"Enter your long-term API key",
|
||||
)}
|
||||
{renderFormField(
|
||||
"region",
|
||||
"AWS region",
|
||||
"text",
|
||||
"Enter the AWS region",
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderFormField(
|
||||
"access_key_id",
|
||||
"AWS access key ID",
|
||||
"password",
|
||||
"Enter the AWS Access Key ID",
|
||||
)}
|
||||
{renderFormField(
|
||||
"secret_access_key",
|
||||
"AWS secret access key",
|
||||
"password",
|
||||
"Enter the AWS Secret Access Key",
|
||||
)}
|
||||
{renderFormField(
|
||||
"region",
|
||||
"AWS region",
|
||||
"text",
|
||||
"Enter the AWS Region",
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
mainFields.map((field) => (
|
||||
<div key={field.name}>
|
||||
<label
|
||||
htmlFor={field.name}
|
||||
className="mb-2 block text-sm font-medium"
|
||||
>
|
||||
{field.label}{" "}
|
||||
{!isEditMode && field.required && (
|
||||
<span className="text-text-error">*</span>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
(leave empty to keep existing)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id={field.name}
|
||||
type={field.type}
|
||||
value={formData[field.name] || ""}
|
||||
onChange={(e) => handleFieldChange(field.name, e.target.value)}
|
||||
placeholder={
|
||||
isEditMode
|
||||
? `Enter new ${field.label} or leave empty`
|
||||
: field.placeholder
|
||||
}
|
||||
className="border-border-neutral-primary bg-bg-neutral-primary w-full rounded-lg border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<FormButtons
|
||||
onCancel={() => router.push("/lighthouse/config")}
|
||||
submitText={getSubmitText()}
|
||||
submitText={isLoading ? getLoadingText() : getSubmitText()}
|
||||
loadingText={getLoadingText()}
|
||||
isDisabled={!isFormValid || isLoading}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { RadioGroup } from "@heroui/radio";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { CustomRadio } from "@/components/ui/custom";
|
||||
|
||||
const BEDROCK_AUTH_METHODS = {
|
||||
API_KEY: "api_key",
|
||||
IAM: "iam",
|
||||
} as const;
|
||||
|
||||
type BedrockAuthMethod =
|
||||
(typeof BEDROCK_AUTH_METHODS)[keyof typeof BEDROCK_AUTH_METHODS];
|
||||
|
||||
export const SelectBedrockAuthMethod = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentAuth = searchParams.get("auth") as BedrockAuthMethod | null;
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("auth", value);
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="mb-2 text-xl font-semibold">Connect Amazon Bedrock</h2>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Choose how you want to authenticate with Amazon Bedrock. You can use a
|
||||
dedicated Bedrock API key or long-term AWS access keys.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<RadioGroup
|
||||
className="flex flex-col gap-3"
|
||||
value={currentAuth || ""}
|
||||
onValueChange={handleSelectionChange}
|
||||
>
|
||||
<CustomRadio value={BEDROCK_AUTH_METHODS.API_KEY}>
|
||||
<div className="flex items-center">
|
||||
<span className="ml-2">Use Bedrock API Key</span>
|
||||
</div>
|
||||
</CustomRadio>
|
||||
|
||||
<CustomRadio value={BEDROCK_AUTH_METHODS.IAM}>
|
||||
<div className="flex items-center">
|
||||
<span className="ml-2">Use AWS Access Key & Secret</span>
|
||||
</div>
|
||||
</CustomRadio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,15 @@
|
||||
import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime";
|
||||
import { ChatBedrockConverse } from "@langchain/aws";
|
||||
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
|
||||
export type ProviderType = "openai" | "bedrock" | "openai_compatible";
|
||||
const PROVIDER_TYPES = {
|
||||
OPENAI: "openai",
|
||||
BEDROCK: "bedrock",
|
||||
OPENAI_COMPATIBLE: "openai_compatible",
|
||||
} as const;
|
||||
|
||||
export type ProviderType = (typeof PROVIDER_TYPES)[keyof typeof PROVIDER_TYPES];
|
||||
|
||||
export interface LLMCredentials {
|
||||
api_key?: string;
|
||||
@@ -25,9 +32,53 @@ export interface LLMConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function createBedrockClient(
|
||||
credentials: LLMCredentials,
|
||||
): BedrockRuntimeClient {
|
||||
if (!credentials.region) {
|
||||
throw new Error("Bedrock provider requires region");
|
||||
}
|
||||
|
||||
if (credentials.api_key) {
|
||||
return new BedrockRuntimeClient({
|
||||
region: credentials.region,
|
||||
token: async () => ({ token: credentials.api_key as string }),
|
||||
authSchemePreference: ["httpBearerAuth"],
|
||||
});
|
||||
}
|
||||
|
||||
if (!credentials.access_key_id || !credentials.secret_access_key) {
|
||||
throw new Error(
|
||||
"Bedrock provider requires either api_key or access_key_id and secret_access_key",
|
||||
);
|
||||
}
|
||||
|
||||
return new BedrockRuntimeClient({
|
||||
region: credentials.region,
|
||||
credentials: {
|
||||
accessKeyId: credentials.access_key_id,
|
||||
secretAccessKey: credentials.secret_access_key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createBedrockLLM(config: LLMConfig): ChatBedrockConverse {
|
||||
const client = createBedrockClient(config.credentials);
|
||||
|
||||
return new ChatBedrockConverse({
|
||||
model: config.model,
|
||||
client,
|
||||
region: config.credentials.region!,
|
||||
streaming: config.streaming,
|
||||
tags: config.tags,
|
||||
maxTokens: config.modelParams?.maxTokens,
|
||||
temperature: config.modelParams?.temperature,
|
||||
});
|
||||
}
|
||||
|
||||
export function createLLM(config: LLMConfig): BaseChatModel {
|
||||
switch (config.provider) {
|
||||
case "openai":
|
||||
case PROVIDER_TYPES.OPENAI:
|
||||
return new ChatOpenAI({
|
||||
modelName: config.model,
|
||||
apiKey: config.credentials.api_key,
|
||||
@@ -37,7 +88,7 @@ export function createLLM(config: LLMConfig): BaseChatModel {
|
||||
temperature: config.modelParams?.temperature,
|
||||
});
|
||||
|
||||
case "openai_compatible":
|
||||
case PROVIDER_TYPES.OPENAI_COMPATIBLE:
|
||||
return new ChatOpenAI({
|
||||
modelName: config.model,
|
||||
apiKey: config.credentials.api_key,
|
||||
@@ -50,28 +101,8 @@ export function createLLM(config: LLMConfig): BaseChatModel {
|
||||
temperature: config.modelParams?.temperature,
|
||||
});
|
||||
|
||||
case "bedrock":
|
||||
if (
|
||||
!config.credentials.access_key_id ||
|
||||
!config.credentials.secret_access_key ||
|
||||
!config.credentials.region
|
||||
) {
|
||||
throw new Error(
|
||||
"Bedrock provider requires access_key_id, secret_access_key, and region",
|
||||
);
|
||||
}
|
||||
return new ChatBedrockConverse({
|
||||
model: config.model,
|
||||
region: config.credentials.region,
|
||||
credentials: {
|
||||
accessKeyId: config.credentials.access_key_id,
|
||||
secretAccessKey: config.credentials.secret_access_key,
|
||||
},
|
||||
streaming: config.streaming,
|
||||
tags: config.tags,
|
||||
maxTokens: config.modelParams?.maxTokens,
|
||||
temperature: config.modelParams?.temperature,
|
||||
});
|
||||
case PROVIDER_TYPES.BEDROCK:
|
||||
return createBedrockLLM(config);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown provider type: ${config.provider}`);
|
||||
|
||||
@@ -112,12 +112,22 @@ export const openAICredentialsSchema = z.object({
|
||||
/**
|
||||
* Amazon Bedrock Provider Credentials Schema
|
||||
*/
|
||||
export const bedrockCredentialsSchema = z.object({
|
||||
export const bedrockIamCredentialsSchema = z.object({
|
||||
access_key_id: awsAccessKeyIdSchema,
|
||||
secret_access_key: awsSecretAccessKeySchema,
|
||||
region: awsRegionSchema,
|
||||
});
|
||||
|
||||
export const bedrockApiKeyCredentialsSchema = z.object({
|
||||
api_key: genericApiKeySchema,
|
||||
region: awsRegionSchema,
|
||||
});
|
||||
|
||||
export const bedrockCredentialsSchema = z.union([
|
||||
bedrockIamCredentialsSchema,
|
||||
bedrockApiKeyCredentialsSchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* OpenAI Compatible Provider Credentials Schema
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user