AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  slash0 onboarding: accepts the pending AWS RAM share invitation(s) from the
  slash0 publisher account(s), so the shared prefix lists (pl-xxxx) become
  referenceable in this account's security groups, and (optionally) requests
  the rules-per-security-group quota increase the subscribed lists need. RAM
  invitations expire in ~12 hours; run this stack after slash0 confirms your
  share was created (the publisher re-issues expired invitations
  automatically, and updating the stack re-runs the acceptance). Deleting the
  stack changes nothing: shares are managed by the publisher and, if you
  offboard, revoked only once nothing here references them. One stack covers
  every region: invitations, the quota and the prefix lists themselves are all
  regional, so the stack works each region in the Regions parameter rather
  than only the one it was launched in.

Parameters:
  PublisherAccountIds:
    Type: CommaDelimitedList
    Description: >-
      AWS account id(s) of the slash0 publisher cell(s), as provided during
      onboarding. Only invitations from these accounts are accepted.
  Regions:
    Type: CommaDelimitedList
    Default: us-east-1,us-east-2,us-west-2
    Description: >-
      Regions to onboard. Prefix lists are regional and a security group can
      only reference one in its own region, so a region absent here cannot use
      slash0 at all. Listing a region you do not use is harmless: with no
      invitation pending there is nothing to accept, and the quota is only
      requested in regions where a slash0 share is actually live. A region
      your account has not enabled is reported and skipped rather than failing
      the stack.
  RunCounter:
    Type: String
    Default: "1"
    Description: Bump to re-run invitation acceptance and the quota request on a stack update.
  DesiredRulesPerSG:
    Type: Number
    Default: 0
    Description: >-
      Rules-per-security-group quota to request (0 skips). A referenced
      prefix list consumes its max_entries against this quota (default 60).
      slash0 computes the right value from your subscriptions during
      onboarding. Note the AWS constraint: rules-per-SG times
      SGs-per-network-interface must stay <= 1000, so request the minimum
      you need. Small increases usually auto-approve in minutes; larger ones
      open a support case.

Resources:
  OnboardRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: lambda.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: slash0-onboard
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ram:GetResourceShareInvitations
                  - ram:AcceptResourceShareInvitation
                Resource: "*"
              - Effect: Allow
                Action:
                  - servicequotas:GetServiceQuota
                  - servicequotas:RequestServiceQuotaIncrease
                Resource: "*"
              # Service Quotas creates its service-linked role on first use
              # in an account; without this the request fails with
              # DependencyAccessDeniedException.
              - Effect: Allow
                Action: iam:CreateServiceLinkedRole
                Resource: "*"
                Condition:
                  StringEquals:
                    iam:AWSServiceName: servicequotas.amazonaws.com

  OnboardFunction:
    Type: AWS::Lambda::Function
    Properties:
      Description: Accepts slash0 RAM invitations + requests SG-rules quota (CFN custom resource)
      Runtime: python3.12
      Handler: index.handler
      Timeout: 120
      Role: !GetAtt OnboardRole.Arn
      Code:
        ZipFile: |
          import boto3
          import cfnresponse

          RULES_PER_SG = ("vpc", "L-0EA8095F")

          def accept_invitations(region, publishers):
              accepted = []
              ram = boto3.client("ram", region_name=region)
              pages = ram.get_paginator("get_resource_share_invitations")
              for page in pages.paginate():
                  for inv in page["resourceShareInvitations"]:
                      if (inv["status"] == "PENDING"
                              and inv["senderAccountId"] in publishers):
                          ram.accept_resource_share_invitation(
                              resourceShareInvitationArn=inv[
                                  "resourceShareInvitationArn"])
                          accepted.append(inv["resourceShareArn"])
              return accepted

          def share_is_live(region, publishers):
              # Whether a publisher share is usable here, which stays true on
              # re-runs when nothing is left to accept. Gating the quota on
              # this keeps a region listed but unused from filing a request.
              ram = boto3.client("ram", region_name=region)
              pages = ram.get_paginator("get_resource_shares")
              for page in pages.paginate(resourceOwner="OTHER-ACCOUNTS"):
                  for share in page["resourceShares"]:
                      if (share.get("owningAccountId") in publishers
                              and share.get("status") == "ACTIVE"):
                          return True
              return False

          def request_quota(region, desired):
              # Best-effort by design: approval is asynchronous and may need
              # a support case; the publisher alarms if a list later can't
              # grow into this account.
              if desired <= 0:
                  return "skipped (DesiredRulesPerSG=0)"
              try:
                  sq = boto3.client("service-quotas", region_name=region)
                  service, code = RULES_PER_SG
                  current = sq.get_service_quota(
                      ServiceCode=service, QuotaCode=code)["Quota"]["Value"]
                  if current >= desired:
                      return "already %d >= %d" % (current, desired)
                  sq.request_service_quota_increase(
                      ServiceCode=service, QuotaCode=code,
                      DesiredValue=float(desired))
                  return "requested %d (was %d)" % (desired, current)
              except Exception as exc:
                  return "not requested: %s" % exc

          def onboard(region, publishers, desired):
              accepted = accept_invitations(region, publishers)
              if not share_is_live(region, publishers):
                  return accepted, "skipped (no slash0 share here)"
              return accepted, request_quota(region, desired)

          def handler(event, context):
              props = event.get("ResourceProperties", {})
              try:
                  data = {"AcceptedShareArns": "", "QuotaRequest": "skipped"}
                  if event["RequestType"] in ("Create", "Update"):
                      publishers = set(props.get("PublisherAccountIds", []))
                      desired = int(props.get("DesiredRulesPerSG", "0"))
                      regions = [r.strip()
                                 for r in props.get("Regions", []) if r.strip()]
                      arns, notes = [], []
                      for region in regions:
                          # A region this account cannot use (never enabled,
                          # or no permission) must not strand the others.
                          try:
                              got, note = onboard(region, publishers, desired)
                              arns += got
                              notes.append("%s: %s" % (region, note))
                          except Exception as exc:
                              notes.append("%s: FAILED %s" % (region, exc))
                      data["AcceptedShareArns"] = ",".join(arns)
                      data["QuotaRequest"] = "; ".join(notes)
                  cfnresponse.send(event, context, cfnresponse.SUCCESS, data)
              except Exception as exc:
                  cfnresponse.send(event, context, cfnresponse.FAILED,
                                   {"Error": str(exc)})

  Onboard:
    Type: Custom::EgressOnboard
    Properties:
      ServiceToken: !GetAtt OnboardFunction.Arn
      PublisherAccountIds: !Ref PublisherAccountIds
      Regions: !Ref Regions
      DesiredRulesPerSG: !Ref DesiredRulesPerSG
      # Bump to re-run acceptance/quota on stack update (a code-only change
      # does not re-trigger the custom resource).
      RunCounter: !Ref RunCounter

Outputs:
  AcceptedShareArns:
    Description: RAM share ARNs accepted by this run (empty if none were pending)
    Value: !GetAtt Onboard.AcceptedShareArns
  QuotaRequest:
    Description: >-
      Per-region result of the rules-per-SG quota request. Read this rather
      than assuming: a region reporting FAILED, or reporting no slash0 share,
      cannot reference the prefix lists.
    Value: !GetAtt Onboard.QuotaRequest
