Skip to content

Commit

Permalink
in app config management
Browse files Browse the repository at this point in the history
  • Loading branch information
estohlmann authored Nov 5, 2024
1 parent f832ab5 commit 14dc255
Show file tree
Hide file tree
Showing 27 changed files with 935 additions and 115 deletions.
4 changes: 0 additions & 4 deletions example_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@ dev:
# rolePrefix: CustomPrefix
# policyPrefix: CustomPrefix
# instanceProfilePrefix: CustomPrefix
# systemBanner:
# text: 'LISA System'
# backgroundColor: orange
# fontColor: black
# vpcId: vpc-0123456789abcdef,
# subnetIds: [subnet-fedcba9876543210, subnet-0987654321fedcba],
s3BucketModels: hf-models-gaiic
Expand Down
6 changes: 5 additions & 1 deletion lambda/authorizer/lambda_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i
logger.info("REST API authorization handler started")

requested_resource = event["resource"]
request_method = event["httpMethod"]

id_token = get_id_token(event)

Expand Down Expand Up @@ -69,7 +70,10 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i
username = jwt_data.get("sub", "user")
logger.info(f"Deny access to {username} due to non-admin accessing /models api.")
return deny_policy

if requested_resource.startswith("/configuration") and request_method == "PUT" and not is_admin_user:
username = jwt_data.get("sub", "user")
logger.info(f"Deny access to {username} due to non-admin trying to update configuration.")
return deny_policy
logger.debug(f"Generated policy: {allow_policy}")
logger.info(f"REST API authorization handler completed with 'Allow' for resource {event['methodArn']}")
return allow_policy
Expand Down
13 changes: 13 additions & 0 deletions lambda/configuration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License").
# You may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
65 changes: 65 additions & 0 deletions lambda/configuration/lambda_functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License").
# You may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Lambda functions for managing sessions."""
import json
import logging
import os
import time
from decimal import Decimal
from typing import Any, Dict

import boto3
import create_env_variables # noqa: F401
from botocore.exceptions import ClientError
from utilities.common_functions import api_wrapper, retry_config

logger = logging.getLogger(__name__)

