Learnixo

AWS Serverless Full-Stack · Lesson 7 of 7

Project: Serverless Client Portal

Project: Serverless Client Portal

Build a client portal where authenticated users manage their projects, upload files, and communicate — all on AWS serverless infrastructure. No servers to manage, scales to zero, and you only pay for actual usage.


Architecture Overview

[Browser / Frontend]
        |
        | HTTPS
        ▼
[API Gateway]  ←→  [Cognito User Pool]
        |                  (auth)
        ▼
[Lambda Functions]
   ├── auth-authorizer
   ├── projects-handler
   ├── files-handler
   └── messages-handler
        |
   ┌────┴──────────┐
   ▼               ▼
[DynamoDB]       [S3 Bucket]
(data)           (files)

Project Setup with SAM

Bash
# Install AWS SAM CLI
pip install aws-sam-cli

# Create project
sam init --runtime dotnet8 --name client-portal
cd client-portal

SAM Template

YAML
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Runtime:     dotnet8
    MemorySize:  512
    Timeout:     30
    Environment:
      Variables:
        TABLE_NAME:  !Ref ClientPortalTable
        BUCKET_NAME: !Ref FilesBucket

Resources:

  # Cognito User Pool
  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: ClientPortalUsers
      AutoVerifiedAttributes: [email]
      PasswordPolicy:
        MinimumLength: 8
        RequireNumbers: true
        RequireSymbols: true

  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref UserPool
      GenerateSecret: false
      ExplicitAuthFlows:
        - ALLOW_USER_PASSWORD_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH

  # DynamoDB table  single-table design
  ClientPortalTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: ClientPortal
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - { AttributeName: PK, AttributeType: S }
        - { AttributeName: SK, AttributeType: S }
        - { AttributeName: GSI1PK, AttributeType: S }
      KeySchema:
        - { AttributeName: PK, KeyType: HASH }
        - { AttributeName: SK, KeyType: RANGE }
      GlobalSecondaryIndexes:
        - IndexName: GSI1
          KeySchema:
            - { AttributeName: GSI1PK, KeyType: HASH }
            - { AttributeName: SK,     KeyType: RANGE }
          Projection: { ProjectionType: ALL }
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true

  # S3 Bucket
  FilesBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      CorsConfiguration:
        CorsRules:
          - AllowedOrigins: ['*']
            AllowedMethods: [PUT, GET]
            AllowedHeaders: ['*']

  # Lambda Functions
  ProjectsFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: ClientPortal.Lambda::ClientPortal.Lambda.ProjectsHandler::FunctionHandler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ClientPortalTable
      Events:
        GetProjects:
          Type: Api
          Properties:
            Path:   /projects
            Method: get
            Auth:
              Authorizer: CognitoAuthorizer
        CreateProject:
          Type: Api
          Properties:
            Path:   /projects
            Method: post
            Auth:
              Authorizer: CognitoAuthorizer

  FilesFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: ClientPortal.Lambda::ClientPortal.Lambda.FilesHandler::FunctionHandler
      Policies:
        - S3CrudPolicy:
            BucketName: !Ref FilesBucket
        - DynamoDBCrudPolicy:
            TableName: !Ref ClientPortalTable
      Events:
        GetUploadUrl:
          Type: Api
          Properties:
            Path:   /files/upload-url
            Method: get
            Auth:
              Authorizer: CognitoAuthorizer

DynamoDB Single-Table Design

Entity         PK                    SK
──────────────────────────────────────────
User           USER#         PROFILE
Project        PROJECT#   METADATA
               USER#         PROJECT#   (ownership index)
File           PROJECT#   FILE#
Message        PROJECT#   MSG##
C#
// Models
public record ProjectItem
{
    public string PK        { get; init; } = "";  // PROJECT#<id>
    public string SK        { get; init; } = "";  // METADATA
    public string GSI1PK   { get; init; } = "";  // USER#<ownerId>
    public string Id        { get; init; } = "";
    public string Name      { get; init; } = "";
    public string Status    { get; init; } = "active";
    public string OwnerId   { get; init; } = "";
    public string CreatedAt { get; init; } = "";
}

Lambda Function — Projects Handler

C#
// ProjectsHandler.cs
public class ProjectsHandler
{
    private readonly IAmazonDynamoDB _dynamo = new AmazonDynamoDBClient();
    private readonly string _table = Environment.GetEnvironmentVariable("TABLE_NAME")!;

