aws-amplify / amplify-category-api

The AWS Amplify CLI is a toolchain for simplifying serverless web and mobile development. This plugin provides functionality for the API category, allowing for the creation and management of GraphQL and REST based backends for your amplify project.
https://docs.amplify.aws/
Apache License 2.0
82 stars 71 forks source link

Lambda authorizer can not access DynamoDb table #2217

Open hasan-aa opened 5 months ago

hasan-aa commented 5 months ago

Environment information

System:
    OS: macOS 13.2.1
    CPU: (10) arm64 Apple M1 Max
    Memory: 14.41 GB / 64.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 20.10.0 - ~/.nvm/versions/node/v20.10.0/bin/node
    Yarn: 1.22.21 - ~/.nvm/versions/node/v20.10.0/bin/yarn
    npm: 10.2.3 - ~/.nvm/versions/node/v20.10.0/bin/npm
    pnpm: 8.14.0 - ~/.nvm/versions/node/v20.10.0/bin/pnpm
    bun: Not Found
    Watchman: Not Found
  npmPackages:
    @aws-amplify/backend: ^0.10.1 => 0.10.1 
    @aws-amplify/backend-cli: ^0.9.6 => 0.9.6 
    aws-amplify: ^6.0.12 => 6.0.12 
    aws-cdk: ^2.121.1 => 2.121.1 
    aws-cdk-lib: ^2.121.1 => 2.121.1 
    typescript: ^5.3.3 => 5.3.3

Description

I'm trying to add a lambda authorizer that reads data from dynamo db table to make authorization decision. But this lambda doesn't have proper IAM permissions to access the table.

I'm trying to customize the table resource to give access to the lambda function but there seems to be no way of accessing the proper table resource.

josefaidt commented 5 months ago

Hey @hasan-aa :wave: thanks for raising this! I'm going to transfer this over to our API repo for tracking and better assistance 🙂

SalmonMode commented 5 months ago

I'm experiencing a similar issue but for GraphQL resolver functions. It might be a separate issue, though, because I think it would require also being able to reference the lambda function generated when using defineData and defineFunction together.

hasan-aa commented 5 months ago

@SalmonMode similarly I'm also not able to reference authorizer lambda function. This value is also always empty: backend.data.resources.cfnResources.cfnFunctions

SalmonMode commented 5 months ago

@hasan-aa I think I have something figured out. It looks like I can define a lambda function as I normally would, e.g. amplify/functions/someFunc/handler.ts and amplify/functions/someFunc/resource.ts, and include that in the call to defineBackend, e.g.:

amplify/functions/someFunc/resource.ts:

import { defineFunction } from '@aws-amplify/backend';

export const someFunc = defineFunction({
  /*
    name?: string // optional parameter to specify a function name. In this case, it will default to "someFunc" (the name of the directory where the function is defined)
    entry?: string // optional path to the function code. Defaults to ./handler.ts
  */
});

amplify/backend.ts:

import { someFunc } from "./functions/someFunc/resource";
export const backend = defineBackend({
  auth,
  data,
  someFunc,
});

Then in amplify/data/resources.ts, I can reference that function definition and set the authorization stuff like so:

import { someFunc } from "./functions/someFunc/resource";

const schema = a
  .schema({
    likeUser: a
      .mutation()
      .arguments({ profileId: a.string().required() })
      .returns(a.id())
      .authorization([a.allow.private("userPools")])
      .function("someFuncHandler"), // seems to determine the key looked for in the 'functions' section below
  })
  .authorization([a.allow.private("iam")]);

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: "userPool",
  },
  functions: {
    someFuncHandler: someFunc
  },
});

Then, in the main amplify/backend.ts file, I can define a standalone table, and give the function permission like so:

import { defineBackend } from "@aws-amplify/backend";
import { auth } from "./auth/resource";
import { data } from "./data/resource";
import { RemovalPolicy } from "aws-cdk-lib";
import { AttributeType, Table } from "aws-cdk-lib/aws-dynamodb";
import { someFunc} from "./functions/someFunc/resource";

export const backend = defineBackend({
  auth,
  data,
  someFunc,
});

const itemsTable = new Table(backend.someFunc.resources.lambda.stack, 'ItemsTable', {
  tableName: "things",
  partitionKey: {
    name: "id",
    type: AttributeType.STRING
  },
  removalPolicy: RemovalPolicy.DESTROY, // NOT recommended for production code
});
const lambda = backend.someFunc.resources.lambda;

itemsTable.grantFullAccess(lambda);

Maybe this can help you solve your issue.

hasan-aa commented 5 months ago

Thanks for the detailed explanation and code @SalmonMode! This looks very promising. I'll try that as soon as I can.

AnilMaktala commented 5 months ago

Hey @SalmonMode thanks for the providing the workaround.

@hasan-aa Please refer to this doc page and let us know if it resolves your issue.

hasan-aa commented 5 months ago

Hello @AnilMaktala , What I've done already was based on the document you've provided. I've checked it once again but no luck.

I was able to go one step further following @SalmonMode suggestion like below though. But I still can not get a reference to the dynamoDB table resource. So I had to give access to all table resources for now.

import {defineBackend} from '@aws-amplify/backend';
import {auth} from './auth/resource';
import {data} from './data/resource';
import {customAuthorizer} from "./functions/resource";
import {Effect, PolicyStatement} from "aws-cdk-lib/aws-iam";