dynamodb = boto3.resource("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config)
table = dynamodb.Table(os.environ["CONFIG_TABLE_NAME"])


@api_wrapper
def get_configuration(event: dict, context: dict) -> Dict[str, Any]:
"""List configuration entries by configScope from DynamoDB."""
config_scope = event["queryStringParameters"]["configScope"]

response = {}
try:
response = table.query(
KeyConditionExpression="#s = :configScope",
ExpressionAttributeNames={"#s": "configScope"},
ExpressionAttributeValues={":configScope": config_scope},
ScanIndexForward=False,
)
except ClientError as error:
if error.response["Error"]["Code"] == "ResourceNotFoundException":
logger.warning(f"No record found with session id: {config_scope}")
else:
logger.exception("Error fetching session")
return response.get("Items", {}) # type: ignore [no-any-return]


@api_wrapper
def update_configuration(event: dict, context: dict) -> None:
"""Update configuration in DynamoDB."""
# from https://stackoverflow.com/a/71446846
body = json.loads(event["body"], parse_float=Decimal)
body["created_at"] = str(Decimal(time.time()))

try:
table.put_item(Item=body)
except ClientError:
logger.exception("Error updating session in DynamoDB")
173 changes: 173 additions & 0 deletions lib/chat/api/configuration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License").
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { IAuthorizer, RestApi } from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { ISecurityGroup } from 'aws-cdk-lib/aws-ec2';
import { Role } from 'aws-cdk-lib/aws-iam';
import { LayerVersion, Runtime } from 'aws-cdk-lib/aws-lambda';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { Construct } from 'constructs';

import { PythonLambdaFunction, registerAPIEndpoint } from '../../api-base/utils';
import { BaseProps } from '../../schema';
import { createLambdaRole } from '../../core/utils';
import { Vpc } from '../../networking/vpc';
import { AwsCustomResource, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';

/**
* Properties for ConfigurationApi Construct.
*
* @property {IVpc} vpc - Stack VPC
* @property {Layer} commonLayer - Lambda layer for all Lambdas.
* @property {IRestApi} restAPI - REST APIGW for UI and Lambdas
* @property {IRole} lambdaExecutionRole - Execution role for lambdas
* @property {IAuthorizer} authorizer - APIGW authorizer
* @property {ISecurityGroup[]} securityGroups - Security groups for Lambdas
* @property {Map<number, ISubnet> }importedSubnets for application.
*/
type ConfigurationApiProps = {
authorizer: IAuthorizer;
restApiId: string;
rootResourceId: string;
securityGroups?: ISecurityGroup[];
vpc?: Vpc;
} & BaseProps;

/**
* API which Maintains config state in DynamoDB
*/
export class ConfigurationApi extends Construct {
constructor (scope: Construct, id: string, props: ConfigurationApiProps) {
super(scope, id);

const { authorizer, config, restApiId, rootResourceId, securityGroups, vpc } = props;

// Get common layer based on arn from SSM due to issues with cross stack references
const commonLambdaLayer = LayerVersion.fromLayerVersionArn(
this,
'configuration-common-lambda-layer',
StringParameter.valueForStringParameter(this, `${config.deploymentPrefix}/layerVersion/common`),
);

// Create DynamoDB table to handle config data
const configTable = new dynamodb.Table(this, 'ConfigurationTable', {
partitionKey: {
name: 'configScope',
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: 'versionId',
type: dynamodb.AttributeType.NUMBER,
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
});

const lambdaRole: Role = createLambdaRole(this, config.deploymentName, 'ConfigurationApi', configTable.tableArn);

// Populate the App Config table with default config
const date = new Date();
new AwsCustomResource(this, 'lisa-init-ddb-config', {
onCreate: {
service: 'DynamoDB',
action: 'putItem',
physicalResourceId: PhysicalResourceId.of('initConfigData'),
parameters: {
TableName: configTable.tableName,
Item: {
'versionId': {'N': '0'},
'changedBy': {'S': 'System'},
'configScope': {'S': 'global'},
'changeReason': {'S': 'Initial deployment default config'},
'createdAt': {'S': Math.round(date.getTime() / 1000).toString()},
'configuration': {'M': {
'enabledComponents': {'M': {
'deleteSessionHistory': {'BOOL': 'True'},
'viewMetaData': {'BOOL': 'True'},
'editKwargs': {'BOOL': 'True'},
'editPromptTemplate': {'BOOL': 'True'},
'editChatHistoryBuffer': {'BOOL': 'True'},
'editNumOfRagDocument': {'BOOL': 'True'},
'uploadRagDocs': {'BOOL': 'True'},
'uploadContextDocs': {'BOOL': 'True'}
}},
'systemBanner': {'M': {
'isEnabled': {'BOOL': 'False'},
'text': {'S': ''},
'textColor': {'S': ''},
'backgroundColor': {'S': ''}
}}
}}
},
},
},
role: lambdaRole
});

const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', {
restApiId: restApiId,
rootResourceId: rootResourceId,
});

// Create API Lambda functions
const apis: PythonLambdaFunction[] = [
{
name: 'get_configuration',
resource: 'configuration',
description: 'Get configuration',
path: 'configuration',
method: 'GET',
environment: {
CONFIG_TABLE_NAME: configTable.tableName
},
},
{
name: 'update_configuration',
resource: 'configuration',
description: 'Updates config data',
path: 'configuration/{configScope}',
method: 'PUT',
environment: {
CONFIG_TABLE_NAME: configTable.tableName,
},
},
];

apis.forEach((f) => {
const lambdaFunction = registerAPIEndpoint(
this,
restApi,
authorizer,
'./lambda',
[commonLambdaLayer],
f,
Runtime.PYTHON_3_10,
lambdaRole,
vpc,
securityGroups,
);
if (f.method === 'POST' || f.method === 'PUT') {
configTable.grantWriteData(lambdaFunction);
} else if (f.method === 'GET') {
configTable.grantReadData(lambdaFunction);
} else if (f.method === 'DELETE') {
configTable.grantReadWriteData(lambdaFunction);
}
});
}
}
10 changes: 10 additions & 0 deletions lib/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Construct } from 'constructs';
import { SessionApi } from './api/session';
import { BaseProps } from '../schema';
import { Vpc } from '../networking/vpc';
import { ConfigurationApi } from './api/configuration';

