Skip to content

AOSS OpenSearch Serverless Development using AWS CDK

Updated: at 07:05 PM
---
title: Architecture Diagram
config:
    theme: base
---
graph LR
    L@{ img: "https://pujan.net/assets/aws/lambda.svg", pos: "t", w: 60, h: 60, constraint: "on" }
    OS@{ img: "https://pujan.net/assets/aws/opensearch.svg", pos: "t", w: 60, h: 60, constraint: "on" }
    VPCE@{ img: "https://pujan.net/assets/aws/vpce.svg", label: "VPC Endpoint", pos: "t", w: 60, h: 60, constraint: "on" }
    
    subgraph "Security Group"
        L(Lambda in VPC)
    end
    
    subgraph "Security Group"
        L e1@--> VPCE
        VPCE e2@--> OS(Opensearch Serverless)
    end
    
    e1@{ animation: slow }
    e2@{ animation: slow }

OpenSearch Serverless

Amazon OpenSearch Serverless (AOSS) is an on-demand serverless option for OpenSearch Service. It eliminates the need to provision, configure, or tune clusters, allowing you to focus on searching and analyzing large volumes of data without managing the underlying infrastructure.

Basic Terminologies of AOSS

Terms

Collection

An AOSS collection is a group of OpenSearch indices that work together to support a specific workload or use case. Collections have the some kind of high-capacity, distributed, and highly available storage volume that’s used by provisioned OpenSearch Service domains. Data is encrypted in transit within a collection.

Choosing a collection type: AOSS supports three primary collection types:

  1. Time series – The log analytics segment that focuses on analyzing large volumes of semi-structured, machine-generated data in real-time for operational, user behavior, and business insights.
  2. Search – Full-text search that powers applications in your internal networks (e.g. CMS) and internet-facing applications, such as ecommerce website search and content search.
  3. Vector search – Semantic search on vector embeddings that simplifies vector data management and powers machine learning (ML) augmented search experiences and generative AI applications, such as chatbots, personal assistants, and fraud detection.

Network Policy

Network policies let you manage many collections at scale by automatically assigning network access settings to collections that match the rules defined in the policy.

In a network policy, you specify a series of rules. These rule define access permissions to collection endpoints and OpenSearch Dashboards endpoints. Each rule consists of an access type (public or VPC) and a resource type (collection and/or OpenSearch Dashboards endpoint). For each resource type (collection and dashboard), you specify a series of rules that define which collection(s) the policy will apply to.

Data Access Policy

Data access policies define how your users access the data within your collections. Data access policies help you manage collections at scale by automatically assigning access permissions to collections and indexes that match a specific pattern. Multiple policies can apply to a single resource.

Implementation using CDK

The above diagram illustrates a straightforward scenario. A Lambda within a VPC can connect to OpenSearch using a VPC Endpoint, but this is not needed if the Lambda is outside the VPC.

Stack

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { CDKContext } from "../types";
import { VpcConstruct } from "./constructs/vpc";
import { IamConstruct } from "./constructs/iam";
import { LambdaConstruct } from "./constructs/lambda";
import { OpenSearchConstruct } from "./constructs/opensearch";

export class Stack extends cdk.Stack {
  constructor(
    scope: Construct,
    id: string,
    ctx: CDKContext,
    props?: cdk.StackProps
  ) {
    super(scope, id, props);

    const vpcConstruct = new VpcConstruct(this, "aoss-vpc", ctx);
    const executorRole = new IamConstruct(this, "aoss-iam-role", ctx);
    const aossConstruct = new OpenSearchConstruct(
      this,
      "OpenSearchConstruct",
      ctx,
      { vpcConstruct, executorRole: executorRole.role }
    );
    new LambdaConstruct(this, "aoss-lambda", { vpcConstruct, aossConstruct });
  }
}

VPC

