feat(expenses): admin_notes column and lambda S3 object permissions - #314

Merged
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra
Aug 12, 2026
Merged

feat(expenses): admin_notes column and lambda S3 object permissions#314
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra

Conversation

@nourshoreibah

@nourshoreibahnourshoreibah commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Groundwork for the expense approve/deny flow, split out so it lands before the lambda code that depends on it. Migrations run against production before the new lambda code deploys, so this ordering is the required one.

Migration

20260812012951_add_expenditure_admin_notes.sql adds a nullable admin_notes TEXT to expenditures — the reviewing admin's note. Additive and forward-only; safe for the lambda code that is live right now, which simply ignores the column.

shared/types/db-types.d.ts is regenerated to match, per the commit-them-together rule in apps/backend/db/README.md.

No status enum change was needed: the baseline check constraint already permits needs_more_info, which is the value the review UI writes.

IAM

Important

The shared lambda role had no S3 permissions at all. A presigned URL carries the signer's permissions, so presigning a receipt upload would have failed AccessDenied at the browser's PUT.

Adds s3:PutObject / s3:GetObject scoped to ${aws_s3_bucket.reports_bucket.arn}/*.

This overlaps in intent with #310, which adds the same two actions while making the bucket private. If #310 merges first this becomes a redundant second inline policy under a different name (branch-lambda-s3-objects) and can be dropped in review — the permissions are identical, so there is no ordering hazard either way.

Deliberately does not touch the bucket's public-access block or bucket policy — that is #310's change, and duplicating it here would conflict.

CI guard bug

migrations-guard failed on this PR, and it turned out to be a bug in the guard rather than a problem with the migration.

make new-migration scaffolds a header containing the sentence:

-- CREATE INDEX CONCURRENTLY / VACUUM will not work here.

The guard grepped the raw file, so that sentence matched its own CONCURRENTLY check and the migration failed before it contained any SQL at all. Every migration generated by the project's own tooling would have failed this check. Nothing had tripped it yet because this is the first new migration since the guard landed — the only other file, 0000_baseline_schema.sql, predates the template.

Fix: blank out SQL comments before the destructive / CONCURRENTLY / user-insert scans. Blanking rather than deleting the lines keeps grep -n line numbers pointing at the real line. The -- allow-destructive: opt-out still reads the raw file, since that marker is itself a comment.

Verified locally that the guard still catches genuine violations:

InputResult
this migrationpass
CREATE INDEX CONCURRENTLY ... in real SQLfail (line 2)
ALTER TABLE ... DROP COLUMN under the scaffold headerfail (line 3)
0000_baseline_schema.sqlpass

Verification

terraform fmt -check clean; the terraform-docs job auto-committed the README row for the new IAM resource. terraform validate needs provider download and was not run.

Follow-up: the application PR is #315.

🤖 Generated with Claude Code

Groundwork for the expense review flow, split out so it lands before the
lambda code that depends on it.
- Adds a nullable `admin_notes TEXT` to `expenditures` for the reviewing
admin's note. Additive and forward-only.
- Grants the shared lambda role `s3:PutObject`/`s3:GetObject` on the
reports bucket. The role had no S3 permissions at all, so presigning a
receipt upload would have failed AccessDenied -- a presigned URL carries
the signer's permissions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

This PR contains a database migration

  • apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql

It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:

  • Applied and tested locally.cd apps/backend && make migrate, then run the affected lambda's tests (cd apps/backend/lambdas/<name> && npm test). make show-migrations shows what applied.
  • Safe for the code that is live right now. During the deploy window -- and indefinitely if the deploy fails -- the currently deployed lambdas run against your new schema. Additive changes (CREATE TABLE, nullable ADD COLUMN, CREATE INDEX) are fine in one PR. DROP COLUMN, renames, ADD COLUMN NOT NULL with no default, and new UNIQUE/CHECK/FOREIGN KEY constraints need two merged PRs -- see the expand/contract rules in apps/backend/db/README.md.
  • Kept separate from unrelated changes. A migration PR should ideally contain the migration, the code that needs it, and nothing else. It changes production state, it is the one thing here that redeploying cannot roll back, and a reviewer should be able to see the whole schema change without scrolling past unrelated work.
  • No already-merged migration was edited. Fix an old migration by adding a new one; there is no down.

shared/types/db-types.d.ts is regenerated and pushed to this branch automatically -- don't hand-edit it. Expect one red migrations-fresh check before that commit lands.

 - Auto-formatted .tf files with terraform fmt
- Updated README.md with terraform-docs
Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

`make new-migration` scaffolds a header that reads "CREATE INDEX
CONCURRENTLY / VACUUM will not work here". The guard grepped the raw
file, so that sentence matched and every scaffolded migration failed
before it contained any SQL at all. This is the first new migration
since the guard landed -- the only other file, the baseline, predates
the template -- so nothing had tripped it yet.
Comments are now blanked before the destructive/CONCURRENTLY/user-insert
scans. Blanking rather than deleting the lines keeps grep -n line
numbers pointing at the real line. The allow-destructive opt-out still
reads the raw file, since that marker is itself a comment.
Verified the guard still fails a real CREATE INDEX CONCURRENTLY and a
real ALTER TABLE ... DROP COLUMN, and still passes the baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.github_folder: Reading...
data.aws_caller_identity.current: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
data.aws_vpc.default: Reading...
data.infisical_secrets.github_folder: Read complete after 0s
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.infisical_secrets.rds_folder: Read complete after 0s
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3474878989]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_4XX]
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_5XX]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
data.aws_vpc.default: Read complete after 1s [id=vpc-0a9ccfb59c8918ce9]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_security_group.rds: Refreshing state... [id=sg-0c8a1ff1676a14edb]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=4sjlu3]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=srhf9j]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=xkazax]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=14khv0]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=kmwcxq]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=elsvn3]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-ANY]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=od3a3y]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:+ create
~ update in-place
Terraform will perform the following actions:# aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_4XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_5XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_iam_role_policy.lambda_s3_objects will be created+resource"aws_iam_role_policy""lambda_s3_objects" {
+id=(known after apply)
+name="branch-lambda-s3-objects"+name_prefix=(known after apply)
+policy=jsonencode(
{
+ Statement = [
+ {
+ Action = [
+"s3:PutObject",
+"s3:GetObject",
]
+ Effect ="Allow"+ Resource ="arn:aws:s3:::c4c-branch-generated-reports20251030194253425700000001/*"+ Sid ="LambdaReportsBucketObjects"
},
]
+ Version ="2012-10-17"
}
)
+role="branch-lambda-role"
}
Plan:1 to add, 2 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@nourshoreibahnourshoreibah added the no-review The PR review bot won't run label Aug 12, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 12, 2026 02:12
@nourshoreibah
nourshoreibah merged commit adda146 into mainAug 12, 2026
23 checks passed
@nourshoreibah
nourshoreibah deleted the expense-approvals-infra branch August 12, 2026 02:12
nourshoreibah added a commit that referenced this pull request Aug 12, 2026
Conflicts in infrastructure/aws/lambda.tf and the generated
infrastructure/aws/README.md.
main's #314 added aws_iam_role_policy.lambda_s3_objects with the same actions
(s3:PutObject, s3:GetObject) on the same resource (reports_bucket) as the
lambda_reports_bucket policy added here, so the two were functionally identical.
Kept main's resource and dropped the duplicate, folding this branch's rationale
(report generation was failing AccessDenied, and a presigned GET needs the
signer to hold GetObject) into its comment.
README.md is terraform-docs output: took main's copy and dropped the
aws_s3_bucket_policy.reports_bucket_policy row, since this branch deletes that
resource. CI regenerates this file regardless.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-reviewThe PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@nourshoreibah
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(expenses): admin_notes column and lambda S3 object permissions - #314

Merged
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra
Aug 12, 2026
Merged

feat(expenses): admin_notes column and lambda S3 object permissions#314
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra

Conversation

@nourshoreibah

@nourshoreibahnourshoreibah commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Groundwork for the expense approve/deny flow, split out so it lands before the lambda code that depends on it. Migrations run against production before the new lambda code deploys, so this ordering is the required one.

Migration

20260812012951_add_expenditure_admin_notes.sql adds a nullable admin_notes TEXT to expenditures — the reviewing admin's note. Additive and forward-only; safe for the lambda code that is live right now, which simply ignores the column.

shared/types/db-types.d.ts is regenerated to match, per the commit-them-together rule in apps/backend/db/README.md.

No status enum change was needed: the baseline check constraint already permits needs_more_info, which is the value the review UI writes.

IAM

Important

The shared lambda role had no S3 permissions at all. A presigned URL carries the signer's permissions, so presigning a receipt upload would have failed AccessDenied at the browser's PUT.

Adds s3:PutObject / s3:GetObject scoped to ${aws_s3_bucket.reports_bucket.arn}/*.

This overlaps in intent with #310, which adds the same two actions while making the bucket private. If #310 merges first this becomes a redundant second inline policy under a different name (branch-lambda-s3-objects) and can be dropped in review — the permissions are identical, so there is no ordering hazard either way.

Deliberately does not touch the bucket's public-access block or bucket policy — that is #310's change, and duplicating it here would conflict.

CI guard bug

migrations-guard failed on this PR, and it turned out to be a bug in the guard rather than a problem with the migration.

make new-migration scaffolds a header containing the sentence:

-- CREATE INDEX CONCURRENTLY / VACUUM will not work here.

The guard grepped the raw file, so that sentence matched its own CONCURRENTLY check and the migration failed before it contained any SQL at all. Every migration generated by the project's own tooling would have failed this check. Nothing had tripped it yet because this is the first new migration since the guard landed — the only other file, 0000_baseline_schema.sql, predates the template.

Fix: blank out SQL comments before the destructive / CONCURRENTLY / user-insert scans. Blanking rather than deleting the lines keeps grep -n line numbers pointing at the real line. The -- allow-destructive: opt-out still reads the raw file, since that marker is itself a comment.

Verified locally that the guard still catches genuine violations:

InputResult
this migrationpass
CREATE INDEX CONCURRENTLY ... in real SQLfail (line 2)
ALTER TABLE ... DROP COLUMN under the scaffold headerfail (line 3)
0000_baseline_schema.sqlpass

Verification

terraform fmt -check clean; the terraform-docs job auto-committed the README row for the new IAM resource. terraform validate needs provider download and was not run.

Follow-up: the application PR is #315.

🤖 Generated with Claude Code

Groundwork for the expense review flow, split out so it lands before the
lambda code that depends on it.
- Adds a nullable `admin_notes TEXT` to `expenditures` for the reviewing
admin's note. Additive and forward-only.
- Grants the shared lambda role `s3:PutObject`/`s3:GetObject` on the
reports bucket. The role had no S3 permissions at all, so presigning a
receipt upload would have failed AccessDenied -- a presigned URL carries
the signer's permissions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

This PR contains a database migration

  • apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql

It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:

  • Applied and tested locally.cd apps/backend && make migrate, then run the affected lambda's tests (cd apps/backend/lambdas/<name> && npm test). make show-migrations shows what applied.
  • Safe for the code that is live right now. During the deploy window -- and indefinitely if the deploy fails -- the currently deployed lambdas run against your new schema. Additive changes (CREATE TABLE, nullable ADD COLUMN, CREATE INDEX) are fine in one PR. DROP COLUMN, renames, ADD COLUMN NOT NULL with no default, and new UNIQUE/CHECK/FOREIGN KEY constraints need two merged PRs -- see the expand/contract rules in apps/backend/db/README.md.
  • Kept separate from unrelated changes. A migration PR should ideally contain the migration, the code that needs it, and nothing else. It changes production state, it is the one thing here that redeploying cannot roll back, and a reviewer should be able to see the whole schema change without scrolling past unrelated work.
  • No already-merged migration was edited. Fix an old migration by adding a new one; there is no down.

shared/types/db-types.d.ts is regenerated and pushed to this branch automatically -- don't hand-edit it. Expect one red migrations-fresh check before that commit lands.

 - Auto-formatted .tf files with terraform fmt
- Updated README.md with terraform-docs
Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

`make new-migration` scaffolds a header that reads "CREATE INDEX
CONCURRENTLY / VACUUM will not work here". The guard grepped the raw
file, so that sentence matched and every scaffolded migration failed
before it contained any SQL at all. This is the first new migration
since the guard landed -- the only other file, the baseline, predates
the template -- so nothing had tripped it yet.
Comments are now blanked before the destructive/CONCURRENTLY/user-insert
scans. Blanking rather than deleting the lines keeps grep -n line
numbers pointing at the real line. The allow-destructive opt-out still
reads the raw file, since that marker is itself a comment.
Verified the guard still fails a real CREATE INDEX CONCURRENTLY and a
real ALTER TABLE ... DROP COLUMN, and still passes the baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.github_folder: Reading...
data.aws_caller_identity.current: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
data.aws_vpc.default: Reading...
data.infisical_secrets.github_folder: Read complete after 0s
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.infisical_secrets.rds_folder: Read complete after 0s
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3474878989]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_4XX]
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_5XX]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
data.aws_vpc.default: Read complete after 1s [id=vpc-0a9ccfb59c8918ce9]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_security_group.rds: Refreshing state... [id=sg-0c8a1ff1676a14edb]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=4sjlu3]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=srhf9j]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=xkazax]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=14khv0]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=kmwcxq]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=elsvn3]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-ANY]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=od3a3y]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:+ create
~ update in-place
Terraform will perform the following actions:# aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_4XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_5XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_iam_role_policy.lambda_s3_objects will be created+resource"aws_iam_role_policy""lambda_s3_objects" {
+id=(known after apply)
+name="branch-lambda-s3-objects"+name_prefix=(known after apply)
+policy=jsonencode(
{
+ Statement = [
+ {
+ Action = [
+"s3:PutObject",
+"s3:GetObject",
]
+ Effect ="Allow"+ Resource ="arn:aws:s3:::c4c-branch-generated-reports20251030194253425700000001/*"+ Sid ="LambdaReportsBucketObjects"
},
]
+ Version ="2012-10-17"
}
)
+role="branch-lambda-role"
}
Plan:1 to add, 2 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@nourshoreibahnourshoreibah added the no-review The PR review bot won't run label Aug 12, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 12, 2026 02:12
@nourshoreibah
nourshoreibah merged commit adda146 into mainAug 12, 2026
23 checks passed
@nourshoreibah
nourshoreibah deleted the expense-approvals-infra branch August 12, 2026 02:12
nourshoreibah added a commit that referenced this pull request Aug 12, 2026
Conflicts in infrastructure/aws/lambda.tf and the generated
infrastructure/aws/README.md.
main's #314 added aws_iam_role_policy.lambda_s3_objects with the same actions
(s3:PutObject, s3:GetObject) on the same resource (reports_bucket) as the
lambda_reports_bucket policy added here, so the two were functionally identical.
Kept main's resource and dropped the duplicate, folding this branch's rationale
(report generation was failing AccessDenied, and a presigned GET needs the
signer to hold GetObject) into its comment.
README.md is terraform-docs output: took main's copy and dropped the
aws_s3_bucket_policy.reports_bucket_policy row, since this branch deletes that
resource. CI regenerates this file regardless.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-reviewThe PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@nourshoreibah
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(expenses): admin_notes column and lambda S3 object permissions - #314

Merged
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra
Aug 12, 2026
Merged

feat(expenses): admin_notes column and lambda S3 object permissions#314
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra

Conversation

@nourshoreibah

@nourshoreibahnourshoreibah commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Groundwork for the expense approve/deny flow, split out so it lands before the lambda code that depends on it. Migrations run against production before the new lambda code deploys, so this ordering is the required one.

Migration

20260812012951_add_expenditure_admin_notes.sql adds a nullable admin_notes TEXT to expenditures — the reviewing admin's note. Additive and forward-only; safe for the lambda code that is live right now, which simply ignores the column.

shared/types/db-types.d.ts is regenerated to match, per the commit-them-together rule in apps/backend/db/README.md.

No status enum change was needed: the baseline check constraint already permits needs_more_info, which is the value the review UI writes.

IAM

Important

The shared lambda role had no S3 permissions at all. A presigned URL carries the signer's permissions, so presigning a receipt upload would have failed AccessDenied at the browser's PUT.

Adds s3:PutObject / s3:GetObject scoped to ${aws_s3_bucket.reports_bucket.arn}/*.

This overlaps in intent with #310, which adds the same two actions while making the bucket private. If #310 merges first this becomes a redundant second inline policy under a different name (branch-lambda-s3-objects) and can be dropped in review — the permissions are identical, so there is no ordering hazard either way.

Deliberately does not touch the bucket's public-access block or bucket policy — that is #310's change, and duplicating it here would conflict.

CI guard bug

migrations-guard failed on this PR, and it turned out to be a bug in the guard rather than a problem with the migration.

make new-migration scaffolds a header containing the sentence:

-- CREATE INDEX CONCURRENTLY / VACUUM will not work here.

The guard grepped the raw file, so that sentence matched its own CONCURRENTLY check and the migration failed before it contained any SQL at all. Every migration generated by the project's own tooling would have failed this check. Nothing had tripped it yet because this is the first new migration since the guard landed — the only other file, 0000_baseline_schema.sql, predates the template.

Fix: blank out SQL comments before the destructive / CONCURRENTLY / user-insert scans. Blanking rather than deleting the lines keeps grep -n line numbers pointing at the real line. The -- allow-destructive: opt-out still reads the raw file, since that marker is itself a comment.

Verified locally that the guard still catches genuine violations:

InputResult
this migrationpass
CREATE INDEX CONCURRENTLY ... in real SQLfail (line 2)
ALTER TABLE ... DROP COLUMN under the scaffold headerfail (line 3)
0000_baseline_schema.sqlpass

Verification

terraform fmt -check clean; the terraform-docs job auto-committed the README row for the new IAM resource. terraform validate needs provider download and was not run.

Follow-up: the application PR is #315.

🤖 Generated with Claude Code

Groundwork for the expense review flow, split out so it lands before the
lambda code that depends on it.
- Adds a nullable `admin_notes TEXT` to `expenditures` for the reviewing
admin's note. Additive and forward-only.
- Grants the shared lambda role `s3:PutObject`/`s3:GetObject` on the
reports bucket. The role had no S3 permissions at all, so presigning a
receipt upload would have failed AccessDenied -- a presigned URL carries
the signer's permissions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

This PR contains a database migration

  • apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql

It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:

  • Applied and tested locally.cd apps/backend && make migrate, then run the affected lambda's tests (cd apps/backend/lambdas/<name> && npm test). make show-migrations shows what applied.
  • Safe for the code that is live right now. During the deploy window -- and indefinitely if the deploy fails -- the currently deployed lambdas run against your new schema. Additive changes (CREATE TABLE, nullable ADD COLUMN, CREATE INDEX) are fine in one PR. DROP COLUMN, renames, ADD COLUMN NOT NULL with no default, and new UNIQUE/CHECK/FOREIGN KEY constraints need two merged PRs -- see the expand/contract rules in apps/backend/db/README.md.
  • Kept separate from unrelated changes. A migration PR should ideally contain the migration, the code that needs it, and nothing else. It changes production state, it is the one thing here that redeploying cannot roll back, and a reviewer should be able to see the whole schema change without scrolling past unrelated work.
  • No already-merged migration was edited. Fix an old migration by adding a new one; there is no down.

shared/types/db-types.d.ts is regenerated and pushed to this branch automatically -- don't hand-edit it. Expect one red migrations-fresh check before that commit lands.

 - Auto-formatted .tf files with terraform fmt
- Updated README.md with terraform-docs
Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

`make new-migration` scaffolds a header that reads "CREATE INDEX
CONCURRENTLY / VACUUM will not work here". The guard grepped the raw
file, so that sentence matched and every scaffolded migration failed
before it contained any SQL at all. This is the first new migration
since the guard landed -- the only other file, the baseline, predates
the template -- so nothing had tripped it yet.
Comments are now blanked before the destructive/CONCURRENTLY/user-insert
scans. Blanking rather than deleting the lines keeps grep -n line
numbers pointing at the real line. The allow-destructive opt-out still
reads the raw file, since that marker is itself a comment.
Verified the guard still fails a real CREATE INDEX CONCURRENTLY and a
real ALTER TABLE ... DROP COLUMN, and still passes the baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.github_folder: Reading...
data.aws_caller_identity.current: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
data.aws_vpc.default: Reading...
data.infisical_secrets.github_folder: Read complete after 0s
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.infisical_secrets.rds_folder: Read complete after 0s
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3474878989]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_4XX]
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_5XX]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
data.aws_vpc.default: Read complete after 1s [id=vpc-0a9ccfb59c8918ce9]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_security_group.rds: Refreshing state... [id=sg-0c8a1ff1676a14edb]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=4sjlu3]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=srhf9j]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=xkazax]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=14khv0]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=kmwcxq]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=elsvn3]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-ANY]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=od3a3y]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:+ create
~ update in-place
Terraform will perform the following actions:# aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_4XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_5XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_iam_role_policy.lambda_s3_objects will be created+resource"aws_iam_role_policy""lambda_s3_objects" {
+id=(known after apply)
+name="branch-lambda-s3-objects"+name_prefix=(known after apply)
+policy=jsonencode(
{
+ Statement = [
+ {
+ Action = [
+"s3:PutObject",
+"s3:GetObject",
]
+ Effect ="Allow"+ Resource ="arn:aws:s3:::c4c-branch-generated-reports20251030194253425700000001/*"+ Sid ="LambdaReportsBucketObjects"
},
]
+ Version ="2012-10-17"
}
)
+role="branch-lambda-role"
}
Plan:1 to add, 2 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@nourshoreibahnourshoreibah added the no-review The PR review bot won't run label Aug 12, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 12, 2026 02:12
@nourshoreibah
nourshoreibah merged commit adda146 into mainAug 12, 2026
23 checks passed
@nourshoreibah
nourshoreibah deleted the expense-approvals-infra branch August 12, 2026 02:12
nourshoreibah added a commit that referenced this pull request Aug 12, 2026
Conflicts in infrastructure/aws/lambda.tf and the generated
infrastructure/aws/README.md.
main's #314 added aws_iam_role_policy.lambda_s3_objects with the same actions
(s3:PutObject, s3:GetObject) on the same resource (reports_bucket) as the
lambda_reports_bucket policy added here, so the two were functionally identical.
Kept main's resource and dropped the duplicate, folding this branch's rationale
(report generation was failing AccessDenied, and a presigned GET needs the
signer to hold GetObject) into its comment.
README.md is terraform-docs output: took main's copy and dropped the
aws_s3_bucket_policy.reports_bucket_policy row, since this branch deletes that
resource. CI regenerates this file regardless.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-reviewThe PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@nourshoreibah
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(expenses): admin_notes column and lambda S3 object permissions - #314

Merged
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra
Aug 12, 2026
Merged

feat(expenses): admin_notes column and lambda S3 object permissions#314
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra

Conversation

@nourshoreibah

@nourshoreibahnourshoreibah commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Groundwork for the expense approve/deny flow, split out so it lands before the lambda code that depends on it. Migrations run against production before the new lambda code deploys, so this ordering is the required one.

Migration

20260812012951_add_expenditure_admin_notes.sql adds a nullable admin_notes TEXT to expenditures — the reviewing admin's note. Additive and forward-only; safe for the lambda code that is live right now, which simply ignores the column.

shared/types/db-types.d.ts is regenerated to match, per the commit-them-together rule in apps/backend/db/README.md.

No status enum change was needed: the baseline check constraint already permits needs_more_info, which is the value the review UI writes.

IAM

Important

The shared lambda role had no S3 permissions at all. A presigned URL carries the signer's permissions, so presigning a receipt upload would have failed AccessDenied at the browser's PUT.

Adds s3:PutObject / s3:GetObject scoped to ${aws_s3_bucket.reports_bucket.arn}/*.

This overlaps in intent with #310, which adds the same two actions while making the bucket private. If #310 merges first this becomes a redundant second inline policy under a different name (branch-lambda-s3-objects) and can be dropped in review — the permissions are identical, so there is no ordering hazard either way.

Deliberately does not touch the bucket's public-access block or bucket policy — that is #310's change, and duplicating it here would conflict.

CI guard bug

migrations-guard failed on this PR, and it turned out to be a bug in the guard rather than a problem with the migration.

make new-migration scaffolds a header containing the sentence:

-- CREATE INDEX CONCURRENTLY / VACUUM will not work here.

The guard grepped the raw file, so that sentence matched its own CONCURRENTLY check and the migration failed before it contained any SQL at all. Every migration generated by the project's own tooling would have failed this check. Nothing had tripped it yet because this is the first new migration since the guard landed — the only other file, 0000_baseline_schema.sql, predates the template.

Fix: blank out SQL comments before the destructive / CONCURRENTLY / user-insert scans. Blanking rather than deleting the lines keeps grep -n line numbers pointing at the real line. The -- allow-destructive: opt-out still reads the raw file, since that marker is itself a comment.

Verified locally that the guard still catches genuine violations:

InputResult
this migrationpass
CREATE INDEX CONCURRENTLY ... in real SQLfail (line 2)
ALTER TABLE ... DROP COLUMN under the scaffold headerfail (line 3)
0000_baseline_schema.sqlpass

Verification

terraform fmt -check clean; the terraform-docs job auto-committed the README row for the new IAM resource. terraform validate needs provider download and was not run.

Follow-up: the application PR is #315.

🤖 Generated with Claude Code

Groundwork for the expense review flow, split out so it lands before the
lambda code that depends on it.
- Adds a nullable `admin_notes TEXT` to `expenditures` for the reviewing
admin's note. Additive and forward-only.
- Grants the shared lambda role `s3:PutObject`/`s3:GetObject` on the
reports bucket. The role had no S3 permissions at all, so presigning a
receipt upload would have failed AccessDenied -- a presigned URL carries
the signer's permissions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

This PR contains a database migration

  • apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql

It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:

  • Applied and tested locally.cd apps/backend && make migrate, then run the affected lambda's tests (cd apps/backend/lambdas/<name> && npm test). make show-migrations shows what applied.
  • Safe for the code that is live right now. During the deploy window -- and indefinitely if the deploy fails -- the currently deployed lambdas run against your new schema. Additive changes (CREATE TABLE, nullable ADD COLUMN, CREATE INDEX) are fine in one PR. DROP COLUMN, renames, ADD COLUMN NOT NULL with no default, and new UNIQUE/CHECK/FOREIGN KEY constraints need two merged PRs -- see the expand/contract rules in apps/backend/db/README.md.
  • Kept separate from unrelated changes. A migration PR should ideally contain the migration, the code that needs it, and nothing else. It changes production state, it is the one thing here that redeploying cannot roll back, and a reviewer should be able to see the whole schema change without scrolling past unrelated work.
  • No already-merged migration was edited. Fix an old migration by adding a new one; there is no down.

shared/types/db-types.d.ts is regenerated and pushed to this branch automatically -- don't hand-edit it. Expect one red migrations-fresh check before that commit lands.

 - Auto-formatted .tf files with terraform fmt
- Updated README.md with terraform-docs
Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

`make new-migration` scaffolds a header that reads "CREATE INDEX
CONCURRENTLY / VACUUM will not work here". The guard grepped the raw
file, so that sentence matched and every scaffolded migration failed
before it contained any SQL at all. This is the first new migration
since the guard landed -- the only other file, the baseline, predates
the template -- so nothing had tripped it yet.
Comments are now blanked before the destructive/CONCURRENTLY/user-insert
scans. Blanking rather than deleting the lines keeps grep -n line
numbers pointing at the real line. The allow-destructive opt-out still
reads the raw file, since that marker is itself a comment.
Verified the guard still fails a real CREATE INDEX CONCURRENTLY and a
real ALTER TABLE ... DROP COLUMN, and still passes the baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.github_folder: Reading...
data.aws_caller_identity.current: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
data.aws_vpc.default: Reading...
data.infisical_secrets.github_folder: Read complete after 0s
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.infisical_secrets.rds_folder: Read complete after 0s
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3474878989]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_4XX]
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_5XX]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
data.aws_vpc.default: Read complete after 1s [id=vpc-0a9ccfb59c8918ce9]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_security_group.rds: Refreshing state... [id=sg-0c8a1ff1676a14edb]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=4sjlu3]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=srhf9j]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=xkazax]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=14khv0]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=kmwcxq]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=elsvn3]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-ANY]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=od3a3y]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:+ create
~ update in-place
Terraform will perform the following actions:# aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_4XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_5XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_iam_role_policy.lambda_s3_objects will be created+resource"aws_iam_role_policy""lambda_s3_objects" {
+id=(known after apply)
+name="branch-lambda-s3-objects"+name_prefix=(known after apply)
+policy=jsonencode(
{
+ Statement = [
+ {
+ Action = [
+"s3:PutObject",
+"s3:GetObject",
]
+ Effect ="Allow"+ Resource ="arn:aws:s3:::c4c-branch-generated-reports20251030194253425700000001/*"+ Sid ="LambdaReportsBucketObjects"
},
]
+ Version ="2012-10-17"
}
)
+role="branch-lambda-role"
}
Plan:1 to add, 2 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@nourshoreibahnourshoreibah added the no-review The PR review bot won't run label Aug 12, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 12, 2026 02:12
@nourshoreibah
nourshoreibah merged commit adda146 into mainAug 12, 2026
23 checks passed
@nourshoreibah
nourshoreibah deleted the expense-approvals-infra branch August 12, 2026 02:12
nourshoreibah added a commit that referenced this pull request Aug 12, 2026
Conflicts in infrastructure/aws/lambda.tf and the generated
infrastructure/aws/README.md.
main's #314 added aws_iam_role_policy.lambda_s3_objects with the same actions
(s3:PutObject, s3:GetObject) on the same resource (reports_bucket) as the
lambda_reports_bucket policy added here, so the two were functionally identical.
Kept main's resource and dropped the duplicate, folding this branch's rationale
(report generation was failing AccessDenied, and a presigned GET needs the
signer to hold GetObject) into its comment.
README.md is terraform-docs output: took main's copy and dropped the
aws_s3_bucket_policy.reports_bucket_policy row, since this branch deletes that
resource. CI regenerates this file regardless.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-reviewThe PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@nourshoreibah
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(expenses): admin_notes column and lambda S3 object permissions - #314

Merged
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra
Aug 12, 2026
Merged

feat(expenses): admin_notes column and lambda S3 object permissions#314
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra

Conversation

@nourshoreibah

@nourshoreibahnourshoreibah commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Groundwork for the expense approve/deny flow, split out so it lands before the lambda code that depends on it. Migrations run against production before the new lambda code deploys, so this ordering is the required one.

Migration

20260812012951_add_expenditure_admin_notes.sql adds a nullable admin_notes TEXT to expenditures — the reviewing admin's note. Additive and forward-only; safe for the lambda code that is live right now, which simply ignores the column.

shared/types/db-types.d.ts is regenerated to match, per the commit-them-together rule in apps/backend/db/README.md.

No status enum change was needed: the baseline check constraint already permits needs_more_info, which is the value the review UI writes.

IAM

Important

The shared lambda role had no S3 permissions at all. A presigned URL carries the signer's permissions, so presigning a receipt upload would have failed AccessDenied at the browser's PUT.

Adds s3:PutObject / s3:GetObject scoped to ${aws_s3_bucket.reports_bucket.arn}/*.

This overlaps in intent with #310, which adds the same two actions while making the bucket private. If #310 merges first this becomes a redundant second inline policy under a different name (branch-lambda-s3-objects) and can be dropped in review — the permissions are identical, so there is no ordering hazard either way.

Deliberately does not touch the bucket's public-access block or bucket policy — that is #310's change, and duplicating it here would conflict.

CI guard bug

migrations-guard failed on this PR, and it turned out to be a bug in the guard rather than a problem with the migration.

make new-migration scaffolds a header containing the sentence:

-- CREATE INDEX CONCURRENTLY / VACUUM will not work here.

The guard grepped the raw file, so that sentence matched its own CONCURRENTLY check and the migration failed before it contained any SQL at all. Every migration generated by the project's own tooling would have failed this check. Nothing had tripped it yet because this is the first new migration since the guard landed — the only other file, 0000_baseline_schema.sql, predates the template.

Fix: blank out SQL comments before the destructive / CONCURRENTLY / user-insert scans. Blanking rather than deleting the lines keeps grep -n line numbers pointing at the real line. The -- allow-destructive: opt-out still reads the raw file, since that marker is itself a comment.

Verified locally that the guard still catches genuine violations:

InputResult
this migrationpass
CREATE INDEX CONCURRENTLY ... in real SQLfail (line 2)
ALTER TABLE ... DROP COLUMN under the scaffold headerfail (line 3)
0000_baseline_schema.sqlpass

Verification

terraform fmt -check clean; the terraform-docs job auto-committed the README row for the new IAM resource. terraform validate needs provider download and was not run.

Follow-up: the application PR is #315.

🤖 Generated with Claude Code

Groundwork for the expense review flow, split out so it lands before the
lambda code that depends on it.
- Adds a nullable `admin_notes TEXT` to `expenditures` for the reviewing
admin's note. Additive and forward-only.
- Grants the shared lambda role `s3:PutObject`/`s3:GetObject` on the
reports bucket. The role had no S3 permissions at all, so presigning a
receipt upload would have failed AccessDenied -- a presigned URL carries
the signer's permissions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

This PR contains a database migration

  • apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql

It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:

  • Applied and tested locally.cd apps/backend && make migrate, then run the affected lambda's tests (cd apps/backend/lambdas/<name> && npm test). make show-migrations shows what applied.
  • Safe for the code that is live right now. During the deploy window -- and indefinitely if the deploy fails -- the currently deployed lambdas run against your new schema. Additive changes (CREATE TABLE, nullable ADD COLUMN, CREATE INDEX) are fine in one PR. DROP COLUMN, renames, ADD COLUMN NOT NULL with no default, and new UNIQUE/CHECK/FOREIGN KEY constraints need two merged PRs -- see the expand/contract rules in apps/backend/db/README.md.
  • Kept separate from unrelated changes. A migration PR should ideally contain the migration, the code that needs it, and nothing else. It changes production state, it is the one thing here that redeploying cannot roll back, and a reviewer should be able to see the whole schema change without scrolling past unrelated work.
  • No already-merged migration was edited. Fix an old migration by adding a new one; there is no down.

shared/types/db-types.d.ts is regenerated and pushed to this branch automatically -- don't hand-edit it. Expect one red migrations-fresh check before that commit lands.

 - Auto-formatted .tf files with terraform fmt
- Updated README.md with terraform-docs
Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

`make new-migration` scaffolds a header that reads "CREATE INDEX
CONCURRENTLY / VACUUM will not work here". The guard grepped the raw
file, so that sentence matched and every scaffolded migration failed
before it contained any SQL at all. This is the first new migration
since the guard landed -- the only other file, the baseline, predates
the template -- so nothing had tripped it yet.
Comments are now blanked before the destructive/CONCURRENTLY/user-insert
scans. Blanking rather than deleting the lines keeps grep -n line
numbers pointing at the real line. The allow-destructive opt-out still
reads the raw file, since that marker is itself a comment.
Verified the guard still fails a real CREATE INDEX CONCURRENTLY and a
real ALTER TABLE ... DROP COLUMN, and still passes the baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.github_folder: Reading...
data.aws_caller_identity.current: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
data.aws_vpc.default: Reading...
data.infisical_secrets.github_folder: Read complete after 0s
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.infisical_secrets.rds_folder: Read complete after 0s
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3474878989]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_4XX]
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_5XX]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
data.aws_vpc.default: Read complete after 1s [id=vpc-0a9ccfb59c8918ce9]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_security_group.rds: Refreshing state... [id=sg-0c8a1ff1676a14edb]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=4sjlu3]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=srhf9j]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=xkazax]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=14khv0]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=kmwcxq]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=elsvn3]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-ANY]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=od3a3y]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:+ create
~ update in-place
Terraform will perform the following actions:# aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_4XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_5XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_iam_role_policy.lambda_s3_objects will be created+resource"aws_iam_role_policy""lambda_s3_objects" {
+id=(known after apply)
+name="branch-lambda-s3-objects"+name_prefix=(known after apply)
+policy=jsonencode(
{
+ Statement = [
+ {
+ Action = [
+"s3:PutObject",
+"s3:GetObject",
]
+ Effect ="Allow"+ Resource ="arn:aws:s3:::c4c-branch-generated-reports20251030194253425700000001/*"+ Sid ="LambdaReportsBucketObjects"
},
]
+ Version ="2012-10-17"
}
)
+role="branch-lambda-role"
}
Plan:1 to add, 2 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@nourshoreibahnourshoreibah added the no-review The PR review bot won't run label Aug 12, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 12, 2026 02:12
@nourshoreibah
nourshoreibah merged commit adda146 into mainAug 12, 2026
23 checks passed
@nourshoreibah
nourshoreibah deleted the expense-approvals-infra branch August 12, 2026 02:12
nourshoreibah added a commit that referenced this pull request Aug 12, 2026
Conflicts in infrastructure/aws/lambda.tf and the generated
infrastructure/aws/README.md.
main's #314 added aws_iam_role_policy.lambda_s3_objects with the same actions
(s3:PutObject, s3:GetObject) on the same resource (reports_bucket) as the
lambda_reports_bucket policy added here, so the two were functionally identical.
Kept main's resource and dropped the duplicate, folding this branch's rationale
(report generation was failing AccessDenied, and a presigned GET needs the
signer to hold GetObject) into its comment.
README.md is terraform-docs output: took main's copy and dropped the
aws_s3_bucket_policy.reports_bucket_policy row, since this branch deletes that
resource. CI regenerates this file regardless.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-reviewThe PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@nourshoreibah
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(expenses): admin_notes column and lambda S3 object permissions - #314

Merged
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra
Aug 12, 2026
Merged

feat(expenses): admin_notes column and lambda S3 object permissions#314
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra

Conversation

@nourshoreibah

@nourshoreibahnourshoreibah commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Groundwork for the expense approve/deny flow, split out so it lands before the lambda code that depends on it. Migrations run against production before the new lambda code deploys, so this ordering is the required one.

Migration

20260812012951_add_expenditure_admin_notes.sql adds a nullable admin_notes TEXT to expenditures — the reviewing admin's note. Additive and forward-only; safe for the lambda code that is live right now, which simply ignores the column.

shared/types/db-types.d.ts is regenerated to match, per the commit-them-together rule in apps/backend/db/README.md.

No status enum change was needed: the baseline check constraint already permits needs_more_info, which is the value the review UI writes.

IAM

Important

The shared lambda role had no S3 permissions at all. A presigned URL carries the signer's permissions, so presigning a receipt upload would have failed AccessDenied at the browser's PUT.

Adds s3:PutObject / s3:GetObject scoped to ${aws_s3_bucket.reports_bucket.arn}/*.

This overlaps in intent with #310, which adds the same two actions while making the bucket private. If #310 merges first this becomes a redundant second inline policy under a different name (branch-lambda-s3-objects) and can be dropped in review — the permissions are identical, so there is no ordering hazard either way.

Deliberately does not touch the bucket's public-access block or bucket policy — that is #310's change, and duplicating it here would conflict.

CI guard bug

migrations-guard failed on this PR, and it turned out to be a bug in the guard rather than a problem with the migration.

make new-migration scaffolds a header containing the sentence:

-- CREATE INDEX CONCURRENTLY / VACUUM will not work here.

The guard grepped the raw file, so that sentence matched its own CONCURRENTLY check and the migration failed before it contained any SQL at all. Every migration generated by the project's own tooling would have failed this check. Nothing had tripped it yet because this is the first new migration since the guard landed — the only other file, 0000_baseline_schema.sql, predates the template.

Fix: blank out SQL comments before the destructive / CONCURRENTLY / user-insert scans. Blanking rather than deleting the lines keeps grep -n line numbers pointing at the real line. The -- allow-destructive: opt-out still reads the raw file, since that marker is itself a comment.

Verified locally that the guard still catches genuine violations:

InputResult
this migrationpass
CREATE INDEX CONCURRENTLY ... in real SQLfail (line 2)
ALTER TABLE ... DROP COLUMN under the scaffold headerfail (line 3)
0000_baseline_schema.sqlpass

Verification

terraform fmt -check clean; the terraform-docs job auto-committed the README row for the new IAM resource. terraform validate needs provider download and was not run.

Follow-up: the application PR is #315.

🤖 Generated with Claude Code

Groundwork for the expense review flow, split out so it lands before the
lambda code that depends on it.
- Adds a nullable `admin_notes TEXT` to `expenditures` for the reviewing
admin's note. Additive and forward-only.
- Grants the shared lambda role `s3:PutObject`/`s3:GetObject` on the
reports bucket. The role had no S3 permissions at all, so presigning a
receipt upload would have failed AccessDenied -- a presigned URL carries
the signer's permissions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

This PR contains a database migration

  • apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql

It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:

  • Applied and tested locally.cd apps/backend && make migrate, then run the affected lambda's tests (cd apps/backend/lambdas/<name> && npm test). make show-migrations shows what applied.
  • Safe for the code that is live right now. During the deploy window -- and indefinitely if the deploy fails -- the currently deployed lambdas run against your new schema. Additive changes (CREATE TABLE, nullable ADD COLUMN, CREATE INDEX) are fine in one PR. DROP COLUMN, renames, ADD COLUMN NOT NULL with no default, and new UNIQUE/CHECK/FOREIGN KEY constraints need two merged PRs -- see the expand/contract rules in apps/backend/db/README.md.
  • Kept separate from unrelated changes. A migration PR should ideally contain the migration, the code that needs it, and nothing else. It changes production state, it is the one thing here that redeploying cannot roll back, and a reviewer should be able to see the whole schema change without scrolling past unrelated work.
  • No already-merged migration was edited. Fix an old migration by adding a new one; there is no down.

shared/types/db-types.d.ts is regenerated and pushed to this branch automatically -- don't hand-edit it. Expect one red migrations-fresh check before that commit lands.

 - Auto-formatted .tf files with terraform fmt
- Updated README.md with terraform-docs
Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

`make new-migration` scaffolds a header that reads "CREATE INDEX
CONCURRENTLY / VACUUM will not work here". The guard grepped the raw
file, so that sentence matched and every scaffolded migration failed
before it contained any SQL at all. This is the first new migration
since the guard landed -- the only other file, the baseline, predates
the template -- so nothing had tripped it yet.
Comments are now blanked before the destructive/CONCURRENTLY/user-insert
scans. Blanking rather than deleting the lines keeps grep -n line
numbers pointing at the real line. The allow-destructive opt-out still
reads the raw file, since that marker is itself a comment.
Verified the guard still fails a real CREATE INDEX CONCURRENTLY and a
real ALTER TABLE ... DROP COLUMN, and still passes the baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.github_folder: Reading...
data.aws_caller_identity.current: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
data.aws_vpc.default: Reading...
data.infisical_secrets.github_folder: Read complete after 0s
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.infisical_secrets.rds_folder: Read complete after 0s
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3474878989]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_4XX]
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_5XX]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
data.aws_vpc.default: Read complete after 1s [id=vpc-0a9ccfb59c8918ce9]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_security_group.rds: Refreshing state... [id=sg-0c8a1ff1676a14edb]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=4sjlu3]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=srhf9j]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=xkazax]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=14khv0]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=kmwcxq]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=elsvn3]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-ANY]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=od3a3y]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:+ create
~ update in-place
Terraform will perform the following actions:# aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_4XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_5XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_iam_role_policy.lambda_s3_objects will be created+resource"aws_iam_role_policy""lambda_s3_objects" {
+id=(known after apply)
+name="branch-lambda-s3-objects"+name_prefix=(known after apply)
+policy=jsonencode(
{
+ Statement = [
+ {
+ Action = [
+"s3:PutObject",
+"s3:GetObject",
]
+ Effect ="Allow"+ Resource ="arn:aws:s3:::c4c-branch-generated-reports20251030194253425700000001/*"+ Sid ="LambdaReportsBucketObjects"
},
]
+ Version ="2012-10-17"
}
)
+role="branch-lambda-role"
}
Plan:1 to add, 2 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@nourshoreibahnourshoreibah added the no-review The PR review bot won't run label Aug 12, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 12, 2026 02:12
@nourshoreibah
nourshoreibah merged commit adda146 into mainAug 12, 2026
23 checks passed
@nourshoreibah
nourshoreibah deleted the expense-approvals-infra branch August 12, 2026 02:12
nourshoreibah added a commit that referenced this pull request Aug 12, 2026
Conflicts in infrastructure/aws/lambda.tf and the generated
infrastructure/aws/README.md.
main's #314 added aws_iam_role_policy.lambda_s3_objects with the same actions
(s3:PutObject, s3:GetObject) on the same resource (reports_bucket) as the
lambda_reports_bucket policy added here, so the two were functionally identical.
Kept main's resource and dropped the duplicate, folding this branch's rationale
(report generation was failing AccessDenied, and a presigned GET needs the
signer to hold GetObject) into its comment.
README.md is terraform-docs output: took main's copy and dropped the
aws_s3_bucket_policy.reports_bucket_policy row, since this branch deletes that
resource. CI regenerates this file regardless.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-reviewThe PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@nourshoreibah
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(expenses): admin_notes column and lambda S3 object permissions - #314

Merged
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra
Aug 12, 2026
Merged

feat(expenses): admin_notes column and lambda S3 object permissions#314
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra

Conversation

@nourshoreibah

@nourshoreibahnourshoreibah commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Groundwork for the expense approve/deny flow, split out so it lands before the lambda code that depends on it. Migrations run against production before the new lambda code deploys, so this ordering is the required one.

Migration

20260812012951_add_expenditure_admin_notes.sql adds a nullable admin_notes TEXT to expenditures — the reviewing admin's note. Additive and forward-only; safe for the lambda code that is live right now, which simply ignores the column.

shared/types/db-types.d.ts is regenerated to match, per the commit-them-together rule in apps/backend/db/README.md.

No status enum change was needed: the baseline check constraint already permits needs_more_info, which is the value the review UI writes.

IAM

Important

The shared lambda role had no S3 permissions at all. A presigned URL carries the signer's permissions, so presigning a receipt upload would have failed AccessDenied at the browser's PUT.

Adds s3:PutObject / s3:GetObject scoped to ${aws_s3_bucket.reports_bucket.arn}/*.

This overlaps in intent with #310, which adds the same two actions while making the bucket private. If #310 merges first this becomes a redundant second inline policy under a different name (branch-lambda-s3-objects) and can be dropped in review — the permissions are identical, so there is no ordering hazard either way.

Deliberately does not touch the bucket's public-access block or bucket policy — that is #310's change, and duplicating it here would conflict.

CI guard bug

migrations-guard failed on this PR, and it turned out to be a bug in the guard rather than a problem with the migration.

make new-migration scaffolds a header containing the sentence:

-- CREATE INDEX CONCURRENTLY / VACUUM will not work here.

The guard grepped the raw file, so that sentence matched its own CONCURRENTLY check and the migration failed before it contained any SQL at all. Every migration generated by the project's own tooling would have failed this check. Nothing had tripped it yet because this is the first new migration since the guard landed — the only other file, 0000_baseline_schema.sql, predates the template.

Fix: blank out SQL comments before the destructive / CONCURRENTLY / user-insert scans. Blanking rather than deleting the lines keeps grep -n line numbers pointing at the real line. The -- allow-destructive: opt-out still reads the raw file, since that marker is itself a comment.

Verified locally that the guard still catches genuine violations:

InputResult
this migrationpass
CREATE INDEX CONCURRENTLY ... in real SQLfail (line 2)
ALTER TABLE ... DROP COLUMN under the scaffold headerfail (line 3)
0000_baseline_schema.sqlpass

Verification

terraform fmt -check clean; the terraform-docs job auto-committed the README row for the new IAM resource. terraform validate needs provider download and was not run.

Follow-up: the application PR is #315.

🤖 Generated with Claude Code

Groundwork for the expense review flow, split out so it lands before the
lambda code that depends on it.
- Adds a nullable `admin_notes TEXT` to `expenditures` for the reviewing
admin's note. Additive and forward-only.
- Grants the shared lambda role `s3:PutObject`/`s3:GetObject` on the
reports bucket. The role had no S3 permissions at all, so presigning a
receipt upload would have failed AccessDenied -- a presigned URL carries
the signer's permissions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

This PR contains a database migration

  • apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql

It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:

  • Applied and tested locally.cd apps/backend && make migrate, then run the affected lambda's tests (cd apps/backend/lambdas/<name> && npm test). make show-migrations shows what applied.
  • Safe for the code that is live right now. During the deploy window -- and indefinitely if the deploy fails -- the currently deployed lambdas run against your new schema. Additive changes (CREATE TABLE, nullable ADD COLUMN, CREATE INDEX) are fine in one PR. DROP COLUMN, renames, ADD COLUMN NOT NULL with no default, and new UNIQUE/CHECK/FOREIGN KEY constraints need two merged PRs -- see the expand/contract rules in apps/backend/db/README.md.
  • Kept separate from unrelated changes. A migration PR should ideally contain the migration, the code that needs it, and nothing else. It changes production state, it is the one thing here that redeploying cannot roll back, and a reviewer should be able to see the whole schema change without scrolling past unrelated work.
  • No already-merged migration was edited. Fix an old migration by adding a new one; there is no down.

shared/types/db-types.d.ts is regenerated and pushed to this branch automatically -- don't hand-edit it. Expect one red migrations-fresh check before that commit lands.

 - Auto-formatted .tf files with terraform fmt
- Updated README.md with terraform-docs
Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

`make new-migration` scaffolds a header that reads "CREATE INDEX
CONCURRENTLY / VACUUM will not work here". The guard grepped the raw
file, so that sentence matched and every scaffolded migration failed
before it contained any SQL at all. This is the first new migration
since the guard landed -- the only other file, the baseline, predates
the template -- so nothing had tripped it yet.
Comments are now blanked before the destructive/CONCURRENTLY/user-insert
scans. Blanking rather than deleting the lines keeps grep -n line
numbers pointing at the real line. The allow-destructive opt-out still
reads the raw file, since that marker is itself a comment.
Verified the guard still fails a real CREATE INDEX CONCURRENTLY and a
real ALTER TABLE ... DROP COLUMN, and still passes the baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.github_folder: Reading...
data.aws_caller_identity.current: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
data.aws_vpc.default: Reading...
data.infisical_secrets.github_folder: Read complete after 0s
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.infisical_secrets.rds_folder: Read complete after 0s
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3474878989]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_4XX]
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_5XX]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
data.aws_vpc.default: Read complete after 1s [id=vpc-0a9ccfb59c8918ce9]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_security_group.rds: Refreshing state... [id=sg-0c8a1ff1676a14edb]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=4sjlu3]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=srhf9j]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=xkazax]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=14khv0]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=kmwcxq]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=elsvn3]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-ANY]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=od3a3y]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:+ create
~ update in-place
Terraform will perform the following actions:# aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_4XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_5XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_iam_role_policy.lambda_s3_objects will be created+resource"aws_iam_role_policy""lambda_s3_objects" {
+id=(known after apply)
+name="branch-lambda-s3-objects"+name_prefix=(known after apply)
+policy=jsonencode(
{
+ Statement = [
+ {
+ Action = [
+"s3:PutObject",
+"s3:GetObject",
]
+ Effect ="Allow"+ Resource ="arn:aws:s3:::c4c-branch-generated-reports20251030194253425700000001/*"+ Sid ="LambdaReportsBucketObjects"
},
]
+ Version ="2012-10-17"
}
)
+role="branch-lambda-role"
}
Plan:1 to add, 2 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@nourshoreibahnourshoreibah added the no-review The PR review bot won't run label Aug 12, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 12, 2026 02:12
@nourshoreibah
nourshoreibah merged commit adda146 into mainAug 12, 2026
23 checks passed
@nourshoreibah
nourshoreibah deleted the expense-approvals-infra branch August 12, 2026 02:12
nourshoreibah added a commit that referenced this pull request Aug 12, 2026
Conflicts in infrastructure/aws/lambda.tf and the generated
infrastructure/aws/README.md.
main's #314 added aws_iam_role_policy.lambda_s3_objects with the same actions
(s3:PutObject, s3:GetObject) on the same resource (reports_bucket) as the
lambda_reports_bucket policy added here, so the two were functionally identical.
Kept main's resource and dropped the duplicate, folding this branch's rationale
(report generation was failing AccessDenied, and a presigned GET needs the
signer to hold GetObject) into its comment.
README.md is terraform-docs output: took main's copy and dropped the
aws_s3_bucket_policy.reports_bucket_policy row, since this branch deletes that
resource. CI regenerates this file regardless.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-reviewThe PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@nourshoreibah
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(expenses): admin_notes column and lambda S3 object permissions - #314

Merged
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra
Aug 12, 2026
Merged

feat(expenses): admin_notes column and lambda S3 object permissions#314
nourshoreibah merged 3 commits into
mainfrom
expense-approvals-infra

Conversation

@nourshoreibah

@nourshoreibahnourshoreibah commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Groundwork for the expense approve/deny flow, split out so it lands before the lambda code that depends on it. Migrations run against production before the new lambda code deploys, so this ordering is the required one.

Migration

20260812012951_add_expenditure_admin_notes.sql adds a nullable admin_notes TEXT to expenditures — the reviewing admin's note. Additive and forward-only; safe for the lambda code that is live right now, which simply ignores the column.

shared/types/db-types.d.ts is regenerated to match, per the commit-them-together rule in apps/backend/db/README.md.

No status enum change was needed: the baseline check constraint already permits needs_more_info, which is the value the review UI writes.

IAM

Important

The shared lambda role had no S3 permissions at all. A presigned URL carries the signer's permissions, so presigning a receipt upload would have failed AccessDenied at the browser's PUT.

Adds s3:PutObject / s3:GetObject scoped to ${aws_s3_bucket.reports_bucket.arn}/*.

This overlaps in intent with #310, which adds the same two actions while making the bucket private. If #310 merges first this becomes a redundant second inline policy under a different name (branch-lambda-s3-objects) and can be dropped in review — the permissions are identical, so there is no ordering hazard either way.

Deliberately does not touch the bucket's public-access block or bucket policy — that is #310's change, and duplicating it here would conflict.

CI guard bug

migrations-guard failed on this PR, and it turned out to be a bug in the guard rather than a problem with the migration.

make new-migration scaffolds a header containing the sentence:

-- CREATE INDEX CONCURRENTLY / VACUUM will not work here.

The guard grepped the raw file, so that sentence matched its own CONCURRENTLY check and the migration failed before it contained any SQL at all. Every migration generated by the project's own tooling would have failed this check. Nothing had tripped it yet because this is the first new migration since the guard landed — the only other file, 0000_baseline_schema.sql, predates the template.

Fix: blank out SQL comments before the destructive / CONCURRENTLY / user-insert scans. Blanking rather than deleting the lines keeps grep -n line numbers pointing at the real line. The -- allow-destructive: opt-out still reads the raw file, since that marker is itself a comment.

Verified locally that the guard still catches genuine violations:

InputResult
this migrationpass
CREATE INDEX CONCURRENTLY ... in real SQLfail (line 2)
ALTER TABLE ... DROP COLUMN under the scaffold headerfail (line 3)
0000_baseline_schema.sqlpass

Verification

terraform fmt -check clean; the terraform-docs job auto-committed the README row for the new IAM resource. terraform validate needs provider download and was not run.

Follow-up: the application PR is #315.

🤖 Generated with Claude Code

Groundwork for the expense review flow, split out so it lands before the
lambda code that depends on it.
- Adds a nullable `admin_notes TEXT` to `expenditures` for the reviewing
admin's note. Additive and forward-only.
- Grants the shared lambda role `s3:PutObject`/`s3:GetObject` on the
reports bucket. The role had no S3 permissions at all, so presigning a
receipt upload would have failed AccessDenied -- a presigned URL carries
the signer's permissions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

This PR contains a database migration

  • apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql

It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:

  • Applied and tested locally.cd apps/backend && make migrate, then run the affected lambda's tests (cd apps/backend/lambdas/<name> && npm test). make show-migrations shows what applied.
  • Safe for the code that is live right now. During the deploy window -- and indefinitely if the deploy fails -- the currently deployed lambdas run against your new schema. Additive changes (CREATE TABLE, nullable ADD COLUMN, CREATE INDEX) are fine in one PR. DROP COLUMN, renames, ADD COLUMN NOT NULL with no default, and new UNIQUE/CHECK/FOREIGN KEY constraints need two merged PRs -- see the expand/contract rules in apps/backend/db/README.md.
  • Kept separate from unrelated changes. A migration PR should ideally contain the migration, the code that needs it, and nothing else. It changes production state, it is the one thing here that redeploying cannot roll back, and a reviewer should be able to see the whole schema change without scrolling past unrelated work.
  • No already-merged migration was edited. Fix an old migration by adding a new one; there is no down.

shared/types/db-types.d.ts is regenerated and pushed to this branch automatically -- don't hand-edit it. Expect one red migrations-fresh check before that commit lands.

 - Auto-formatted .tf files with terraform fmt
- Updated README.md with terraform-docs
Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

`make new-migration` scaffolds a header that reads "CREATE INDEX
CONCURRENTLY / VACUUM will not work here". The guard grepped the raw
file, so that sentence matched and every scaffolded migration failed
before it contained any SQL at all. This is the first new migration
since the guard landed -- the only other file, the baseline, predates
the template -- so nothing had tripped it yet.
Comments are now blanked before the destructive/CONCURRENTLY/user-insert
scans. Blanking rather than deleting the lines keeps grep -n line
numbers pointing at the real line. The allow-destructive opt-out still
reads the raw file, since that marker is itself a comment.
Verified the guard still fails a real CREATE INDEX CONCURRENTLY and a
real ALTER TABLE ... DROP COLUMN, and still passes the baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.github_folder: Reading...
data.aws_caller_identity.current: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
data.aws_vpc.default: Reading...
data.infisical_secrets.github_folder: Read complete after 0s
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.infisical_secrets.rds_folder: Read complete after 0s
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3474878989]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_4XX]
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_5XX]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
data.aws_vpc.default: Read complete after 1s [id=vpc-0a9ccfb59c8918ce9]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_security_group.rds: Refreshing state... [id=sg-0c8a1ff1676a14edb]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=4sjlu3]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=srhf9j]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=xkazax]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=14khv0]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=kmwcxq]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=elsvn3]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-ANY]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=od3a3y]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:+ create
~ update in-place
Terraform will perform the following actions:# aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_4XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be updated in-place
~ resource"aws_api_gateway_gateway_response""cors" {
id="aggr-2apxzxb0r8-DEFAULT_5XX"
~ response_templates={
-"application/json" = "{\"message\":$context.error.messageString}"->null
}
# (5 unchanged attributes hidden)
}
# aws_iam_role_policy.lambda_s3_objects will be created+resource"aws_iam_role_policy""lambda_s3_objects" {
+id=(known after apply)
+name="branch-lambda-s3-objects"+name_prefix=(known after apply)
+policy=jsonencode(
{
+ Statement = [
+ {
+ Action = [
+"s3:PutObject",
+"s3:GetObject",
]
+ Effect ="Allow"+ Resource ="arn:aws:s3:::c4c-branch-generated-reports20251030194253425700000001/*"+ Sid ="LambdaReportsBucketObjects"
},
]
+ Version ="2012-10-17"
}
)
+role="branch-lambda-role"
}
Plan:1 to add, 2 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@nourshoreibahnourshoreibah added the no-review The PR review bot won't run label Aug 12, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 12, 2026 02:12
@nourshoreibah
nourshoreibah merged commit adda146 into mainAug 12, 2026
23 checks passed
@nourshoreibah
nourshoreibah deleted the expense-approvals-infra branch August 12, 2026 02:12
nourshoreibah added a commit that referenced this pull request Aug 12, 2026
Conflicts in infrastructure/aws/lambda.tf and the generated
infrastructure/aws/README.md.
main's #314 added aws_iam_role_policy.lambda_s3_objects with the same actions
(s3:PutObject, s3:GetObject) on the same resource (reports_bucket) as the
lambda_reports_bucket policy added here, so the two were functionally identical.
Kept main's resource and dropped the duplicate, folding this branch's rationale
(report generation was failing AccessDenied, and a presigned GET needs the
signer to hold GetObject) into its comment.
README.md is terraform-docs output: took main's copy and dropped the
aws_s3_bucket_policy.reports_bucket_policy row, since this branch deletes that
resource. CI regenerates this file regardless.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-reviewThe PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@nourshoreibah