const backend = defineBackend({
    auth,
    data,
    customAuthorizer
});

let ddbReadPolicy = new PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
        "dynamodb:BatchGetItem",
        "dynamodb:GetItem",
        "dynamodb:Scan",
        "dynamodb:Query",
        "dynamodb:GetRecords"],
// @ts-ignore
    resources: ['*'] // Table ARN is needed here.
})

backend.customAuthorizer.resources.lambda.addToRolePolicy(ddbReadPolicy)
ideen1 commented 5 months ago

Are there any workarounds for this without needing to define the table directly in backend.ts? I am hoping to use the tables that are already defined in the data/resources.ts file. I as well strongly feel that giving Lambdas access to the table is a very basic requirement.

renebrandel commented 3 months ago

👋 - You can access the existing table info using this backend.data.resources.tables["Todo"].tableArn code snippet. Similarly, this should work too: backend.data.resources.tables["Todo"].grantReadData(...). The thing I'm not 100% sure yet is if it'll create a circular dependency issue.

LukaASoban commented 2 months ago

@renebrandel that does seem to cause a circular dependency. I am unable to give my lambda read access to my dynamodb table and also there doesn't seem to be a way give the lambda the generated name as an env variable

ideen1 commented 2 months ago

If you are using the table name for queries/mutations on tables, the newer versions of Amplify Gen 2 allow you to access the dataClient server side very easily. If this is your goal you can check out this page which helped me: https://docs.amplify.aws/nextjs/build-a-backend/data/customize-authz/grant-lambda-function-access-to-api/

LukaASoban commented 2 months ago

So I am using the generated table name like "UserProfile-xxjjshsidjt-dev" so that I can call dynamoDB functions directly within my lambda.

As far as I know there isn't a way to get that?

thomasoehri commented 1 month ago

I need my lambda function to access the table for my model too.

// Giving the lambda function access to the table like this works:
const permissionsStack = backend.createStack("PermissionsStack");
const createOrganizationFunctionLambda = backend.createOrganizationFunction.resources.lambda as lambda.Function;
new iam.Policy(permissionsStack, "CreateOrganizationOrganizationTablePolicy", {
  policyName: "CreateOrganizationOrganizationTablePolicy",
  roles: [createOrganizationFunctionLambda.role!],
  statements: [
    new iam.PolicyStatement({
      actions: ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:PutItem"],
      resources: [backend.data.resources.tables.Organization.tableArn],
    }),
  ],
});

// But passing the table name to the lambda function results in a circlar dependency error:
createOrganizationFunctionLambda.addEnvironment("ORGANIZATION_TABLE_NAME", backend.data.resources.tables.Organization.tableName ?? "");

It would be great if we could give our lambdas access to tables using a .access poperty on defineData or the schema and it creates the access policies and passes the table name as an environment variable to the lambda function.

Maybe similarly to the access property on storage:

access: (allow) => ({
    'MyTable*': [
      allow.resource(myLambdaFunction).to(['read', 'write', 'delete'])
    ]
  })
LukaASoban commented 1 month ago

@thomasoehri I had to revert to using the SSM Parameter store before we get more info on how to pass it without causing a circular dependency. It still works, but obviously would prefer to send the name directly.

import * as ssm from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";

type MyParameterStoreProps = {
  parameters: { name: string; value: string }[];
};

export class MyParameterStore extends Construct {
  public readonly ssmStringParameters: ssm.StringParameter[];

  constructor(scope: Construct, id: string, props: MyParameterStoreProps) {
    super(scope, id);

    this.ssmStringParameters = props.parameters.map((param) => {
      return new ssm.StringParameter(this, param.name, {
        parameterName: param.name,
        stringValue: param.value,
      });
    });
  }
}

and then in your backend.ts

// Create parameters for MyParameterStore
const parameterStoreDynamoDBGeneratedKey = `/amplify/userProfileTableName-${process.env.AWS_BRANCH}`;

const myParameterStore = new myParameterStore(
  backend.createStack("myParameterStore"),
  "myParameterStore",
  {
    parameters: [
      {
        name: parameterStoreDynamoDBGeneratedKey,
        value: backend.data.resources.tables["UserProfile"].tableName,
      },
    ],
  }
);

.
.
.

const myLambda = backend.myFunction
  .resources.lambda as Function;

myLambda.addEnvironment(
  "SSM_USER_PROFILE_TABLE_NAME_KEY",
  parameterStoreDynamoDBGeneratedKey
);

and then in your lambda

// Get the table name from the SSM environment variable
  const ssmParameterName = env.SSM_USER_PROFILE_TABLE_NAME_KEY;
  const ssmInput: GetParameterCommandInput = {
    Name: ssmParameterName,
  };
  const ssmOutput = await ssmClient.send(new GetParameterCommand(ssmInput));

  const tableName = ssmOutput.Parameter?.Value;

Hope this helps!

thomasoehri commented 1 month ago

@LukaASoban Thank you so much for your workaround!

LukaASoban commented 2 weeks ago

any update on this one? I feel like this is a critical one for Amplify Gen 2 as a whole tbh. Not being able to pass in table names to resolvers without resorting to SSM param store or something else is pretty huge don't you think?