import { Construct } from "constructs";
import { IVpc, Port, SecurityGroup, Vpc } from "aws-cdk-lib/aws-ec2";
import { CDKContext } from "../../types";

export class VpcConstruct extends Construct {
  public readonly vpc: IVpc;
  public readonly vpcEndpointSg: SecurityGroup;
  public readonly lambdaSg: SecurityGroup;

  constructor(scope: Construct, id: string, ctx: CDKContext) {
    super(scope, id);

    this.vpc = Vpc.fromLookup(this, "vpc", {
      vpcId: ctx.resources.vpc.vpcId,
      region: ctx.region,
      ownerAccountId: ctx.accountId,
    });

    this.vpcEndpointSg = new SecurityGroup(this, "aoss-vpc-endpoint-sg", {
      description: "aoss-vpc-endpoint-sg",
      securityGroupName: "aoss-vpc-endpoint-sg",
      vpc: this.vpc,
    });
    this.lambdaSg = new SecurityGroup(this, "aoss-lambda-sg", {
      description: "aoss-lambda-sg",
      securityGroupName: "aoss-lambda-sg",
      vpc: this.vpc,
      allowAllOutbound: false,
    });

    this.lambdaSg.addEgressRule(this.vpcEndpointSg, Port.allTraffic());
    this.vpcEndpointSg.addIngressRule(this.lambdaSg, Port.allTraffic());
    const eksClusterSg = SecurityGroup.fromLookupById(
      this,
      "eks-cluster-sg",
      ctx.resources.eks.clusterSecurityGroupId
    );
    this.vpcEndpointSg.addIngressRule(eksClusterSg, Port.allTraffic());
  }
}

Lambda

import {
  Effect,
  ManagedPolicy,
  PolicyStatement,
  Role,
  ServicePrincipal,
} from "aws-cdk-lib/aws-iam";
import {
  NodejsFunction,
  NodejsFunctionProps,
} from "aws-cdk-lib/aws-lambda-nodejs";
import { join } from "path";
import { RetentionDays } from "aws-cdk-lib/aws-logs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";
import { VpcConstruct } from "./vpc";
import { OpenSearchConstruct } from "./opensearch";
import { Aws } from "aws-cdk-lib";

interface LambdaConstructProps {
  vpcConstruct: VpcConstruct;
  aossConstruct: OpenSearchConstruct;
}

export class LambdaConstruct extends Construct {
  public readonly nodejsFunction: NodejsFunction;

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

    this.nodejsFunction = new NodejsFunction(
      this,
      "aossLambdaApiExecutorFunction",
      {
        entry: join(__dirname, "..", "..", "handlers", "aoss-lambda.ts"),
        functionName: "aoss-lambda-api-executor",
        role: this.lambdaRole(),
        ...this.nodeJsFunctionProps,
        logRetention: RetentionDays.ONE_WEEK,
        environment: {
          COLLECTION_ENDPOINT:
            this.props.aossConstruct.collection.attrCollectionEndpoint,
        },
        vpc: this.props.vpcConstruct.vpc,
        vpcSubnets: { subnets: this.props.vpcConstruct.vpc.privateSubnets },
        securityGroups: [this.props.vpcConstruct.lambdaSg],
      }
    );
  }

  private lambdaRole() {
    return new Role(this, "aossLambdaApiExecutorRole", {
      roleName: `aoss-lambda-api-executor-role`,
      assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AWSLambdaBasicExecutionRole"
        ),
        ManagedPolicy.fromAwsManagedPolicyName(
          "AmazonOpenSearchServiceFullAccess"
        ),
        new ManagedPolicy(this, "lambda-basic-execution-policy", {
          managedPolicyName: "lambda-basic-execution-policy",
          statements: [
            new PolicyStatement({
              effect: Effect.ALLOW,
              actions: ["logs:CreateLogGroup"],
              resources: [`arn:aws:logs::${Aws.ACCOUNT_ID}:*`],
            }),
            new PolicyStatement({
              effect: Effect.ALLOW,
              actions: ["logs:CreateLogStream", "logs:PutLogEvents"],
              resources: [
                `arn:aws:logs::${Aws.ACCOUNT_ID}:log-group:/aws/lambda/aoss-lambda-api-executor:*`,
              ],
            }),
          ],
        }),
        new ManagedPolicy(this, "vpc-access-execution-policy", {
          managedPolicyName: "vpc-access-execution-policy",
          statements: [
            new PolicyStatement({
              effect: Effect.ALLOW,
              actions: [
                "ec2:CreateNetworkInterface",
                "ec2:DeleteNetworkInterface",
                "ec2:DescribeNetworkInterfaces",
              ],
              resources: ["*"],
            }),
          ],
        }),
        new ManagedPolicy(this, "aoss-full-api-access", {
          managedPolicyName: "aoss-full-api-access",
          statements: [
            new PolicyStatement({
              effect: Effect.ALLOW,
              actions: ["aoss:BatchGetCollection", "aoss:APIAccessAll"],
              resources: ["*"],
            }),
          ],
        }),
      ],
    });
  }

  private nodeJsFunctionProps: NodejsFunctionProps = {
    bundling: { externalModules: ["aws-sdk"] },
    runtime: lambda.Runtime.NODEJS_18_X,
  };
}

IAM Role

import { Construct } from "constructs";
import {
  AccountPrincipal,
  Effect,
  FederatedPrincipal,
  ManagedPolicy,
  PolicyStatement,
  Role,
  ServicePrincipal,
} from "aws-cdk-lib/aws-iam";
import { CDKContext } from "../../types";

export class IamConstruct extends Construct {
  public readonly role: Role;

  constructor(scope: Construct, id: string, ctx: CDKContext) {
    super(scope, id);

    this.role = new Role(this, "aossExecutorRole", {
      roleName: `aoss-executor-role`,
      assumedBy: new ServicePrincipal("eks.amazonaws.com"),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName(
          "AmazonOpenSearchServiceFullAccess"
        ),
        new ManagedPolicy(this, "vpc-access-policy", {
          managedPolicyName: "eks-vpc-access-policy",
          statements: [
            new PolicyStatement({
              effect: Effect.ALLOW,
              actions: [
                "ec2:CreateNetworkInterface",
                "ec2:DeleteNetworkInterface",
                "ec2:DescribeNetworkInterfaces",
              ],
              resources: ["*"],
            }),
          ],
        }),
        new ManagedPolicy(this, "eks-aoss-api-access1", {
          managedPolicyName: "eks-aoss-api-access1",
          statements: [
            new PolicyStatement({
              effect: Effect.ALLOW,
              actions: ["aoss:BatchGetCollection", "aoss:APIAccessAll"],
              resources: ["*"],
            }),
          ],
        }),
      ],
    });

    this.role.assumeRolePolicy?.addStatements(
      new PolicyStatement({
        effect: Effect.ALLOW,
        principals: [new AccountPrincipal(ctx.accountId)],
        actions: ["sts:AssumeRole"],
      }),
      new PolicyStatement({
        effect: Effect.ALLOW,
        principals: [
          new FederatedPrincipal(
            `arn:aws:iam::${ctx.accountId}:oidc-provider/oidc.eks.${ctx.region}.amazonaws.com/id/${ctx.resources.eks.oidcId}`
          ),
        ],
        actions: ["sts:AssumeRoleWithWebIdentity"],
        conditions: {
          StringEquals: {
            [`oidc.eks.${ctx.region}.amazonaws.com/id/${ctx.resources.eks.oidcId}:sub`]: `system:serviceaccount:${ctx.resources.eks.namespace}:${ctx.resources.eks.serviceAccountName}`,
          },
        },
      })
    );
  }
}

OpenSearch Serverless

