From 0dea585c1a46d3693dddb4efe88268e98f31fc3c Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 10 Jul 2026 15:45:23 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20sh-openswe-traces=20=E2=80=94=20LangSmi?= =?UTF-8?q?th=20bulk-export=20trace=20archive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage-only SAM stack (S3 + KMS CMK + write-only IAM writer + Secrets Manager holder) as the S3 destination for LangSmith Bulk Export of Open SWE traces, for long-horizon auditing and prompt improvement (Athena over Parquet). - template.yaml: versioned SSE-KMS bucket, access-log bucket, TLS-only policy, DEEP_ARCHIVE lifecycle; least-privilege LangSmith writer (bucket-wide PutObject, ViaService-scoped KMS, no read/delete). - bootstrap.yaml: dedicated OIDC deploy role + least-privilege CFN exec role so CI never touches the shared execution role. - CI/CD via reusable ci-python-sam / cd-sam workflows. IAM passed GPT-4.1 cross-review + /sh-security-review (no blocking findings). --- .github/workflows/ci.yaml | 10 ++ .github/workflows/deploy.yaml | 22 ++++ .gitignore | 5 + README.md | 194 +++++++++++++++++++++++++++ bootstrap.yaml | 202 ++++++++++++++++++++++++++++ samconfig.toml.example | 10 ++ template.yaml | 239 ++++++++++++++++++++++++++++++++++ 7 files changed, 682 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/deploy.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bootstrap.yaml create mode 100644 samconfig.toml.example create mode 100644 template.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..24d0cc6 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,10 @@ +name: CI +on: + pull_request: + branches: [main] + +jobs: + ci: + # No Python source in this repo — ruff no-ops on an empty file set and the + # reusable workflow still runs `sam validate --lint` on template.yaml. + uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@fd60e4c9041784f666ac0fdefb9bec3c7fbf5143 # main diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..b6b34e5 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,22 @@ +name: Deploy +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +concurrency: + group: deploy + cancel-in-progress: false + +jobs: + deploy: + uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@fd60e4c9041784f666ac0fdefb9bec3c7fbf5143 # main + with: + stack-name: sh-openswe-traces + cfn-role-arn: arn:aws:iam::328440206208:role/sh-openswe-traces-cfn-exec-role + secrets: + deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8a5400 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.aws-sam/ +samconfig.toml +*.pyc +__pycache__/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..4061593 --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# sh-openswe-traces + +LangSmith Bulk Export destination for the Open SWE deployment. A **storage-only** +SAM stack — no compute. LangSmith runs the export on its own schedule and writes +Parquet run/trace data into this bucket; we retain it for periodic auditing and +prompt/instruction improvement (query with Athena). + +## Why this exists + +LangSmith retains traces for ~14 days. To audit agent behavior over longer +horizons and mine it for prompt improvements, we own the data in S3 rather than +paying for extended LangSmith retention. Capture is LangSmith-native Bulk Export; +this repo is just the destination and its access controls. + +## Architecture + +``` +LangSmith (Bulk Export, scheduled LangSmith-side) + │ s3:PutObject (IAM user access key, least-privilege) + ▼ + s3://sh-openswe-traces/langsmith/… SSE-KMS (alias/sh-openswe-traces) + │ lifecycle: → DEEP_ARCHIVE @ 90d + ▼ + Athena / manual audit +``` + +| Resource | Name | Notes | +|---|---|---| +| S3 bucket | `sh-openswe-traces` | BPA all-on, SSE-KMS (default), versioned, TLS-only, `Retain` on delete | +| Log bucket | `sh-openswe-traces-logs` | S3 server access logs (SSE-S3) for read attribution; logs expire 365d | +| KMS CMK | `alias/sh-openswe-traces` | Rotation on; bucket default + writer encrypt through it | +| IAM user | auto-named (tag `sh-openswe-langsmith-export`) | Write-only LangSmith writer; `PutObject` bucket-wide, no read/delete | +| Secret | `sh-openswe/langsmith-export-s3` | Writer's access key (aws/secretsmanager key); populated post-deploy | + +The IAM user is **not** given an explicit name so the app stack deploys under +`CAPABILITY_IAM`. It is referenced by ARN. + +## Deploy + +Two stacks: + +- **`bootstrap.yaml`** → `sh-openswe-traces-bootstrap` — the CI IAM roles (OIDC deploy + role + a least-privilege CFN exec role). Deployed **once, manually, under admin** + (`CAPABILITY_NAMED_IAM`); rarely changes. Kept separate so CI never touches the shared + `github-cfn-execution-role`, which is roles-only and can't create this stack's KMS key, + secret, or IAM user. +- **`template.yaml`** → `sh-openswe-traces` — the app (buckets, CMK, writer, secret). CI + (`cd-sam.yaml`) deploys it on merge to `main`, assuming the OIDC deploy role and passing + `sh-openswe-traces-cfn-exec-role` as `--role-arn`. + +```bash +# One-time bootstrap (admin): +aws cloudformation deploy --template-file bootstrap.yaml \ + --stack-name sh-openswe-traces-bootstrap --capabilities CAPABILITY_NAMED_IAM \ + --region us-east-1 + +# App stack — CI does this on merge to main; for a local/admin deploy: +cp samconfig.toml.example samconfig.toml +sam validate --lint && sam build +sam deploy --capabilities CAPABILITY_IAM --resolve-s3 +``` + +### Wire CI (after bootstrap + repo exist) + +- Set repo secret **`AWS_DEPLOY_ROLE_ARN`** to the bootstrap `DeployRoleArn` output + (`arn:aws:iam::328440206208:role/githubdeploy-sh-openswe-traces`). +- `deploy.yaml` already passes `cfn-role-arn = sh-openswe-traces-cfn-exec-role`; the OIDC + trust is pinned to this repo's `main` ref. + +### Post-deploy: mint and store the writer access key + +The stack creates the IAM user and an empty secret; the access key is minted +out-of-band so it never lands in CloudFormation state. **Run this only on a trusted +single-user workstation, never in CI** — it handles a live credential. + +```bash +USER=$(aws cloudformation describe-stacks --stack-name sh-openswe-traces \ + --query "Stacks[0].Outputs[?OutputKey=='ExportUserName'].OutputValue" --output text) + +KEY_JSON=$(aws iam create-access-key --user-name "$USER" \ + | jq '{AccessKeyId: .AccessKey.AccessKeyId, SecretAccessKey: .AccessKey.SecretAccessKey}') + +# Pass the secret via stdin, not argv, so it never appears in the process table. +aws secretsmanager put-secret-value \ + --secret-id sh-openswe/langsmith-export-s3 \ + --secret-string file:///dev/stdin <<<"$KEY_JSON" + +unset KEY_JSON +``` + +### Configure LangSmith Bulk Export + +Driven by the LangSmith API (Plus/Enterprise only). Needs `LS_API_KEY` (LangSmith API +key) and `LS_TENANT` (workspace id). Run **after** the writer key is minted/stored — the +destination call validates by test-writing to the bucket. + +**1. Create the destination** (creds pulled from Secrets Manager, never pasted): + +```bash +CREDS=$(aws secretsmanager get-secret-value --secret-id sh-openswe/langsmith-export-s3 \ + --query SecretString --output text) +AKID=$(jq -r .AccessKeyId <<<"$CREDS"); SAK=$(jq -r .SecretAccessKey <<<"$CREDS") + +curl -sS -X POST 'https://api.smith.langchain.com/api/v1/bulk-exports/destinations' \ + -H 'Content-Type: application/json' -H "X-API-Key: $LS_API_KEY" -H "X-Tenant-Id: $LS_TENANT" \ + --data @- <' '' '' '' ) # all 4, or the subset you audit +for PID in "${PROJECTS[@]}"; do + curl -sS -X POST 'https://api.smith.langchain.com/api/v1/bulk-exports' \ + -H 'Content-Type: application/json' -H "X-API-Key: $LS_API_KEY" -H "X-Tenant-Id: $LS_TENANT" \ + --data @- </…`. + +### Monitor exports + +```bash +# Every export in the workspace: id, project, schedule, status +curl -sS 'https://api.smith.langchain.com/api/v1/bulk-exports' \ + -H "X-API-Key: $LS_API_KEY" -H "X-Tenant-Id: $LS_TENANT" \ + | jq -r '(.bulk_exports // .exports // .)[] + | [.id, + (.session_id // "all_experiments"), + (if .interval_hours then "every \(.interval_hours)h" else "one-off" end), + .status] | @tsv' | column -t +``` + +- A recurring schedule's own status is `IntervalScheduled`; the daily child exports it + spawns have `CREATED` / `RUNNING` / `COMPLETED` / `FAILED` / `CANCELLED` / `TIMEDOUT` + (child exports carry `source_bulk_export_id`). +- One export's detail: `GET /api/v1/bulk-exports/` — its per-run rows: + `GET /api/v1/bulk-exports//runs`. +- **Stop a schedule:** `PATCH /api/v1/bulk-exports/` with `{"status":"Cancelled"}`. + Already-spawned child exports must be cancelled separately, and a cancelled job can't be + restarted — create a new one. + +## Operations + +- **Rotate** the writer access key quarterly: `aws iam create-access-key`, update the + secret + LangSmith destination (`PATCH`/recreate), then delete the old key. Key age is + monitored account-wide by the Security Hub `ACCESS_KEYS_ROTATED` Config rule (flags at + 90d) — rotation keeps it compliant. +- **Audit**: point Athena at `s3://sh-openswe-traces/langsmith/` (Parquet). Objects older + than 90 days are in Deep Archive — restore before querying. +- **Read attribution**: object access is logged to `s3://sh-openswe-traces-logs/s3-access/` + (S3 server access logging). +- **Cost**: Deep Archive ≈ $1/TB/mo; expect the archive to dominate storage cost. + +## Gotchas (from IAM cross-review) + +- **Writer needs `kms:Decrypt`.** SSE-KMS multipart uploads call `kms:Decrypt` at + `CompleteMultipartUpload`; without it every multipart export fails `AccessDenied`. + It's granted and safe — the writer has no `s3:GetObject`, so nothing to exfiltrate. +- **No ACL headers.** The bucket is `BucketOwnerEnforced`; any PutObject carrying an + ACL header is rejected with `AccessControlListNotSupported` (not fixable in policy). + boto3/most SDKs send none by default — verify LangSmith's exporter likewise. +- **Downstream readers need their own CMK grant.** An Athena/Glue role reading the + Parquet needs explicit `kms:Decrypt` (+ `kms:GenerateDataKey`) on + `alias/sh-openswe-traces` — see `reference_kms_cmk_grant_migration`. + +## Security + +Traces can contain source code and secrets surfaced in tool I/O. Controls: SSE-KMS at +rest (customer-managed CMK, bucket default), versioning (overwrite recovery), Block Public +Access, TLS-only bucket policy, a write-only least-privilege writer (bucket-wide `PutObject` ++ `kms:Decrypt` gated to `kms:ViaService=s3`, no read/delete), the credential secret on a +separate managed key, and S3 access logging. CI deploys via a dedicated least-privilege +exec role (see `bootstrap.yaml`), not the shared execution role. + +The IAM surface passed GPT-4.1 cross-review and a `/sh-security-review` fan-out + +proof-or-kill verifier (no blocking findings). Deferred, non-blocking: CloudTrail S3 +data-events (org trail carries none; access logging covers attribution for now). diff --git a/bootstrap.yaml b/bootstrap.yaml new file mode 100644 index 0000000..88cc0f5 --- /dev/null +++ b/bootstrap.yaml @@ -0,0 +1,202 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + Bootstrap IAM for the sh-openswe-traces app stack. Deployed ONCE, manually, under + admin (CAPABILITY_NAMED_IAM). Creates two named roles so CI never touches the shared + github-cfn-execution-role: + - DeployRole (githubdeploy-sh-openswe-traces): assumed by this repo's GitHub + Actions via OIDC (main branch only); can drive CloudFormation for THIS stack and + pass the exec role. + - ExecRole (sh-openswe-traces-cfn-exec-role): assumed by CloudFormation to create + the app stack's resources; least-privilege to exactly this stack's resource set. + +Resources: + ExecRole: + Type: AWS::IAM::Role + Properties: + RoleName: sh-openswe-traces-cfn-exec-role + Description: CloudFormation execution role for the sh-openswe-traces app stack. + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Action: sts:AssumeRole + Condition: + StringEquals: + "aws:SourceAccount": !Ref "AWS::AccountId" + Policies: + - PolicyName: manage-sh-openswe-traces-resources + PolicyDocument: + Version: "2012-10-17" + Statement: + # Defense-in-depth: this role can create sh-openswe-traces-* users, so + # explicitly forbid the actions that would turn one into a usable/escalated + # principal (credentials, console login, extra policies, boundary removal). + - Sid: DenyUserCredentialAndEscalation + Effect: Deny + Action: + - "iam:CreateAccessKey" + - "iam:CreateLoginProfile" + - "iam:UpdateLoginProfile" + - "iam:AttachUserPolicy" + - "iam:CreateServiceSpecificCredential" + - "iam:PutUserPermissionsBoundary" + - "iam:DeleteUserPermissionsBoundary" + Resource: "*" + - Sid: Buckets + Effect: Allow + Action: + - "s3:CreateBucket" + - "s3:DeleteBucket" + - "s3:PutBucketPolicy" + - "s3:DeleteBucketPolicy" + - "s3:GetBucketPolicy" + - "s3:PutEncryptionConfiguration" + - "s3:GetEncryptionConfiguration" + - "s3:PutBucketVersioning" + - "s3:GetBucketVersioning" + - "s3:PutBucketPublicAccessBlock" + - "s3:GetBucketPublicAccessBlock" + - "s3:PutBucketOwnershipControls" + - "s3:GetBucketOwnershipControls" + - "s3:PutLifecycleConfiguration" + - "s3:GetLifecycleConfiguration" + - "s3:PutBucketLogging" + - "s3:GetBucketLogging" + - "s3:PutBucketTagging" + - "s3:GetBucketTagging" + - "s3:GetBucketLocation" + - "s3:GetBucketAcl" + Resource: + - "arn:aws:s3:::sh-openswe-traces" + - "arn:aws:s3:::sh-openswe-traces-logs" + # CreateKey/CreateAlias cannot be resource-scoped (the key does not yet + # exist). Only CloudFormation can assume this role, and only to deploy this + # stack, so the blast radius is bounded to this stack's deployments. + - Sid: Kms + Effect: Allow + Action: + - "kms:CreateKey" + - "kms:CreateAlias" + - "kms:DeleteAlias" + - "kms:UpdateAlias" + - "kms:PutKeyPolicy" + - "kms:GetKeyPolicy" + - "kms:EnableKeyRotation" + - "kms:DisableKeyRotation" + - "kms:GetKeyRotationStatus" + - "kms:DescribeKey" + - "kms:TagResource" + - "kms:UntagResource" + - "kms:ListResourceTags" + - "kms:ScheduleKeyDeletion" + - "kms:EnableKey" + Resource: "*" + - Sid: Secret + Effect: Allow + Action: + - "secretsmanager:CreateSecret" + - "secretsmanager:DeleteSecret" + - "secretsmanager:DescribeSecret" + - "secretsmanager:UpdateSecret" + - "secretsmanager:TagResource" + - "secretsmanager:UntagResource" + - "secretsmanager:GetResourcePolicy" + - "secretsmanager:PutResourcePolicy" + Resource: !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:sh-openswe/*" + - Sid: ExportUser + Effect: Allow + Action: + - "iam:CreateUser" + - "iam:DeleteUser" + - "iam:GetUser" + - "iam:TagUser" + - "iam:UntagUser" + - "iam:PutUserPolicy" + - "iam:DeleteUserPolicy" + - "iam:GetUserPolicy" + - "iam:ListUserPolicies" + - "iam:ListUserTags" + - "iam:ListAttachedUserPolicies" + - "iam:ListGroupsForUser" + Resource: !Sub "arn:aws:iam::${AWS::AccountId}:user/sh-openswe-traces-*" + + DeployRole: + Type: AWS::IAM::Role + Properties: + RoleName: githubdeploy-sh-openswe-traces + Description: GitHub Actions OIDC deploy role for the sh-openswe-traces app stack. + MaxSessionDuration: 3600 + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Federated: !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com" + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + "token.actions.githubusercontent.com:sub": "repo:Sea-Haven-Industries/sh-openswe-traces:ref:refs/heads/main" + Policies: + - PolicyName: deploy-sh-openswe-traces + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: AppStack + Effect: Allow + Action: + - "cloudformation:CreateChangeSet" + - "cloudformation:ExecuteChangeSet" + - "cloudformation:DescribeChangeSet" + - "cloudformation:DeleteChangeSet" + - "cloudformation:CreateStack" + - "cloudformation:UpdateStack" + - "cloudformation:DescribeStacks" + - "cloudformation:DescribeStackEvents" + - "cloudformation:DescribeStackResource" + - "cloudformation:DescribeStackResources" + - "cloudformation:ListStackResources" + - "cloudformation:GetTemplate" + - "cloudformation:GetTemplateSummary" + Resource: + - !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/sh-openswe-traces/*" + - !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:changeSet/*/*" + # SAM's --resolve-s3 looks up (does not recreate) the pre-existing managed + # artifact stack + bucket. + - Sid: SamManagedStackRead + Effect: Allow + Action: + - "cloudformation:DescribeStacks" + Resource: !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/aws-sam-cli-managed-default/*" + - Sid: SamArtifactBucket + Effect: Allow + Action: + - "s3:GetObject" + - "s3:PutObject" + - "s3:GetBucketLocation" + - "s3:ListBucket" + Resource: + - "arn:aws:s3:::aws-sam-cli-managed-default-*" + - "arn:aws:s3:::aws-sam-cli-managed-default-*/*" + - Sid: CfnValidate + Effect: Allow + Action: + - "cloudformation:ValidateTemplate" + Resource: "*" + - Sid: PassExecRoleToCfn + Effect: Allow + Action: "iam:PassRole" + Resource: !GetAtt ExecRole.Arn + Condition: + StringEquals: + "iam:PassedToService": cloudformation.amazonaws.com + +Outputs: + DeployRoleArn: + Description: Set as the repo secret AWS_DEPLOY_ROLE_ARN. + Value: !GetAtt DeployRole.Arn + ExecRoleArn: + Description: Set as cfn-role-arn in .github/workflows/deploy.yaml. + Value: !GetAtt ExecRole.Arn diff --git a/samconfig.toml.example b/samconfig.toml.example new file mode 100644 index 0000000..33fbc5f --- /dev/null +++ b/samconfig.toml.example @@ -0,0 +1,10 @@ +version = 0.1 + +[default.deploy.parameters] +stack_name = "sh-openswe-traces" +region = "us-east-1" +capabilities = "CAPABILITY_IAM" +resolve_s3 = true +confirm_changeset = true +# Optional: override the export prefix +# parameter_overrides = "ExportPrefix=langsmith/" diff --git a/template.yaml b/template.yaml new file mode 100644 index 0000000..ce01d0d --- /dev/null +++ b/template.yaml @@ -0,0 +1,239 @@ +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