Skip to content

fix: address audit findings across lambdas, frontend and infra - #310

Merged
nourshoreibah merged 8 commits into
mainfrom
worktree-fix-audit-findings
Aug 12, 2026
Merged

fix: address audit findings across lambdas, frontend and infra#310
nourshoreibah merged 8 commits into
mainfrom
worktree-fix-audit-findings

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

Fixes the actionable findings from a full-repo bug scan. Findings already covered by open PRs (#301#307) are deliberately untouched — see the bottom section.

Critical

The generated-reports S3 bucket was world-readable. All four block_public_* were false plus a bucket policy granting s3:GetObject to Principal: "*". Reports embed member names and emails, donor contacts, and every expenditure amount, at predictable keys (reports/{projectId}/{ISO-timestamp}.pdf). Bucket is now fully private and the policy is gone.

The lambda role had no S3 permissions at all — only AWSLambdaBasicExecutionRole + Cognito. So POST /reports/generate has been failing AccessDenied in production. Added s3:PutObject/s3:GetObject on the reports bucket; GetObject is also required because a presigned URL carries the signer's permissions.

Any invite containing an uppercase letter created a permanently unclaimable account. validateEmail returned the string verbatim, so POST /users stored Alice@Branch.org, while POST /auth/register looks up email.toLowerCase() — it missed and returned 403 INVITATION_REQUIRED, indistinguishable from "never invited". Emails are now normalized at the single validation choke point.

Correctness

  • POST /auth/register now checks numUpdatedRows on the invitation claim. A no-op claim still returned 201, leaving a Cognito user whose sub referenced no row — every later login failed, unfixable without manual SQL. Rolls back the Cognito user and returns 409 ALREADY_CLAIMED; the auto-link path got the same check.
  • DELETE /users/{userId} deletes the Cognito user too, so the address can be re-invited. Without this, once feat(auth): #243 Admin Invite User #302 lands, delete-then-reinvite 409s forever.
  • PATCH /users/{userId} rejects email changes — email is the Cognito username and nothing synced it, so a self-service change silently broke sign-in and password reset.
  • POST /donations returns 404 for a missing donor/project instead of 500 (23503 was unhandled), and accepts numeric strings for amount/ids since the column is NUMERIC(12,2) and forms post strings.
  • GET /projects/{id}/donors selects explicit columns. selectAll() over a 3-table join emitted p.*, bpd.*, bd.*, collided on project_id, and leaked the whole project row into a donors list.
  • Non-numeric path ids are rejected on the users and projects {id} routes; they previously reached Postgres as NaN and surfaced as 500s.
  • Stopped logging every user row (emails, admin flags) to CloudWatch on GET /users.

Half-built features finished

  • GET /reports/{id}/download{ downloadUrl, expiresIn: 900 }. Reports could be generated but never retrieved: nothing presigned a GET, and the frontend used object_url only to compute a format label. Same auth + checkProjectAccess as GET /reports/{id}; 409 if the stored URL isn't ours.
  • Reports bulk delete now works. It was stubbed behind a stale comment claiming no DELETE endpoint existed — DELETE /reports/{id} has been there all along.
  • Reports page moved to server-side pagination (it fetched every row and sliced client-side), and delete/download failures land in a non-blocking banner rather than the state that gates the table.
  • POST /reports/generate accepts report_type and returns file_type; it previously hardcoded 'technical' even for a .docx.

Cleanup

  • One canonical region-qualified S3 URL helper — the two call sites disagreed, and the region-less form only resolved via a redirect some clients don't follow.
  • Dropped the unused DonationValidationUtils import (its camelCase keys and amount: 0 tolerance conflict with this route's contract and tests).
  • Reconciled the duplicated auth DTOs: isAdmin is now required in both. Full dedup is blocked by packaging — neither shared/types nor shared/lambda-auth can resolve the other without a new cross-dependency, and shared/types guarantees zero dependencies. Proper fix is a third types-only package.
  • Gitignored lambda.zip (~5 MB each, previously one git add -A from being committed).

Test-safety fix worth reviewing

The users-lambda tests now mock @aws-sdk/client-cognito-identity-provider. CI injects a real COGNITO_USER_POOL_ID, and the DELETE tests target path: '/1' — seeded ashley@branch.org. Without the mock, every CI run would issue a live AdminDeleteUser against the production pool, which becomes destructive the moment that address has a Cognito user. Verified zero live calls, including under a real-looking pool id.

Verification

  • tsc --noEmit: clean across all six lambdas + frontend.
  • jest: users 52/52, reports 103/103, projects 75/75, frontend 25/25 suites (259 passed, 2 pre-existing skips).
  • donors 50/51 and auth 67/70 — every failure is a test that fetches http://localhost:3000 with no dev server running; confirmed unrelated to these changes.
  • terraform fmt -check -recursive infrastructure/ clean.
  • Ten tests were updated where they asserted the old buggy behaviour (PATCH email, the leaked project row, the 500-on-invalid-id) plus mock-harness updates. No new test files.
  • prettier --check fails on 30 files, including ones untouched here — pre-existing repo-wide, so not reformatted.

Merge ordering

Additive-only overlaps with open PRs, but worth sequencing deliberately:

Not addressed

Three local .env files hold long-lived AKIA… IAM keys (apps/backend/.env, apps/backend/lambdas/{auth,reports}/.env). All are gitignored and absent from git ls-files, so nothing leaked — but static keys for a shared account are the wrong shape. Recommend rotating to short-lived credentials and confirming they aren't the deploy keys. Not a code change, so out of scope here.

🤖 Generated with Claude Code

Fixes the actionable findings from a full-repo bug scan. Findings already
covered by open PRs (#301-#307) are deliberately untouched.

Security / data exposure:
- Lock down the generated-reports S3 bucket. All four block_public_* were
  false and a bucket policy granted s3:GetObject to Principal "*", so
  reports (member emails, donor contacts, expenditure amounts) were
  world-readable at predictable keys. Reports are now served only through a
  presigned GET.
- Grant the lambda role s3:PutObject/s3:GetObject on the reports bucket. It
  had no S3 permissions at all, so POST /reports/generate was failing
  AccessDenied; GetObject is additionally required because a presigned URL
  carries the signer's permissions.
- Validate objectUrl on POST /reports against the bucket's own host, so an
  arbitrary (e.g. javascript:) URL can no longer be stored and rendered.
- Sanitize fileName before interpolating it into an S3 key in
  GET /reports/upload-url.
- Stop logging every user row (emails, admin flags) to CloudWatch.

Correctness:
- Lowercase emails in UserValidationUtils.validateEmail. POST /users stored
  them verbatim while POST /auth/register looks up email.toLowerCase(), so
  any invite containing uppercase was permanently unclaimable (403
  INVITATION_REQUIRED).
- POST /auth/register now checks numUpdatedRows on the invitation claim. A
  no-op claim previously still returned 201, leaving a Cognito user whose
  sub referenced no row, which broke every later login. Same check on the
  auto-link path.
- DELETE /users/{userId} deletes the Cognito user too, so the address can be
  re-invited.
- PATCH /users/{userId} rejects email changes. Email is the Cognito username
  and nothing synced it, so a change silently broke sign-in and reset.
- POST /donations returns 404 for a missing donor/project instead of 500, and
  accepts numeric strings for amount and ids.
- GET /projects/{id}/donors selects explicit columns; selectAll() over a
  3-table join collided project_id and leaked the whole project row.
- Reject non-numeric path ids on the users and projects {id} routes, which
  reached Postgres as NaN and surfaced as 500s.

Features that were half-built:
- Add GET /reports/{id}/download returning a presigned URL. Reports could be
  generated but never retrieved; the frontend used object_url only for a
  format label.
- Wire up bulk delete on the reports page. It was stubbed behind a stale
  comment claiming no DELETE endpoint existed, though DELETE /reports/{id}
  has been there all along.
- Switch the reports page to server-side pagination and surface transient
  delete/download failures in a non-blocking banner.
- Pass report_type through POST /reports/generate and return file_type.

Cleanup:
- Single canonical region-qualified S3 URL helper; the two call sites
  disagreed and the region-less form only resolved via a redirect.
- Drop the unused DonationValidationUtils import.
- Reconcile the duplicated auth DTOs (isAdmin is now required in both).
  Full dedup needs a packaging change, since neither shared package can
  resolve the other without a new cross-dependency.
- Gitignore lambda.zip.

Users-lambda tests now mock the Cognito SDK: CI injects a real user pool id,
and the DELETE tests target seeded ashley@branch.org, so they would otherwise
have issued live AdminDeleteUser calls against production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
github-actions Bot added a commit that referenced this pull request Aug 12, 2026
  - Auto-formatted .tf files with terraform fmt
  - Updated README.md with terraform-docs

  Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@nourshoreibah nourshoreibah added the test-environment Creates a temporary (nearly free) test environment. Uses prod DB and cognito label Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🌿 ⏳ Creating preview environment… (logs)

@nourshoreibah
nourshoreibah requested a lite review from Copilot August 12, 2026 01:14
@nourshoreibah nourshoreibah added the no-review The PR review bot won't run label Aug 12, 2026
github-actions Bot added a commit that referenced this pull request Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🌿 Preview environment — ready ✅

Open: https://d3nmtjoh6ir9ym.cloudfront.net/pr-310/
API: https://i9211h634g.execute-api.us-east-2.amazonaws.com/prod

Shared RDS + Cognito (prod data); DB migrations are not applied here — if this PR adds a migration, endpoints using the new columns will fail until it merges. New commits update this environment in place — a note is posted here on each update. Remove the test-environment label or close the PR to tear it down.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Addresses repo-wide audit findings spanning infrastructure, backend lambdas, shared types, and the frontend—primarily tightening S3 access for generated reports, fixing Cognito/user lifecycle edge cases, and completing report download + pagination flows.

Changes:

  • Infrastructure: make the reports S3 bucket fully private and grant the shared Lambda role scoped s3:GetObject/s3:PutObject permissions for reports.
  • Backend: add presigned download support for reports, normalize email validation, harden auth registration invitation-claim/linking correctness, delete Cognito users on user deletion, and improve ID/DB error handling across routes.
  • Frontend: move reports to server-side pagination, add download + working bulk delete UX, and update tests accordingly.

Reviewed changes

Copilot reviewed 23 out of 26 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
shared/types/auth-types.d.ts Makes isAdmin required on the shared AuthenticatedUser DTO.
infrastructure/aws/s3.tf Locks down reports bucket public access (removes public policy + blocks public settings).
infrastructure/aws/README.md Updates generated Terraform resource inventory to reflect new IAM policy and removed bucket policy.
infrastructure/aws/lambda.tf Adds IAM policy for reports bucket read/write; updates Cognito admin-policy comment.
infrastructure/AGENTS.md Updates infra docs to reflect reports bucket privacy and new IAM permissions.
apps/frontend/test/components/ReportsPage.test.tsx Updates mocks/assertions to the new paginated /reports?page=&limit= fetch contract.
apps/frontend/src/app/reports/page.tsx Implements server-side pagination, bulk delete via per-report DELETE, and presigned download UX.
apps/backend/lambdas/users/validation-utils.ts Normalizes emails (trim + lowercase) at validation choke point.
apps/backend/lambdas/users/test/users.test.ts Mocks Cognito SDK to prevent destructive live calls in CI; updates PATCH expectations.
apps/backend/lambdas/users/test/user.unit.test.ts Mocks Cognito SDK in unit tests; updates PATCH expectations around immutable email.
apps/backend/lambdas/users/package.json Adds Cognito Identity Provider AWS SDK dependency for AdminDeleteUser.
apps/backend/lambdas/users/package-lock.json Locks new Cognito client dependency (and associated lockfile updates).
apps/backend/lambdas/users/handler.ts Adds Cognito delete-on-user-delete, rejects non-numeric IDs, and makes email immutable on PATCH.
apps/backend/lambdas/reports/test/reports.unit.test.ts Extends report-service mock with objectUrlFor/keyFromObjectUrl helpers.
apps/backend/lambdas/reports/test/reports.e2e.test.ts Sets REPORTS_BUCKET_NAME env for URL helper tests.
apps/backend/lambdas/reports/report-service.ts Introduces canonical region-qualified S3 URL helper + key extraction/validation.
apps/backend/lambdas/reports/README.md Documents new GET /reports/{id}/download endpoint.
apps/backend/lambdas/reports/openapi.yaml Adds OpenAPI spec for presigned report download endpoint.
apps/backend/lambdas/reports/handler.ts Adds GET /reports/{id}/download, filename sanitization, report_type support, URL helper usage.
apps/backend/lambdas/projects/test/projects.unit.test.ts Updates invalid-id expectation from 500 to 400.
apps/backend/lambdas/projects/test/example.test.ts Removes assertions that depended on leaking project columns in donors list responses.
apps/backend/lambdas/projects/handler.ts Rejects non-numeric IDs and replaces broad selectAll() in donors join with explicit columns.
apps/backend/lambdas/donors/handler.ts Accepts numeric strings for IDs/amount, adds FK-not-found handling, removes unused import.
apps/backend/lambdas/auth/test/auth.login.unit.test.ts Adjusts mocks to assert/update on executeTakeFirst result for invitation claims.
apps/backend/lambdas/auth/handler.ts Checks numUpdatedRows for invitation claim/link paths; refactors Cognito rollback helper.
.gitignore Ignores lambda.zip build artifacts.
Files not reviewed (1)
  • apps/backend/lambdas/users/package-lock.json: Generated file
Suppressed comments (2)

apps/backend/lambdas/reports/handler.ts:306

  • GET /reports/{id}/download signs whatever key is embedded in report.object_url as long as it is in the reports bucket. If a report row were ever created with a cross-project key, this endpoint would leak the other object's contents. Enforce that the key is under the expected reports/${report.project_id}/ prefix before minting a presigned URL.
      const key = keyFromObjectUrl(report.object_url);
      if (!key) {
        return json(409, { message: 'Report is not stored in the reports bucket' });
      }

      const downloadUrl = await getSignedUrl(s3, new GetObjectCommand({
        Bucket: BUCKET,
        Key: key,
      }), { expiresIn: DOWNLOAD_URL_TTL_SECONDS });

apps/backend/lambdas/donors/handler.ts:220

  • The POST /donations try/catch block is also mis-indented, which obscures which statements are inside the try vs after it. Re-indent this section to keep the error handling and return paths clear.
      try {
      const donation = await db
        .insertInto('branch.project_donations')
        .values({
          donor_id: donorId,
          project_id: projectId,
          amount: donationAmount,
        })
        .returningAll()
        .executeTakeFirstOrThrow();

      return json(201, { data: donation });
    } catch (err: any) {
      if (err?.code === '23505') {
        return json(409, { message: 'A donation from this donor to this project already exists' });
      }
      if (err?.code === '23503') {
        return json(404, { message: 'Donor or project not found' });
      }
      throw err;
    }
    }

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/backend/lambdas/reports/handler.ts Outdated
Comment on lines +256 to +258
if (!keyFromObjectUrl(objectUrl)) {
return json(400, { message: 'objectUrl must point at the reports bucket' });
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed. The path was real: a member of project A could POST a row with project_id: A and an objectUrl under reports/B/ (host check passes, checkProjectAccess(A) passes), then read project B's object back via the download route, which authorizes off report.project_id.

Keys are now bound to their project through a single reportKeyPrefix(projectId) helper used for both construction and validation, so the two can't drift:

  • POST /reports rejects a key outside the project's prefix.
  • GET /reports/{id}/download re-checks the stored key against report.project_id before presigning, which also covers any row written before this validation existed.

Two notes on the details. I placed the prefix check after the project-existence and checkProjectAccess calls rather than with the other body validation, so 404/403 still take precedence and an unauthorized caller doesn't learn anything about key layout. And all existing rows satisfy the prefix — both key-construction paths (uploadToS3 and /reports/upload-url) already used reports/{projectId}/ — so this is not a breaking change for stored data. Added one regression test for the cross-project rejection.

Comment on lines 149 to +152
useEffect(() => {
fetchReports();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. selectedIds is now cleared in the [currentPage] effect.

Verified the failure mode before changing it: selection genuinely survived a page change, and handleDeleteSelected iterates all of selectedIds, so selecting a row on page 1 and hitting Delete from page 2 removed the page-1 row with nothing on screen indicating it. That's a regression I introduced in this PR — under the previous client-side pagination reports held every row, so a cross-page selection was at least coherent.

Comment on lines +206 to +214
let cognitoDeleted = true;
try {
await cognitoClient.send(new AdminDeleteUserCommand({ UserPoolId: USER_POOL_ID, Username: user.email }));
} catch (err: any) {
if (err?.name !== 'UserNotFoundException') {
console.error('Cognito delete error:', err);
cognitoDeleted = false;
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly. The remedy you describe was already in place, so I implemented the narrower improvement.

An empty UserPoolId makes AdminDeleteUser throw InvalidParameterException, which is not UserNotFoundException, so it already fell into the catch that sets cognitoDeleted = false. The response therefore already reported cognitoDeleted: false rather than a silent success — I saw exactly this locally, where the pool id is unset.

What was genuinely worth fixing is the part about attempting the call: it can never succeed, and the resulting error is an opaque SDK exception rather than a clear signal. It now short-circuits when USER_POOL_ID is empty, logs it as a configuration error, and sets cognitoDeleted = false without the round trip.

I did not take the "fail the request" option. By that point the branch.users row is already deleted, so a 4xx/5xx would misreport what happened; 200 plus cognitoDeleted: false is the accurate contract for a partial success. Worth noting the deployed lambdas always have COGNITO_USER_POOL_ID set — infrastructure/aws/lambda.tf declares it in an authoritative environment block — so this is a local-dev and misconfiguration guard rather than a production path.

Comment thread apps/backend/lambdas/donors/handler.ts Outdated
Comment on lines 164 to 176
@@ -168,13 +175,34 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Re-indented both the membership block and the try/catch in POST /donations.

The concern was accurate: the membership if body sat at the same level as the if itself and its closing brace was outdented two spaces, so it read as though it closed the route block rather than the conditional — which is exactly the kind of thing that gets mis-edited. Same shape on the try.

Whitespace only, no behavioural change; donors tests are unchanged at 50 passing (the one failure is the pre-existing health test, which fetches localhost:3000 and needs the lambda running). Note this file's GET routes are being rewritten by #301, so I kept the reformatting strictly inside POST /donations to avoid widening that merge.

The auth DTOs were declared twice, in shared/types/auth-types.d.ts and
shared/lambda-auth/src/types.ts, and had already drifted (isAdmin was optional
in one and required in the other). The previous commit only reconciled the two
copies and left a "keep these in sync" comment, which is just documented
duplication.

shared/lambda-auth now takes a file: dependency on @branch/types and re-exports
the DTOs from it, so there is exactly one declaration. @branch/types stays a
dependency-free leaf, which is what keeps the edge acyclic; lambdas are
unaffected because they already depend on both packages, and the types are
erased at compile time so nothing reaches the bundle.

Adding a dependency to shared/lambda-auth changes the resolved tree for every
lambda, so all six package-lock.json files are regenerated. They were already
stale: none recorded lambda-auth's devDependencies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🌿 Test environment updated in place ✅ — Click here to open. updated for cbbdcfb · logs

Addresses the four review comments on #310.

The significant one: POST /reports only checked that objectUrl pointed at the
reports bucket, not that the key belonged to the project being written. A caller
with access to project A could register a row with project_id A and an objectUrl
under reports/B/, then read project B's report back through
GET /reports/{id}/download, which authorizes off report.project_id. That
defeats the access control this PR set out to add.

Keys are now bound to their project via a single reportKeyPrefix() helper used
for both construction and validation: POST /reports rejects a key outside the
project's prefix, and the download route re-checks the stored key against
report.project_id before presigning. The prefix check runs after the access
check so 403/404 still take precedence. All existing rows match the prefix,
since both key-construction paths already used it.

Also:
- Clear the reports-page selection on page change. With server-side pagination
  selectedIds could retain rows from a previous page and bulk delete would
  remove them unseen.
- Skip the Cognito delete when COGNITO_USER_POOL_ID is unset instead of making a
  call that cannot succeed, and log it as a configuration error. cognitoDeleted
  already reported false in this case via the InvalidParameterException path.
- Re-indent the POST /donations membership and try/catch blocks, whose closing
  braces read as though they closed the route.

One regression test covers the cross-project key rejection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🌿 Test environment updated in place ✅ — Click here to open. updated for 4447021 · logs

@github-actions

Copy link
Copy Markdown
Contributor

🌿 Test environment updated in place ✅ — Click here to open. updated for 948428e · logs

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.
@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]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
data.aws_caller_identity.current: Reading...
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
data.aws_vpc.default: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
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]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
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_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
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_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
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["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
data.aws_vpc.default: Read complete after 1s [id=vpc-0a9ccfb59c8918ce9]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
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_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
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["reports"]: Refreshing state... [id=elsvn3]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
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_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-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["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_iam_role_policy.lambda_s3_objects: Refreshing state... [id=branch-lambda-role:branch-lambda-s3-objects]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.infisical_secrets.github_folder: Reading...
data.infisical_secrets.rds_folder: Reading...
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-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["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/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_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
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]
data.infisical_secrets.rds_folder: Read complete after 1s
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
data.infisical_secrets.github_folder: Read complete after 1s
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
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_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_lambda_permission.api_gateway_permissions["users"]: 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_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_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:
  ~ update in-place
  - destroy

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_s3_bucket_policy.reports_bucket_policy will be destroyed
  # (because aws_s3_bucket_policy.reports_bucket_policy is not in configuration)
  - resource "aws_s3_bucket_policy" "reports_bucket_policy" {
      - bucket = "c4c-branch-generated-reports20251030194253425700000001" -> null
      - id     = "c4c-branch-generated-reports20251030194253425700000001" -> null
      - policy = jsonencode(
            {
              - Statement = [
                  - {
                      - Action    = "s3:GetObject"
                      - Effect    = "Allow"
                      - Principal = "*"
                      - Resource  = "arn:aws:s3:::c4c-branch-generated-reports20251030194253425700000001/*"
                      - Sid       = "PublicReadGetObject"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> null
      - region = "us-east-2" -> null
    }

  # aws_s3_bucket_public_access_block.reports_bucket_public_access will be updated in-place
  ~ resource "aws_s3_bucket_public_access_block" "reports_bucket_public_access" {
      ~ block_public_acls       = false -> true
      ~ block_public_policy     = false -> true
        id                      = "c4c-branch-generated-reports20251030194253425700000001"
      ~ ignore_public_acls      = false -> true
      ~ restrict_public_buckets = false -> true
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 3 to change, 1 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

🌿 Test environment updated in place ✅ — Click here to open. updated for 50e33c7 · logs

@nourshoreibah
nourshoreibah merged commit e6f4509 into main Aug 12, 2026
18 checks passed
@nourshoreibah
nourshoreibah deleted the worktree-fix-audit-findings branch August 12, 2026 02:29
@github-actions

Copy link
Copy Markdown
Contributor

🌿 Preview environment torn down 🧹 — the stack for this PR has been destroyed.

nourshoreibah added a commit that referenced this pull request Aug 12, 2026
Resolves conflicts between the admin-only dashboard and main's expense
approval flow (#315), project role rename (#311) and audit fixes (#310):

- routes: /dashboard is admin-gated, /expenses is not. Main opened
  /expenses to non-admins because they submit and read their own
  expenses there; only the review modal's approve/deny is admin-gated.
- accounts: both sides moved the staff roster out of page.tsx to satisfy
  the Next.js page-export rule. Kept main's mockUsers.ts and dropped the
  duplicate staff.ts.
- Navbar/routes tests follow the same split.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-review The PR review bot won't run test-environment Creates a temporary (nearly free) test environment. Uses prod DB and cognito

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants