diff --git a/aws-secrets/aws-secrets.cabal b/aws-secrets/aws-secrets.cabal index 9cff6f43..df48c669 100644 --- a/aws-secrets/aws-secrets.cabal +++ b/aws-secrets/aws-secrets.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.35.2. +-- This file has been generated from package.yaml by hpack version 0.36.0. -- -- see: https://github.com/sol/hpack @@ -78,6 +78,7 @@ library , optparse-applicative , stack-deploy , stratosphere + , stratosphere-iam , stratosphere-secretsmanager , text , unliftio @@ -142,6 +143,8 @@ test-suite test , optparse-applicative , stack-deploy , stratosphere + , stratosphere-iam + , stratosphere-lambda , stratosphere-secretsmanager , tasty , tasty-hunit diff --git a/aws-secrets/package.yaml b/aws-secrets/package.yaml index 9ae7c8af..87027edc 100644 --- a/aws-secrets/package.yaml +++ b/aws-secrets/package.yaml @@ -20,6 +20,7 @@ dependencies: - optparse-applicative - stack-deploy - stratosphere +- stratosphere-iam - stratosphere-secretsmanager - text - unliftio @@ -31,5 +32,6 @@ tests: - aws-secrets - devtools - mio-log + - stratosphere-lambda - tasty - tasty-hunit diff --git a/aws-secrets/src/AWS/Secrets.hs b/aws-secrets/src/AWS/Secrets.hs index 15d86de3..2f0f1a01 100644 --- a/aws-secrets/src/AWS/Secrets.hs +++ b/aws-secrets/src/AWS/Secrets.hs @@ -5,10 +5,13 @@ module AWS.Secrets , IsSecret(..) , SecretConfig(..) , arnValue + , envSpecEntries , externalTemplate , fetchStackSecretArn + , getSecretValuePolicy , internalComponent , rdsGeneratePostgresqlPassword + , readEnvSecretValue , readStackSecretValue , secretNameValue , secrets @@ -20,14 +23,19 @@ import AWS.Secrets.Prelude import qualified Amazonka import qualified Amazonka.CloudFormation.Types as CF import qualified Amazonka.SecretsManager.GetSecretValue as SecretsManager +import qualified Data.Aeson as JSON import qualified Data.List as List +import qualified Data.Text as Text import qualified MIO.Amazonka as AWS import qualified MIO.Log as Log import qualified StackDeploy.Component as StackDeploy +import qualified StackDeploy.EnvSpec import qualified StackDeploy.Template as StackDeploy import qualified StackDeploy.Utils import qualified Stratosphere as CFT +import qualified Stratosphere.IAM.Role as IAM.Role import qualified Stratosphere.SecretsManager.Secret as SecretsManager +import qualified UnliftIO.Environment as Environment import qualified UnliftIO.Exception as UnliftIO type Env env = (AWS.Env env, Log.Env env) @@ -48,23 +56,17 @@ externalSecretsStackParameter = CFT.mkParameter "ExternalSecretsStack" "String" internalComponent :: forall a . IsSecret a => StackDeploy.Component internalComponent = mempty { StackDeploy.outputs = CFT.Outputs $ internalOutputs <> externalOutputs - , StackDeploy.resources = CFT.Resources $ mkResource <$> internalSecrets + , StackDeploy.resources = CFT.Resources $ mkSecretResource <$> internalSecrets , StackDeploy.parameters = CFT.Parameters effectiveParameters } where - internalOutputs = mkInternalOutput <$> internalSecrets - externalOutputs = mkExternalOutput <$> externalSecrets @a + internalOutputs = mkInternalOutput <$> internalSecrets + externalOutputs = mkInternalExternalOutput <$> externalSecrets effectiveParameters = [externalSecretsStackParameter | not $ List.null externalOutputs] - mkExternalOutput :: a -> CFT.Output - mkExternalOutput secret - = CFT.mkOutput (arnOutputName secret) (arnExternalValue secret) - - internalSecrets = filterSecrets @a $ \case - Internal{} -> True - External{} -> False + (externalSecrets, internalSecrets) = partitionSecrets @a externalTemplate :: forall a . IsSecret a @@ -77,7 +79,7 @@ externalTemplate = where stratosphere :: CFT.Template stratosphere = - (CFT.mkTemplate . CFT.Resources $ mkResource <$> externalSecrets @a) + (CFT.mkTemplate . CFT.Resources $ mkSecretResource <$> externalSecrets) { CFT.outputs = pure . CFT.Outputs $ mkExternalOutput <$> externalSecrets } mkExternalOutput :: a -> CFT.Output @@ -88,18 +90,21 @@ externalTemplate = $ mkOutputExportNameValue CFT.awsStackName secret } -externalSecrets :: forall a . IsSecret a => [a] -externalSecrets = filterSecrets @a $ \case - Internal{} -> False - External{} -> True + (externalSecrets, _internalSecrets) = partitionSecrets @a -filterSecrets :: forall a . IsSecret a => (SecretConfig -> Bool) -> [a] -filterSecrets filter = List.filter (filter . secretConfig) $ secrets @a +mkInternalExternalOutput :: IsSecret a => a -> CFT.Output +mkInternalExternalOutput secret + = CFT.mkOutput (arnOutputName secret) (arnExternalValue secret) + +mkInternalOutput :: IsSecret a => a -> CFT.Output +mkInternalOutput secret = CFT.mkOutput (arnOutputName secret) (arnInternalValue secret) -extractSecretsManagerSecret :: SecretConfig -> SecretsManager.Secret -extractSecretsManagerSecret = \case - (Internal secret) -> secret - (External secret) -> secret +partitionSecrets :: forall a . IsSecret a => ([a], [a]) +partitionSecrets = List.partition (filter . secretConfig) $ secrets @a + where + filter = \case + Internal{} -> False + External{} -> True logicalResourceName :: IsSecret a => a -> Text logicalResourceName = (<> "Secret") . convert . show @@ -122,14 +127,15 @@ mkOutputExportNameValue :: IsSecret a => CFT.Value Text -> a -> CFT.Value Text mkOutputExportNameValue stackName secret = CFT.Join "-" [stackName, CFT.Literal $ arnOutputName secret] -mkResource :: IsSecret a => a -> CFT.Resource -mkResource secret +mkSecretResource :: IsSecret a => a -> CFT.Resource +mkSecretResource secret = CFT.resource (logicalResourceName secret) . extractSecretsManagerSecret $ secretConfig secret - -mkInternalOutput :: IsSecret a => a -> CFT.Output -mkInternalOutput secret = CFT.mkOutput (arnOutputName secret) (arnInternalValue secret) + where + extractSecretsManagerSecret = \case + (Internal value) -> value + (External value) -> value secretNameValue :: Text -> CFT.Value Text -> CFT.Value Text secretNameValue namespace name @@ -146,6 +152,9 @@ fetchStackSecretArn stack readStackSecretValue :: Env env => IsSecret a => CF.Stack -> a -> MIO env Text readStackSecretValue stack secret = readSecretsManagerSecret =<< fetchStackSecretArn stack secret +readEnvSecretValue :: (Env env, IsSecret a) => a -> MIO env Text +readEnvSecretValue = readSecretsManagerSecret . convert <=< Environment.getEnv . convert . envArnName + readSecretsManagerSecret :: Env env => Text -> MIO env Text readSecretsManagerSecret arn = do Log.info $ "Reading secret: " <> arn @@ -163,3 +172,25 @@ rdsGeneratePostgresqlPassword = SecretsManager.mkGenerateSecretStringProperty & CFT.set @"ExcludeCharacters" "/\"@" & CFT.set @"PasswordLength" (CFT.Literal 48) + +envArnName :: IsSecret a => a -> Text +envArnName = (<> "_ARN") . Text.toUpper . convert . JSON.camelTo2 '_' . show + +envSpecEntries :: IsSecret a => [a] -> [StackDeploy.EnvSpec.Entry] +envSpecEntries = fmap $ \secret -> + StackDeploy.EnvSpec.Entry + { envName = envArnName secret + , envValue = StackDeploy.EnvSpec.StackOutput $ case secretConfig secret of + Internal{} -> mkInternalOutput secret + External{} -> mkInternalExternalOutput secret + } + +getSecretValuePolicy :: IsSecret a => [a] -> IAM.Role.PolicyProperty +getSecretValuePolicy values = IAM.Role.mkPolicyProperty [("Statement", statement)] "allow-secrets" + where + statement :: JSON.Value + statement = JSON.Object + [ ("Action", "secretsmanager:GetSecretValue") + , ("Effect", "Allow") + , ("Resource", JSON.toJSON $ arnValue <$> values) + ] diff --git a/aws-secrets/test/Test.hs b/aws-secrets/test/Test.hs index 6845e976..8a15acc8 100644 --- a/aws-secrets/test/Test.hs +++ b/aws-secrets/test/Test.hs @@ -7,8 +7,12 @@ import AWS.Secrets.Prelude import qualified Devtools import qualified StackDeploy.Component as StackDeploy +import qualified StackDeploy.EnvSpec as StackDeploy import qualified StackDeploy.Template as StackDeploy +import qualified StackDeploy.Utils as StackDeploy import qualified Stratosphere as CFT +import qualified Stratosphere.IAM.Role as IAM +import qualified Stratosphere.Lambda.Function as Lambda import qualified Stratosphere.SecretsManager.Secret as SecretsManager import qualified Test.Tasty as Tasty @@ -50,8 +54,30 @@ main = where internalTemplate = StackDeploy.mkTemplate (fromType @"secrets-internal") - [internalComponent @TestSecret] + [ internalComponent @TestSecret + , lambdaComponent + ] internalTemplateNoExternal = StackDeploy.mkTemplate (fromType @"secrets-internal-no-external") [internalComponent @TestSecretNoExternal] + + lambdaComponent = mempty + { StackDeploy.resources = [lambdaFunction, lambdaRole] } + where + lambdaFunction + = CFT.resource "TestLambdaFunction" + $ Lambda.mkFunction lambdaFunctionCode (StackDeploy.getAttArn lambdaRole) + & CFT.set @"Environment" + (StackDeploy.lambdaEnvironment $ envSpecEntries [TestExternal, TestInternal]) + + lambdaRole + = CFT.resource "TestLambdaRole" + $ IAM.mkRole (StackDeploy.assumeRole "lambda.amazonaws.com") + & CFT.set @"Policies" [getSecretValuePolicy [TestExternal, TestInternal]] + + lambdaFunctionCode :: Lambda.CodeProperty + lambdaFunctionCode + = Lambda.mkCodeProperty + & CFT.set @"S3Bucket" "test-bucket" + & CFT.set @"S3Key" "test-key" diff --git a/aws-secrets/test/template/secrets-internal.json b/aws-secrets/test/template/secrets-internal.json index 5e47e25d..3c61df79 100644 --- a/aws-secrets/test/template/secrets-internal.json +++ b/aws-secrets/test/template/secrets-internal.json @@ -45,6 +45,85 @@ } }, "Type": "AWS::SecretsManager::Secret" + }, + "TestLambdaFunction": { + "Properties": { + "Code": { + "S3Bucket": "test-bucket", + "S3Key": "test-key" + }, + "Environment": { + "Variables": { + "TEST_EXTERNAL_ARN": { + "Fn::ImportValue": { + "Fn::Join": [ + "-", + [ + { + "Ref": "ExternalSecretsStack" + }, + "TestExternalSecretArn" + ] + ] + } + }, + "TEST_INTERNAL_ARN": { + "Ref": "TestInternalSecret" + } + } + }, + "Role": { + "Fn::GetAtt": [ + "TestLambdaRole", + "Arn" + ] + } + }, + "Type": "AWS::Lambda::Function" + }, + "TestLambdaRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + }, + "Version": "2012-10-17" + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": { + "Action": "secretsmanager:GetSecretValue", + "Effect": "Allow", + "Resource": [ + { + "Fn::ImportValue": { + "Fn::Join": [ + "-", + [ + { + "Ref": "ExternalSecretsStack" + }, + "TestExternalSecretArn" + ] + ] + } + }, + { + "Ref": "TestInternalSecret" + } + ] + } + }, + "PolicyName": "allow-secrets" + } + ] + }, + "Type": "AWS::IAM::Role" } } } \ No newline at end of file