AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Description: > sh-openswe-traces — LangSmith Bulk Export destination. Storage-only: KMS-encrypted S3 bucket, a least-privilege IAM writer for LangSmith's export job, and a Secrets Manager holder for that writer's access key. No compute — the export schedule is configured on the LangSmith side. Parameters: ExportPrefix: Type: String Default: langsmith/ Description: S3 key prefix LangSmith writes exports under (also the lifecycle scope). Resources: TracesKey: Type: AWS::KMS::Key Properties: Description: SSE-KMS CMK for sh-openswe-traces (LangSmith export archive). EnableKeyRotation: true KeyPolicy: Version: "2012-10-17" Statement: # Root-enable so IAM identity policies (below) govern access. - Sid: EnableIAMPolicies Effect: Allow Principal: AWS: !Sub "arn:aws:iam::${AWS::AccountId}:root" Action: "kms:*" Resource: "*" TracesKeyAlias: Type: AWS::KMS::Alias Properties: AliasName: alias/sh-openswe-traces TargetKeyId: !Ref TracesKey TracesBucket: Type: AWS::S3::Bucket DeletionPolicy: Retain UpdateReplacePolicy: Retain # Logging target policy must exist before S3 will accept LoggingConfiguration. DependsOn: TracesLogBucketPolicy Properties: BucketName: sh-openswe-traces PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: SSEAlgorithm: aws:kms KMSMasterKeyID: !Ref TracesKey BucketKeyEnabled: true OwnershipControls: Rules: - ObjectOwnership: BucketOwnerEnforced # Tamper recovery: the writer key is write-only (no DeleteObject / no # DeleteObjectVersion), so a malicious or buggy overwrite creates a noncurrent # version the prior bytes are recoverable from. Exports write new partitioned # keys, so noncurrent versions are rare — expire them after 90 days. VersioningConfiguration: Status: Enabled LifecycleConfiguration: Rules: - Id: archive-exports-to-deep-archive Status: Enabled Prefix: !Ref ExportPrefix Transitions: - StorageClass: DEEP_ARCHIVE TransitionInDays: 90 - Id: expire-noncurrent-versions Status: Enabled NoncurrentVersionExpiration: NoncurrentDays: 90 - Id: abort-incomplete-multipart Status: Enabled AbortIncompleteMultipartUpload: DaysAfterInitiation: 7 LoggingConfiguration: DestinationBucketName: !Ref TracesLogBucket LogFilePrefix: s3-access/ TracesBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref TracesBucket PolicyDocument: Version: "2012-10-17" Statement: - Sid: DenyInsecureTransport Effect: Deny Principal: "*" Action: "s3:*" Resource: - !GetAtt TracesBucket.Arn - !Sub "${TracesBucket.Arn}/*" Condition: Bool: "aws:SecureTransport": "false" # No SSE-header enforcement Deny. LangSmith's exporter does not send an # "aws:kms" SSE header, so a "must be aws:kms" Deny blocks its writes — and # StringNotEqualsIfExists on a Deny also blocks header-less puts (absent key # evaluates true). Encryption is instead guaranteed by the bucket DEFAULT # (SSE-KMS with our CMK, applied to every header-less put) plus S3's baseline # (no object is ever stored unencrypted). If LangSmith explicitly requests # AES256, that object lands as SSE-S3 rather than CMK — verify post-write # (head-object) and decide CMK-vs-SSE-S3 if so. # Server access logging target for TracesBucket — read attribution for the # secret-bearing archive (the org trail logs no S3 data events). SSE-S3 only: # S3 log delivery cannot write to an SSE-KMS bucket. TracesLogBucket: Type: AWS::S3::Bucket DeletionPolicy: Retain UpdateReplacePolicy: Retain Properties: BucketName: sh-openswe-traces-logs PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: SSEAlgorithm: AES256 OwnershipControls: Rules: - ObjectOwnership: BucketOwnerEnforced LifecycleConfiguration: Rules: - Id: expire-access-logs Status: Enabled ExpirationInDays: 365 TracesLogBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref TracesLogBucket PolicyDocument: Version: "2012-10-17" Statement: - Sid: S3ServerAccessLogsWrite Effect: Allow Principal: Service: logging.s3.amazonaws.com Action: "s3:PutObject" Resource: !Sub "${TracesLogBucket.Arn}/s3-access/*" Condition: ArnLike: # literal (not !GetAtt) to avoid a cycle with TracesBucket's DependsOn "aws:SourceArn": "arn:aws:s3:::sh-openswe-traces" StringEquals: "aws:SourceAccount": !Ref "AWS::AccountId" - Sid: DenyInsecureTransport Effect: Deny Principal: "*" Action: "s3:*" Resource: - !GetAtt TracesLogBucket.Arn - !Sub "${TracesLogBucket.Arn}/*" Condition: Bool: "aws:SecureTransport": "false" # No explicit UserName: an IAM name would require CAPABILITY_NAMED_IAM, but the # standard cd-sam.yaml reusable workflow deploys with CAPABILITY_IAM only. The # principal is referenced by ARN (in the secret + LangSmith config), not by name; # the tag carries the human-facing identifier. LangSmithExportUser: Type: AWS::IAM::User Properties: Tags: - Key: Name Value: sh-openswe-langsmith-export - Key: purpose Value: langsmith-bulk-export-writer Policies: - PolicyName: langsmith-export-put PolicyDocument: Version: "2012-10-17" Statement: # Bucket-wide (not prefix-scoped): LangSmith's destination-creation # validation writes a test object whose key is NOT guaranteed to be # under ExportPrefix (docs reference a /tmp path), and its documented # policy scopes PutObject to the whole bucket. This bucket is # single-purpose, so bucket-wide write is still tightly bounded. # Deliberately NO s3:GetObject / s3:DeleteObject (both optional per # LangSmith): omitting them keeps the writer write-only (no exfil, # no delete). Trade-off: LangSmith skips post-write size verification # and leaves its small test object behind (harmless). - Sid: PutExportObjects Effect: Allow Action: - "s3:PutObject" - "s3:AbortMultipartUpload" Resource: !Sub "${TracesBucket.Arn}/*" - Sid: EncryptWithBucketKey Effect: Allow Action: - "kms:GenerateDataKey" - "kms:Encrypt" # Required by S3 at CompleteMultipartUpload for SSE-KMS. Safe: the # writer has no s3:GetObject, so there is no object to decrypt/exfil. - "kms:Decrypt" Resource: !GetAtt TracesKey.Arn # Usable only through S3 — blocks a leaked key from calling kms:Decrypt # directly against arbitrary ciphertext under this CMK. Condition: StringEquals: "kms:ViaService": !Sub "s3.${AWS::Region}.amazonaws.com" # Holder only — the real access key is minted post-deploy and written in # with `aws secretsmanager put-secret-value` (see README). Never in the template. ExportKeySecret: Type: AWS::SecretsManager::Secret Properties: Name: sh-openswe/langsmith-export-s3 Description: > Access key for the sh-openswe-langsmith-export IAM user, consumed by LangSmith Bulk Export. Populated out-of-band post-deploy; rotate quarterly. # Encrypted with the aws/secretsmanager managed key — deliberately NOT the # trace CMK, so the credential and the data it protects never share a key the # writer principal holds any KMS grant on. Outputs: BucketName: Value: !Ref TracesBucket BucketArn: Value: !GetAtt TracesBucket.Arn KmsKeyArn: Value: !GetAtt TracesKey.Arn ExportUserName: Value: !Ref LangSmithExportUser ExportKeySecretName: Value: sh-openswe/langsmith-export-s3