    public async Task<APIGatewayProxyResponse> FunctionHandler(
        APIGatewayProxyRequest request,
        ILambdaContext context)
    {
        var userId = request.RequestContext.Authorizer["claims"]?["sub"]?.ToString();
        if (userId is null) return Unauthorized();

        return request.HttpMethod switch
        {
            "GET"  => await GetProjectsAsync(userId),
            "POST" => await CreateProjectAsync(userId, request.Body),
            _      => MethodNotAllowed()
        };
    }

    private async Task<APIGatewayProxyResponse> GetProjectsAsync(string userId)
    {
        var client = new DynamoDBContext(_dynamo);

        // Query by owner using GSI1
        var config = new DynamoDBOperationConfig
        {
            IndexName = "GSI1",
            QueryFilter = new List<ScanCondition>
            {
                new("GSI1PK", ScanOperator.Equal, $"USER#{userId}")
            }
        };

        var projects = await client
            .QueryAsync<ProjectItem>($"USER#{userId}", config)
            .GetRemainingAsync();

        return Ok(projects);
    }

    private async Task<APIGatewayProxyResponse> CreateProjectAsync(string userId, string body)
    {
        var req = JsonSerializer.Deserialize<CreateProjectRequest>(body)!;
        var id  = Guid.NewGuid().ToString();

        var client  = new DynamoDBContext(_dynamo);
        var project = new ProjectItem
        {
            PK        = $"PROJECT#{id}",
            SK        = "METADATA",
            GSI1PK   = $"USER#{userId}",
            Id        = id,
            Name      = req.Name,
            OwnerId   = userId,
            CreatedAt = DateTimeOffset.UtcNow.ToString("O")
        };

        await client.SaveAsync(project);
        return Created(project);
    }

    private static APIGatewayProxyResponse Ok(object body) =>
        new() { StatusCode = 200, Body = JsonSerializer.Serialize(body),
                Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" } };

    private static APIGatewayProxyResponse Created(object body) =>
        new() { StatusCode = 201, Body = JsonSerializer.Serialize(body),
                Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" } };

    private static APIGatewayProxyResponse Unauthorized() =>
        new() { StatusCode = 401, Body = "{\"error\":\"Unauthorized\"}" };

    private static APIGatewayProxyResponse MethodNotAllowed() =>
        new() { StatusCode = 405, Body = "{\"error\":\"Method not allowed\"}" };
}

Files Handler — Pre-signed S3 URLs

C#
public class FilesHandler
{
    private readonly IAmazonS3 _s3 = new AmazonS3Client();
    private readonly string _bucket = Environment.GetEnvironmentVariable("BUCKET_NAME")!;

    public async Task<APIGatewayProxyResponse> FunctionHandler(
        APIGatewayProxyRequest request, ILambdaContext context)
    {
        var userId    = request.RequestContext.Authorizer["claims"]?["sub"]?.ToString();
        var projectId = request.QueryStringParameters?["projectId"];
        var fileName  = request.QueryStringParameters?["fileName"];

        if (userId is null || projectId is null || fileName is null)
            return new() { StatusCode = 400, Body = "{\"error\":\"Missing parameters\"}" };

        var key        = $"{userId}/{projectId}/{fileName}";
        var expiry     = DateTime.UtcNow.AddMinutes(15);
        var presigned  = new GetPreSignedUrlRequest
        {
            BucketName = _bucket,
            Key        = key,
            Verb       = HttpVerb.PUT,
            Expires    = expiry,
            ContentType = "application/octet-stream"
        };

        var url = await _s3.GetPreSignedURLAsync(presigned);

        return new()
        {
            StatusCode = 200,
            Body       = JsonSerializer.Serialize(new { uploadUrl = url, key }),
            Headers    = new Dictionary<string, string> { ["Content-Type"] = "application/json" }
        };
    }
}

Deploy and Test

Bash
# Build and deploy
sam build
sam deploy --guided

# Test with curl
TOKEN=$(aws cognito-idp initiate-auth \
  --auth-flow USER_PASSWORD_AUTH \
  --client-id <UserPoolClientId> \
  --auth-parameters USERNAME=test@example.com,PASSWORD=Test1234! \
  --query 'AuthenticationResult.IdToken' --output text)

curl -H "Authorization: Bearer $TOKEN" \
  https://<api-id>.execute-api.eu-west-1.amazonaws.com/Prod/projects

# Local testing
sam local start-api

Key Takeaways

  1. SAM template defines all infrastructure as code — the entire portal deploys with sam deploy
  2. Cognito handles auth complexity — user pools, tokens, and JWT validation without custom code
  3. Single-table DynamoDB design with a GSI lets you query by user or project efficiently
  4. Pre-signed S3 URLs let clients upload directly to S3 — Lambda never handles file bytes
  5. PAY_PER_REQUEST billing on DynamoDB and Lambda means zero cost when idle — perfect for portals with unpredictable traffic