import { Construct } from "constructs";
import {
  CfnAccessPolicy,
  CfnCollection,
  CfnSecurityPolicy,
  CfnVpcEndpoint,
} from "aws-cdk-lib/aws-opensearchserverless";
import { VpcConstruct } from "./vpc";
import { Role } from "aws-cdk-lib/aws-iam";
import { CDKContext } from "../../types";

interface OpenSearchConstructProps {
  vpcConstruct: VpcConstruct;
  executorRole: Role;
}

export class OpenSearchConstruct extends Construct {
  public readonly searchDomain: string;
  public readonly collection: CfnCollection;

  constructor(
    scope: Construct,
    id: string,
    ctx: CDKContext,
    props: OpenSearchConstructProps
  ) {
    super(scope, id);

    this.collection = new CfnCollection(this, "MySearchCollection", {
      name: "my-collection",
      type: "SEARCH",
    });

    const encPolicy = new CfnSecurityPolicy(this, "MySecurityPolicy", {
      name: "my-collection-policy",
      policy:
        '{"Rules":[{"ResourceType":"collection","Resource":["collection/my-collection"]}],"AWSOwnedKey":true}',
      type: "encryption",
    });
    this.collection.addDependency(encPolicy);

    const vpcEndpoint = new CfnVpcEndpoint(this, "oss-vpce", {
      name: "oss-vpce",
      securityGroupIds: [props.vpcConstruct.vpcEndpointSg.securityGroupId],
      subnetIds: [
        ...props.vpcConstruct.vpc.privateSubnets.map(i => i.subnetId),
      ], // either private or isolated
      vpcId: props.vpcConstruct.vpc.vpcId,
    });

    // Network policy is required so that the dashboard can be viewed!
    const netPolicy = new CfnSecurityPolicy(this, "ProductNetworkPolicy", {
      name: "my-network-policy",
      policy: JSON.stringify([
        {
          Rules: [
            {
              ResourceType: "collection",
              Resource: ["collection/my-collection"],
            },
            {
              ResourceType: "dashboard",
              Resource: ["collection/my-collection"],
            },
          ],
          AllowFromPublic: false,
          SourceVPCEs: [vpcEndpoint.ref],
        },
        {
          Rules: [
            {
              ResourceType: "collection",
              Resource: ["collection/my-collection"],
            },
            {
              ResourceType: "dashboard",
              Resource: ["collection/my-collection"],
            },
          ],
          AllowFromPublic: true,
        },
      ]),
      type: "network",
    });
    this.collection.addDependency(netPolicy);

    // Data collection policy
    const dataAccessPolicy = new CfnAccessPolicy(this, "MyCfnAccessPolicy", {
      name: "my-data-access-policy",
      type: "data",
      policy: JSON.stringify([
        {
          Rules: [
            {
              Resource: ["collection/my-collection"],
              Permission: [
                "aoss:CreateCollectionItems",
                "aoss:DeleteCollectionItems",
                "aoss:UpdateCollectionItems",
                "aoss:DescribeCollectionItems",
              ],
              ResourceType: "collection",
            },
            {
              Resource: ["index/my-collection/*"],
              Permission: [
                "aoss:CreateIndex",
                "aoss:DeleteIndex",
                "aoss:UpdateIndex",
                "aoss:DescribeIndex",
                "aoss:ReadDocument",
                "aoss:WriteDocument",
              ], // TODO: Update this as per the requirement
              ResourceType: "index",
            },
          ],
          Principal: [
            `arn:aws:iam::${ctx.accountId}:role/aoss-lambda-api-executor-role`,
            props.executorRole.roleArn,
            `arn:aws:iam::${ctx.accountId}:role/AnyOtherRole`,
          ],
        },
      ]),
    });
    this.collection.addDependency(dataAccessPolicy);
    this.searchDomain = this.collection.attrCollectionEndpoint;
  }
}
If you enjoy the content, please consider supporting work