AWSTemplateFormatVersion: "2010-09-09"

# Read-only cross-account access to one prefix of one bucket, for objectgate.
#
# What this stack creates: a single IAM role, with no user, no access key and no
# change to the bucket. The role can list one prefix and read the objects under
# it. It cannot write, delete, or see any other prefix. Deleting the stack
# deletes the role, which is the whole revocation procedure.
#
# Two properties of the role are load-bearing:
#
#   * The trust policy names objectgate's AWS account AND requires an external
#     id. Knowing the role ARN is not enough to assume it, so an ARN that leaks
#     is not access.
#   * RoleName is derived from the external id, so objectgate can compute the
#     ARN from the account id it already asked for and poll for the role to
#     appear. That is why nothing has to be pasted back from the console, and
#     also why creating this stack needs the CAPABILITY_NAMED_IAM
#     acknowledgement: the role has a chosen name rather than a generated one.
#
# The bucket ARN is built with AWS::Partition, so the template also works in the
# GovCloud and China partitions. The role ARN there does not start with
# arn:aws:, so objectgate cannot predict it; read RoleArn from the outputs and
# paste it in.

Description: >-
  objectgate read-only access: lists one prefix of one bucket and reads the
  objects under it. Creates one IAM role and nothing else.

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: What objectgate may read
        Parameters:
          - BucketName
          - Prefix
      - Label:
          default: What objectgate must present to read it
        Parameters:
          - ExternalId
    ParameterLabels:
      BucketName:
        default: Bucket name
      Prefix:
        default: Key prefix, blank for the whole bucket
      ExternalId:
        default: External id issued by objectgate

Parameters:
  BucketName:
    Type: String
    Description: >-
      The bucket to read, by name and without s3:// or a trailing slash.
    MinLength: 3
    MaxLength: 63
    # Uppercase is allowed because buckets created in us-east-1 before 2018 may
    # carry it; anything created since is lowercase.
    AllowedPattern: "^[A-Za-z0-9][A-Za-z0-9._-]{1,61}[A-Za-z0-9]$"
    ConstraintDescription: A bucket name, 3 to 63 characters.

  Prefix:
    Type: String
    Default: ""
    Description: >-
      Everything the share may reach, ending in a slash, for example
      deliveries/2026/. Leave blank to grant the whole bucket.
    MaxLength: 900
    # A wildcard typed into the prefix would widen both the list condition and
    # the object ARN rather than narrowing them, so it is rejected here instead
    # of quietly granting more than the form appears to.
    AllowedPattern: "^[^*?]*$"
    ConstraintDescription: A key prefix with no * or ? in it.

  ExternalId:
    Type: String
    Description: >-
      The identifier objectgate shows next to this connection. It belongs to
      one connection, is required on every assume-role call, and also becomes
      the role's name.
    MinLength: 16
    MaxLength: 48
    AllowedPattern: "^[A-Za-z0-9._-]{16,48}$"
    ConstraintDescription: The external id copied from objectgate, 16 to 48 characters.

Resources:
  ObjectgateReadRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "objectgate-${ExternalId}"
      Description: !Sub "Read-only access to s3://${BucketName}/${Prefix} for objectgate."
      # An hour is the ceiling objectgate's sessions need. A shorter duration
      # makes every presigned URL shorter too, because a URL signed with
      # temporary credentials dies with them.
      MaxSessionDuration: 3600
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: ObjectgateAssume
            Effect: Allow
            Principal:
              AWS: "arn:aws:iam::AWS_ACCOUNT_ID_REPLACE_AT_BOOTSTRAP:root"
            Action: "sts:AssumeRole"
            Condition:
              StringEquals:
                "sts:ExternalId": !Ref ExternalId
      Policies:
        - PolicyName: objectgate-read-one-prefix
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # HeadBucket and the region check need this; it discloses the
              # region and nothing about the contents.
              - Sid: LocateBucket
                Effect: Allow
                Action: "s3:GetBucketLocation"
                Resource: !Sub "arn:${AWS::Partition}:s3:::${BucketName}"

              # The condition is the prefix boundary: a list call that asks for
              # anything else is denied by IAM, not by application code. One
              # consequence is that HeadBucket, which sends no s3:prefix at all,
              # is denied too — objectgate treats that 403 as expected and lists
              # with the prefix instead.
              - Sid: ListPrefix
                Effect: Allow
                Action: "s3:ListBucket"
                Resource: !Sub "arn:${AWS::Partition}:s3:::${BucketName}"
                Condition:
                  StringLike:
                    "s3:prefix":
                      - !Sub "${Prefix}*"

              - Sid: ReadObjects
                Effect: Allow
                Action: "s3:GetObject"
                Resource: !Sub "arn:${AWS::Partition}:s3:::${BucketName}/${Prefix}*"
      Tags:
        - Key: managed-by
          Value: objectgate
        - Key: objectgate-bucket
          Value: !Ref BucketName

Outputs:
  RoleArn:
    Description: >-
      The role objectgate assumes. It is predicted from the account id and the
      external id, so it only has to be copied for an account outside the aws
      partition.
    Value: !GetAtt ObjectgateReadRole.Arn

  RoleName:
    Description: The role's name, derived from the external id.
    Value: !Ref ObjectgateReadRole

  ExternalId:
    Description: The external id this role requires on every assume-role call.
    Value: !Ref ExternalId

  GrantedPath:
    Description: What the role can read.
    Value: !Sub "s3://${BucketName}/${Prefix}"
