From 0ccb56a0fd1a7da2bf5f8b8daa0ef6df52ebe9b3 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Thu, 18 Jun 2026 03:19:04 -0400 Subject: [PATCH 1/2] Add profile asset storage baseline --- .github/workflows/terraform.yml | 22 ++ apps/web/package.json | 1 + .../src/lib/server/profile-asset-storage.ts | 29 ++- docs/README.md | 2 +- docs/deployment/aws-baseline.md | 14 +- docs/developers/self-hosting-and-iac.md | 4 +- docs/engineering/service-map.md | 2 +- docs/planning/docs-strategy.md | 2 +- docs/planning/engineering-strategy.md | 4 +- infra/terraform/README.md | 7 +- .../profile-assets/.terraform.lock.hcl | 68 ++++++ infra/terraform/profile-assets/README.md | 55 +++++ infra/terraform/profile-assets/main.tf | 195 ++++++++++++++++++ infra/terraform/profile-assets/outputs.tf | 19 ++ .../profile-assets/terraform.tfvars.example | 9 + infra/terraform/profile-assets/variables.tf | 59 ++++++ infra/terraform/profile-assets/versions.tf | 38 ++++ infra/terraform/state-mgmt/README.md | 4 +- infra/terraform/state-mgmt/main.tf | 83 +++++++- infra/terraform/state-mgmt/variables.tf | 18 ++ infra/terraform/vercel/README.md | 4 +- pnpm-lock.yaml | 100 +++++++-- 22 files changed, 701 insertions(+), 38 deletions(-) create mode 100644 infra/terraform/profile-assets/.terraform.lock.hcl create mode 100644 infra/terraform/profile-assets/README.md create mode 100644 infra/terraform/profile-assets/main.tf create mode 100644 infra/terraform/profile-assets/outputs.tf create mode 100644 infra/terraform/profile-assets/terraform.tfvars.example create mode 100644 infra/terraform/profile-assets/variables.tf create mode 100644 infra/terraform/profile-assets/versions.tf diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 315cb6c..5574a89 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -24,6 +24,7 @@ on: - state-mgmt - docs-site - web-domains + - profile-assets - ses - posthog - vercel @@ -52,6 +53,7 @@ env: TERRAFORM_SES_DOMAIN_NAME: ${{ vars.TERRAFORM_SES_DOMAIN_NAME }} TERRAFORM_SES_FROM_EMAIL: ${{ vars.TERRAFORM_SES_FROM_EMAIL }} TERRAFORM_ROUTE53_ZONE_ID: ${{ vars.TERRAFORM_ROUTE53_ZONE_ID }} + TERRAFORM_PROFILE_ASSETS_ENABLED: ${{ vars.TERRAFORM_PROFILE_ASSETS_ENABLED }} jobs: terraform-fmt: @@ -95,6 +97,7 @@ jobs: requires_posthog: "false" requires_posthog_public_key: "false" requires_ses_domain: "false" + requires_profile_assets_enabled: "false" - name: docs-site path: infra/terraform/docs-site backend: "true" @@ -106,6 +109,7 @@ jobs: requires_posthog: "false" requires_posthog_public_key: "false" requires_ses_domain: "false" + requires_profile_assets_enabled: "false" - name: web-domains path: infra/terraform/web-domains backend: "true" @@ -117,6 +121,19 @@ jobs: requires_posthog: "false" requires_posthog_public_key: "false" requires_ses_domain: "false" + requires_profile_assets_enabled: "false" + - name: profile-assets + path: infra/terraform/profile-assets + backend: "true" + plan: "true" + auto_apply: "true" + manual_apply: "true" + requires_aws: "true" + requires_vercel: "true" + requires_posthog: "false" + requires_posthog_public_key: "false" + requires_ses_domain: "false" + requires_profile_assets_enabled: "true" - name: ses path: infra/terraform/ses backend: "true" @@ -128,6 +145,7 @@ jobs: requires_posthog: "false" requires_posthog_public_key: "false" requires_ses_domain: "true" + requires_profile_assets_enabled: "false" - name: posthog path: infra/terraform/posthog backend: "true" @@ -139,6 +157,7 @@ jobs: requires_posthog: "true" requires_posthog_public_key: "false" requires_ses_domain: "false" + requires_profile_assets_enabled: "false" - name: vercel path: infra/terraform/vercel backend: "true" @@ -150,6 +169,7 @@ jobs: requires_posthog: "false" requires_posthog_public_key: "true" requires_ses_domain: "false" + requires_profile_assets_enabled: "false" - name: restream-worker path: infra/terraform/restream-worker backend: "true" @@ -161,6 +181,7 @@ jobs: requires_posthog: "false" requires_posthog_public_key: "false" requires_ses_domain: "false" + requires_profile_assets_enabled: "false" steps: - name: Checkout @@ -229,6 +250,7 @@ jobs: if [ "${{ matrix.stack.requires_posthog }}" = "true" ] && [ -z "$POSTHOG_API_KEY" ]; then missing+=("POSTHOG_API_KEY"); fi if [ "${{ matrix.stack.requires_posthog_public_key }}" = "true" ] && [ -z "$TERRAFORM_POSTHOG_PUBLIC_KEY" ]; then missing+=("TERRAFORM_POSTHOG_PUBLIC_KEY"); fi if [ "${{ matrix.stack.requires_ses_domain }}" = "true" ] && [ -z "$TERRAFORM_SES_DOMAIN_NAME" ]; then missing+=("TERRAFORM_SES_DOMAIN_NAME"); fi + if [ "${{ matrix.stack.requires_profile_assets_enabled }}" = "true" ] && [ "$TERRAFORM_PROFILE_ASSETS_ENABLED" != "true" ]; then missing+=("TERRAFORM_PROFILE_ASSETS_ENABLED=true"); fi if [ "${#missing[@]}" -gt 0 ]; then echo "plan_enabled=false" >> "$GITHUB_OUTPUT" diff --git a/apps/web/package.json b/apps/web/package.json index 4558784..3fcbe13 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1068.0", "@convex-dev/auth": "^0.0.92", + "@vercel/oidc-aws-credentials-provider": "^3.1.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "convex": "^1.32.0", diff --git a/apps/web/src/lib/server/profile-asset-storage.ts b/apps/web/src/lib/server/profile-asset-storage.ts index a3847b3..81758a5 100644 --- a/apps/web/src/lib/server/profile-asset-storage.ts +++ b/apps/web/src/lib/server/profile-asset-storage.ts @@ -1,4 +1,5 @@ import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { awsCredentialsProvider } from "@vercel/oidc-aws-credentials-provider"; type StorageConfig = { bucket: string; @@ -13,6 +14,17 @@ type StoredObject = { const cachedClients = new Map(); +function isVercelRuntime(): boolean { + return process.env.VERCEL === "1" || process.env.VERCEL === "true" || Boolean(process.env.VERCEL_OIDC_TOKEN); +} + +function vercelOidcRoleArn(): string | undefined { + const roleArn = process.env.VRDEX_PROFILE_ASSET_ROLE_ARN ?? (isVercelRuntime() ? process.env.AWS_ROLE_ARN : undefined); + const normalized = roleArn?.trim(); + + return normalized ? normalized : undefined; +} + function storageConfig(): StorageConfig | null { const bucket = process.env.VRDEX_PROFILE_ASSET_BUCKET ?? process.env.VRDEX_ASSET_BUCKET; const region = @@ -25,15 +37,20 @@ function storageConfig(): StorageConfig | null { return { bucket, region }; } -function s3Client(region: string): S3Client { - const cachedClient = cachedClients.get(region); +function s3Client(config: StorageConfig): S3Client { + const roleArn = vercelOidcRoleArn(); + const cacheKey = `${config.region}:${roleArn ?? "default"}`; + const cachedClient = cachedClients.get(cacheKey); if (cachedClient !== undefined) { return cachedClient; } - const client = new S3Client({ region }); - cachedClients.set(region, client); + const client = new S3Client({ + region: config.region, + ...(roleArn !== undefined ? { credentials: awsCredentialsProvider({ roleArn }) } : {}), + }); + cachedClients.set(cacheKey, client); return client; } @@ -53,7 +70,7 @@ export async function putProfileAssetObject(input: { throw new Error("Profile asset storage is not configured."); } - await s3Client(config.region).send( + await s3Client(config).send( new PutObjectCommand({ Bucket: config.bucket, Key: input.storageKey, @@ -72,7 +89,7 @@ export async function getProfileAssetObject(storageKey: string): Promise// +``` diff --git a/infra/terraform/profile-assets/main.tf b/infra/terraform/profile-assets/main.tf new file mode 100644 index 0000000..47f6625 --- /dev/null +++ b/infra/terraform/profile-assets/main.tf @@ -0,0 +1,195 @@ +data "aws_caller_identity" "current" {} + +data "vercel_project" "web" { + name = var.vercel_project_name + team_id = var.vercel_team_id +} + +locals { + asset_bucket_name = var.asset_bucket_name != null ? var.asset_bucket_name : "vrdex-profile-assets-${data.aws_caller_identity.current.account_id}" + object_prefix = "profile-assets/" + + vercel_oidc_issuer_path = "oidc.vercel.com/${var.vercel_team_slug}" + vercel_oidc_issuer_url = "https://${local.vercel_oidc_issuer_path}" + vercel_oidc_audience = "https://vercel.com/${var.vercel_team_slug}" + vercel_oidc_subjects = [ + for environment in var.vercel_runtime_environments : "owner:${var.vercel_team_slug}:project:${var.vercel_project_name}:environment:${environment}" + ] + + runtime_env_comment = "VRDex private profile asset storage managed by infra/terraform/profile-assets." + runtime_env_values = { + VRDEX_PROFILE_ASSET_BUCKET = aws_s3_bucket.profile_assets.bucket + VRDEX_PROFILE_ASSET_REGION = var.aws_region + VRDEX_PROFILE_ASSET_ROLE_ARN = aws_iam_role.vercel_profile_assets.arn + } + + standard_vercel_targets = var.manage_production_environment ? { production = ["production"] } : {} + + tags = merge( + { + Project = "VRDex" + ManagedBy = "Terraform" + Component = "profile-assets" + }, + var.tags, + ) +} + +data "tls_certificate" "vercel_oidc" { + url = local.vercel_oidc_issuer_url +} + +resource "aws_s3_bucket" "profile_assets" { + bucket = local.asset_bucket_name + tags = local.tags +} + +resource "aws_s3_bucket_public_access_block" "profile_assets" { + bucket = aws_s3_bucket.profile_assets.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "profile_assets" { + bucket = aws_s3_bucket.profile_assets.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "profile_assets" { + bucket = aws_s3_bucket.profile_assets.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +data "aws_iam_policy_document" "profile_assets_bucket" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + + resources = [ + aws_s3_bucket.profile_assets.arn, + "${aws_s3_bucket.profile_assets.arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_policy" "profile_assets" { + bucket = aws_s3_bucket.profile_assets.id + policy = data.aws_iam_policy_document.profile_assets_bucket.json +} + +resource "aws_iam_openid_connect_provider" "vercel" { + url = local.vercel_oidc_issuer_url + client_id_list = [local.vercel_oidc_audience] + thumbprint_list = [data.tls_certificate.vercel_oidc.certificates[0].sha1_fingerprint] + + tags = local.tags +} + +data "aws_iam_policy_document" "vercel_profile_assets_assume_role" { + statement { + effect = "Allow" + + principals { + type = "Federated" + identifiers = [aws_iam_openid_connect_provider.vercel.arn] + } + + actions = ["sts:AssumeRoleWithWebIdentity"] + + condition { + test = "StringEquals" + variable = "${local.vercel_oidc_issuer_path}:aud" + values = [local.vercel_oidc_audience] + } + + condition { + test = "StringEquals" + variable = "${local.vercel_oidc_issuer_path}:sub" + values = local.vercel_oidc_subjects + } + } +} + +resource "aws_iam_role" "vercel_profile_assets" { + name = var.runtime_role_name + assume_role_policy = data.aws_iam_policy_document.vercel_profile_assets_assume_role.json + tags = local.tags +} + +data "aws_iam_policy_document" "vercel_profile_assets" { + statement { + sid = "ReadAndWriteProfileAssets" + actions = [ + "s3:GetObject", + "s3:PutObject", + ] + + resources = ["${aws_s3_bucket.profile_assets.arn}/${local.object_prefix}*"] + } +} + +resource "aws_iam_role_policy" "vercel_profile_assets" { + name = "profile-assets-s3-access" + role = aws_iam_role.vercel_profile_assets.id + policy = data.aws_iam_policy_document.vercel_profile_assets.json +} + +resource "vercel_project_environment_variable" "profile_assets_standard" { + for_each = { + for pair in setproduct(keys(local.runtime_env_values), keys(local.standard_vercel_targets)) : "${pair[0]}_${pair[1]}" => { + key = pair[0] + target = local.standard_vercel_targets[pair[1]] + value = local.runtime_env_values[pair[0]] + } + } + + project_id = data.vercel_project.web.id + team_id = var.vercel_team_id + key = each.value.key + value = each.value.value + target = each.value.target + sensitive = true + comment = local.runtime_env_comment +} + +resource "vercel_project_environment_variable" "profile_assets_staging_custom" { + for_each = { + for pair in setproduct(keys(local.runtime_env_values), var.staging_custom_environment_ids) : "${pair[0]}_${pair[1]}" => { + key = pair[0] + custom_environment_id = pair[1] + value = local.runtime_env_values[pair[0]] + } + } + + project_id = data.vercel_project.web.id + team_id = var.vercel_team_id + key = each.value.key + value = each.value.value + custom_environment_ids = [each.value.custom_environment_id] + sensitive = true + comment = local.runtime_env_comment +} diff --git a/infra/terraform/profile-assets/outputs.tf b/infra/terraform/profile-assets/outputs.tf new file mode 100644 index 0000000..fb2bb18 --- /dev/null +++ b/infra/terraform/profile-assets/outputs.tf @@ -0,0 +1,19 @@ +output "profile_asset_bucket_name" { + description = "Private S3 bucket for VRDex profile media-kit assets." + value = aws_s3_bucket.profile_assets.bucket +} + +output "profile_asset_bucket_region" { + description = "AWS region for the private profile asset bucket." + value = var.aws_region +} + +output "profile_asset_runtime_role_arn" { + description = "IAM role ARN Vercel functions assume through OIDC for profile asset S3 access." + value = aws_iam_role.vercel_profile_assets.arn +} + +output "managed_profile_asset_environment_keys" { + description = "Vercel environment variable names managed by this stack for profile asset storage." + value = keys(local.runtime_env_values) +} diff --git a/infra/terraform/profile-assets/terraform.tfvars.example b/infra/terraform/profile-assets/terraform.tfvars.example new file mode 100644 index 0000000..1961084 --- /dev/null +++ b/infra/terraform/profile-assets/terraform.tfvars.example @@ -0,0 +1,9 @@ +aws_region = "us-east-1" +vercel_team_slug = "basic-bit" +vercel_project_name = "vr-dex-web" + +# Optional override. Defaults to vrdex-profile-assets-${account_id}. +# asset_bucket_name = "vrdex-profile-assets-123456789012" + +# Production is managed by default. Staging uses the hosted custom environment ID below. +staging_custom_environment_ids = ["env_1iR8Tk53UMEhsbEgONsgGhzl9hX9"] diff --git a/infra/terraform/profile-assets/variables.tf b/infra/terraform/profile-assets/variables.tf new file mode 100644 index 0000000..e2b01be --- /dev/null +++ b/infra/terraform/profile-assets/variables.tf @@ -0,0 +1,59 @@ +variable "aws_region" { + description = "AWS region for the private profile asset bucket. Vercel must set VRDEX_PROFILE_ASSET_REGION to the same value." + type = string + default = "us-east-1" +} + +variable "asset_bucket_name" { + description = "Optional S3 bucket name for private profile media-kit assets. Defaults to vrdex-profile-assets plus account id." + type = string + default = null +} + +variable "runtime_role_name" { + description = "IAM role name assumed by Vercel functions through OIDC for profile asset S3 access." + type = string + default = "vrdex-vercel-profile-assets" +} + +variable "vercel_team_id" { + description = "Vercel team ID that owns the VRDex web project." + type = string + default = "team_GoHh5xUc96fAIAqJoG55A71S" +} + +variable "vercel_team_slug" { + description = "Vercel team slug used in team-mode OIDC issuer and audience claims." + type = string + default = "basic-bit" +} + +variable "vercel_project_name" { + description = "Existing Vercel project name for apps/web. OIDC trust is scoped to this project name." + type = string + default = "vr-dex-web" +} + +variable "vercel_runtime_environments" { + description = "Vercel deployment environment names allowed to assume the profile asset runtime role." + type = set(string) + default = ["production", "staging"] +} + +variable "manage_production_environment" { + description = "Whether to manage production profile asset env vars on the Vercel project." + type = bool + default = true +} + +variable "staging_custom_environment_ids" { + description = "Vercel custom environment IDs for staging-like environments that should receive profile asset env vars. Empty leaves custom environments unmanaged." + type = set(string) + default = ["env_1iR8Tk53UMEhsbEgONsgGhzl9hX9"] +} + +variable "tags" { + description = "Additional resource tags." + type = map(string) + default = {} +} diff --git a/infra/terraform/profile-assets/versions.tf b/infra/terraform/profile-assets/versions.tf new file mode 100644 index 0000000..94b39c4 --- /dev/null +++ b/infra/terraform/profile-assets/versions.tf @@ -0,0 +1,38 @@ +terraform { + required_version = ">= 1.10.0" + + backend "s3" { + bucket = "vrdex-terraform-state" + key = "profile-assets/terraform.tfstate" + region = "us-east-1" + encrypt = true + use_lockfile = true + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + + tls = { + source = "hashicorp/tls" + version = "~> 4.0" + } + + vercel = { + source = "vercel/vercel" + version = "~> 4.0" + } + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = local.tags + } +} + +provider "vercel" {} diff --git a/infra/terraform/state-mgmt/README.md b/infra/terraform/state-mgmt/README.md index c4cce72..cd288d2 100644 --- a/infra/terraform/state-mgmt/README.md +++ b/infra/terraform/state-mgmt/README.md @@ -12,7 +12,7 @@ It intentionally uses local Terraform state. Do not configure this stack to use - S3 versioning - S3 bucket policy that denies non-TLS requests - GitHub Actions OIDC Terraform role `vrdex-github-terraform` -- least-privilege inline policy for Terraform state, the `vrdex.net` Route 53 zone, hosted SES identity, and the Convex SES sender IAM user +- least-privilege inline policy for Terraform state, the `vrdex.net` Route 53 zone, hosted SES identity, the Convex SES sender IAM user, and the profile asset storage baseline The application stacks use S3 native lockfiles through `use_lockfile = true`; no DynamoDB lock table is required. @@ -42,6 +42,8 @@ terraform import aws_iam_role.github_actions_terraform vrdex-github-terraform terraform import aws_iam_role_policy.github_actions_terraform vrdex-github-terraform:vrdex-terraform-ci ``` +Apply this stack before enabling provider-backed CI plan/apply for `infra/terraform/profile-assets`. That stack needs the GitHub Actions role to manage the private profile asset S3 bucket, the Vercel OIDC identity provider, and the Vercel profile asset runtime role. + ## State Boundary Do not commit local state, plans, or `terraform.tfvars`. The root `.gitignore` excludes them. diff --git a/infra/terraform/state-mgmt/main.tf b/infra/terraform/state-mgmt/main.tf index 0d9fe18..59fa40b 100644 --- a/infra/terraform/state-mgmt/main.tf +++ b/infra/terraform/state-mgmt/main.tf @@ -1,5 +1,9 @@ locals { - state_bucket_arn = "arn:aws:s3:::${var.state_bucket_name}" + state_bucket_arn = "arn:aws:s3:::${var.state_bucket_name}" + profile_asset_bucket = var.profile_asset_bucket_name != null ? var.profile_asset_bucket_name : "vrdex-profile-assets-${data.aws_caller_identity.current.account_id}" + profile_asset_bucket_arn = "arn:aws:s3:::${local.profile_asset_bucket}" + vercel_oidc_provider_arn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/oidc.vercel.com/${var.vercel_team_slug}" + profile_asset_role_arn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.profile_asset_runtime_role_name}" tags = merge( { @@ -220,6 +224,83 @@ data "aws_iam_policy_document" "github_actions_terraform" { resources = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/service/vrdex-convex-ses-sender"] } + + statement { + sid = "ProfileAssetBucketManagement" + + actions = [ + "s3:CreateBucket", + "s3:DeleteBucket", + "s3:GetBucketLocation", + "s3:GetBucketPolicy", + "s3:GetBucketTagging", + "s3:GetBucketPublicAccessBlock", + "s3:GetBucketOwnershipControls", + "s3:GetEncryptionConfiguration", + "s3:ListBucket", + "s3:PutBucketPolicy", + "s3:PutBucketTagging", + "s3:PutBucketPublicAccessBlock", + "s3:PutBucketOwnershipControls", + "s3:PutEncryptionConfiguration", + "s3:DeleteBucketPolicy", + "s3:DeleteBucketTagging", + "s3:DeleteBucketPublicAccessBlock", + "s3:DeleteBucketOwnershipControls", + "s3:DeleteBucketEncryption", + ] + + resources = [local.profile_asset_bucket_arn] + } + + statement { + sid = "ProfileAssetBucketDiscovery" + + actions = [ + "s3:ListAllMyBuckets", + ] + + resources = ["*"] + } + + statement { + sid = "VercelProfileAssetOidcProviderManagement" + + actions = [ + "iam:AddClientIDToOpenIDConnectProvider", + "iam:CreateOpenIDConnectProvider", + "iam:DeleteOpenIDConnectProvider", + "iam:GetOpenIDConnectProvider", + "iam:ListOpenIDConnectProviderTags", + "iam:RemoveClientIDFromOpenIDConnectProvider", + "iam:TagOpenIDConnectProvider", + "iam:UntagOpenIDConnectProvider", + "iam:UpdateOpenIDConnectProviderThumbprint", + ] + + resources = [local.vercel_oidc_provider_arn] + } + + statement { + sid = "VercelProfileAssetRoleManagement" + + actions = [ + "iam:CreateRole", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:ListRolePolicies", + "iam:PutRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + ] + + resources = [local.profile_asset_role_arn] + } } resource "aws_iam_role_policy" "github_actions_terraform" { diff --git a/infra/terraform/state-mgmt/variables.tf b/infra/terraform/state-mgmt/variables.tf index 348dfb5..1193042 100644 --- a/infra/terraform/state-mgmt/variables.tf +++ b/infra/terraform/state-mgmt/variables.tf @@ -27,6 +27,24 @@ variable "ses_domain_name" { default = "vrdex.net" } +variable "profile_asset_bucket_name" { + description = "S3 bucket name Terraform CI may manage for private profile media-kit assets. Defaults to vrdex-profile-assets plus account id." + type = string + default = null +} + +variable "profile_asset_runtime_role_name" { + description = "IAM role name Terraform CI may manage for Vercel profile asset runtime access." + type = string + default = "vrdex-vercel-profile-assets" +} + +variable "vercel_team_slug" { + description = "Vercel team slug used by the profile asset OIDC provider Terraform CI may manage." + type = string + default = "basic-bit" +} + variable "tags" { description = "Additional resource tags." type = map(string) diff --git a/infra/terraform/vercel/README.md b/infra/terraform/vercel/README.md index 03a1865..e75e71f 100644 --- a/infra/terraform/vercel/README.md +++ b/infra/terraform/vercel/README.md @@ -1,6 +1,6 @@ # Vercel Web Terraform -This stack manages Vercel project environment variables for the hosted VRDex web app. +This stack manages PostHog Vercel project environment variables for the hosted VRDex web app. It currently manages PostHog analytics variables for the existing Vercel project: @@ -16,6 +16,8 @@ It currently manages PostHog analytics variables for the existing Vercel project The PostHog project key is client-exposed once deployed, but keep the value out of git so forks and self-hosted installs do not accidentally send analytics into the BASIC BIT project. +Profile asset storage variables are owned by `infra/terraform/profile-assets`, not this stack, because that stack owns the paired S3 bucket and Vercel OIDC runtime role. + ## Usage 1. Copy `terraform.tfvars.example` to `terraform.tfvars`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4f6b63..6da764f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: '@convex-dev/auth': specifier: ^0.0.92 version: 0.0.92(@auth/core@0.37.4)(convex@1.32.0(react@19.2.3))(react@19.2.3) + '@vercel/oidc-aws-credentials-provider': + specifier: ^3.1.4 + version: 3.1.4 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -3650,6 +3653,21 @@ packages: cpu: [x64] os: [win32] + '@vercel/cli-config@0.2.0': + resolution: {integrity: sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ==} + + '@vercel/cli-exec@0.1.1': + resolution: {integrity: sha512-LMRMEai3Z+BODyxGcU9+KiWrS/UElNiOLKiNRfGNt2Vu3NTEmXgFeXG9wBfocAnTe5yJCX/DY6k3k7S/LkPp/g==} + engines: {node: '>= 18'} + + '@vercel/oidc-aws-credentials-provider@3.1.4': + resolution: {integrity: sha512-JYfZSGs/losKgsT5Xtt4szmcoGi5HYgQmr4WUUK4fDYW9XPb5tPRAn8B/UpGNsFEBQuOwI+pRGSGI4OaxndbVg==} + engines: {node: '>= 20'} + + '@vercel/oidc@3.6.1': + resolution: {integrity: sha512-8ipTFoiX3WBRrvXLjSrmgAiwtMDQk3EgSxe8N7v2rXBz39NBIIyoGXeVbJRoBcP8WEuVnvjvIQsggbGU7ZKrMw==} + engines: {node: '>= 20'} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -6581,6 +6599,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -8495,10 +8517,18 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xdg-app-paths@5.5.1: + resolution: {integrity: sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==} + engines: {node: '>= 6.0'} + xdg-basedir@5.1.0: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xml-js@1.6.11: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true @@ -8529,6 +8559,9 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -8811,7 +8844,7 @@ snapshots: '@aws-sdk/credential-provider-login': 3.972.44 '@aws-sdk/credential-provider-process': 3.972.40 '@aws-sdk/credential-provider-sso': 3.972.44 - '@aws-sdk/credential-provider-web-identity': 3.972.44 + '@aws-sdk/credential-provider-web-identity': 3.972.52 '@aws-sdk/nested-clients': 3.997.12 '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.5 @@ -8837,11 +8870,11 @@ snapshots: '@aws-sdk/credential-provider-login@3.972.44': dependencies: - '@aws-sdk/core': 3.974.14 - '@aws-sdk/nested-clients': 3.997.12 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.5 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.25.0 + '@smithy/types': 4.15.0 tslib: 2.8.1 '@aws-sdk/credential-provider-login@3.972.52': @@ -8953,13 +8986,13 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.14 + '@aws-sdk/core': 3.974.20 '@aws-sdk/signature-v4-multi-region': 3.996.29 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.5 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.25.0 '@smithy/fetch-http-handler': 5.4.5 '@smithy/node-http-handler': 4.7.5 - '@smithy/types': 4.14.2 + '@smithy/types': 4.15.0 tslib: 2.8.1 '@aws-sdk/nested-clients@3.997.20': @@ -8977,9 +9010,9 @@ snapshots: '@aws-sdk/signature-v4-multi-region@3.996.29': dependencies: - '@aws-sdk/types': 3.973.9 + '@aws-sdk/types': 3.973.12 '@smithy/signature-v4': 5.4.5 - '@smithy/types': 4.14.2 + '@smithy/types': 4.15.0 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.34': @@ -8991,11 +9024,11 @@ snapshots: '@aws-sdk/token-providers@3.1054.0': dependencies: - '@aws-sdk/core': 3.974.14 - '@aws-sdk/nested-clients': 3.997.12 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.5 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/nested-clients': 3.997.20 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.25.0 + '@smithy/types': 4.15.0 tslib: 2.8.1 '@aws-sdk/token-providers@3.1066.0': @@ -13168,6 +13201,26 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vercel/cli-config@0.2.0': + dependencies: + xdg-app-paths: 5.5.1 + zod: 4.1.11 + + '@vercel/cli-exec@0.1.1': + dependencies: + execa: 5.1.1 + + '@vercel/oidc-aws-credentials-provider@3.1.4': + dependencies: + '@aws-sdk/credential-provider-web-identity': 3.972.52 + '@vercel/oidc': 3.6.1 + + '@vercel/oidc@3.6.1': + dependencies: + '@vercel/cli-config': 0.2.0 + '@vercel/cli-exec': 0.1.1 + jose: 5.10.0 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -16768,6 +16821,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + os-paths@4.4.0: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -19218,8 +19273,17 @@ snapshots: dependencies: is-wsl: 3.1.1 + xdg-app-paths@5.5.1: + dependencies: + os-paths: 4.4.0 + xdg-portable: 7.3.0 + xdg-basedir@5.1.0: {} + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xml-js@1.6.11: dependencies: sax: 1.6.0 @@ -19238,6 +19302,8 @@ snapshots: dependencies: zod: 4.3.6 + zod@4.1.11: {} + zod@4.3.6: {} zwitch@2.0.4: {} From 8494f04656493e520503c6d547c131939cb2a332 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Fri, 19 Jun 2026 09:41:19 -0400 Subject: [PATCH 2/2] Address profile asset storage review feedback --- apps/web/src/lib/server/profile-asset-storage.ts | 6 +----- infra/terraform/profile-assets/variables.tf | 2 +- infra/terraform/state-mgmt/main.tf | 1 + 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/server/profile-asset-storage.ts b/apps/web/src/lib/server/profile-asset-storage.ts index 81758a5..0fcc35e 100644 --- a/apps/web/src/lib/server/profile-asset-storage.ts +++ b/apps/web/src/lib/server/profile-asset-storage.ts @@ -14,12 +14,8 @@ type StoredObject = { const cachedClients = new Map(); -function isVercelRuntime(): boolean { - return process.env.VERCEL === "1" || process.env.VERCEL === "true" || Boolean(process.env.VERCEL_OIDC_TOKEN); -} - function vercelOidcRoleArn(): string | undefined { - const roleArn = process.env.VRDEX_PROFILE_ASSET_ROLE_ARN ?? (isVercelRuntime() ? process.env.AWS_ROLE_ARN : undefined); + const roleArn = process.env.VRDEX_PROFILE_ASSET_ROLE_ARN; const normalized = roleArn?.trim(); return normalized ? normalized : undefined; diff --git a/infra/terraform/profile-assets/variables.tf b/infra/terraform/profile-assets/variables.tf index e2b01be..64a5370 100644 --- a/infra/terraform/profile-assets/variables.tf +++ b/infra/terraform/profile-assets/variables.tf @@ -49,7 +49,7 @@ variable "manage_production_environment" { variable "staging_custom_environment_ids" { description = "Vercel custom environment IDs for staging-like environments that should receive profile asset env vars. Empty leaves custom environments unmanaged." type = set(string) - default = ["env_1iR8Tk53UMEhsbEgONsgGhzl9hX9"] + default = [] } variable "tags" { diff --git a/infra/terraform/state-mgmt/main.tf b/infra/terraform/state-mgmt/main.tf index 59fa40b..a1835b7 100644 --- a/infra/terraform/state-mgmt/main.tf +++ b/infra/terraform/state-mgmt/main.tf @@ -292,6 +292,7 @@ data "aws_iam_policy_document" "github_actions_terraform" { "iam:GetRolePolicy", "iam:ListAttachedRolePolicies", "iam:ListInstanceProfilesForRole", + "iam:ListRoleTags", "iam:ListRolePolicies", "iam:PutRolePolicy", "iam:TagRole",