type CustomLisaChatStackProps = {
authorizer: IAuthorizer;
Expand Down Expand Up @@ -56,5 +57,14 @@ export class LisaChatApplicationStack extends Stack {
securityGroups,
vpc,
});

new ConfigurationApi(this, 'ConfigurationApi', {
authorizer,
config,
restApiId,
rootResourceId,
securityGroups,
vpc,
});
}
}
7 changes: 0 additions & 7 deletions lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -765,13 +765,6 @@ const RawConfigSchema = z
sdkLayerPath: z.string().optional(),
})
.optional(),
systemBanner: z
.object({
text: z.string(),
backgroundColor: z.string(),
fontColor: z.string(),
})
.optional(),
permissionsBoundaryAspect: z
.object({
permissionsBoundaryPolicyName: z.string(),
Expand Down
5 changes: 0 additions & 5 deletions lib/user-interface/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,6 @@ export class UserInterfaceStack extends Stack {
).stringValue,
RESTAPI_VERSION: 'v2',
RAG_ENABLED: config.deployRag,
SYSTEM_BANNER: {
text: config.systemBanner?.text,
backgroundColor: config.systemBanner?.backgroundColor,
fontColor: config.systemBanner?.fontColor,
},
API_BASE_URL: config.apiGatewayConfig?.domainName ? '/' : `/${config.deploymentStage}/`,
};

Expand Down
17 changes: 14 additions & 3 deletions lib/user-interface/react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { selectCurrentUserIsAdmin } from './shared/reducers/user.reducer';
import ModelManagement from './pages/ModelManagement';
import NotificationBanner from './shared/notification/notification';
import ConfirmationModal, { ConfirmationModalProps } from './shared/modal/confirmation-modal';
import Configuration from './pages/Configuration';
import { useGetConfigurationQuery } from './shared/reducers/configuration.reducer';

const PrivateRoute = ({ children }) => {
const auth = useAuth();
Expand Down Expand Up @@ -58,6 +60,7 @@ function App () {
const [showTools, setShowTools] = useState(false);
const [tools, setTools] = useState(null);
const confirmationModal: ConfirmationModalProps = useAppSelector((state) => state.modal.confirmationModal);
const { data: config } = useGetConfigurationQuery('global', {refetchOnMountOrArgChange: 5});

useEffect(() => {
if (tools) {
Expand All @@ -70,10 +73,10 @@ function App () {
const baseHref = document?.querySelector('base')?.getAttribute('href')?.replace(/\/$/, '');
return (
<HashRouter basename={baseHref}>
{window.env.SYSTEM_BANNER?.text && <SystemBanner position='TOP' />}
{config && config[0]?.configuration.systemBanner.isEnabled && <SystemBanner position='TOP' />}
<div
id='h'
style={{ position: 'sticky', top: 0, paddingTop: window.env.SYSTEM_BANNER?.text ? '1.5em' : 0, zIndex: 1002 }}
style={{ position: 'sticky', top: 0, paddingTop: config && config[0]?.configuration.systemBanner.isEnabled ? '1.5em' : 0, zIndex: 1002 }}
>
<Topbar />
</div>
Expand Down Expand Up @@ -113,11 +116,19 @@ function App () {
</AdminRoute>
}
/>
<Route
path='configuration'
element={
<AdminRoute>
<Configuration setTools={setTools} />
</AdminRoute>
}
/>
</Routes>
}
/>
{confirmationModal && <ConfirmationModal {...confirmationModal} />}
{window.env.SYSTEM_BANNER?.text && <SystemBanner position='BOTTOM' />}
{config && config[0]?.configuration.systemBanner.isEnabled && <SystemBanner position='BOTTOM' />}
</HashRouter>
);
}
Expand Down
Loading

0 comments on commit 14dc255

Please sign in to comment.