Skip to main content

Store Invite Authorization and Lifecycle Remediation Report

Date: 2026-08-21 Affected services: merchant-api, auth Affected routes: POST /api/v1/stores/{storeId}/invites, POST /api/v1/invites/validate, POST /api/v1/invites/accept Reported environment: staging Initially observed revision: peakpos-staging-merchant--r32447865439-1

Executive Summary

A merchant organization owner received 403 FORBIDDEN while inviting an employee to a store. The request reached merchant-api; it was not rejected by request validation, routing, or authentication.

The original failure was a nested store-role authorization bug. A selected-store Peak authorization token was treated as a store-only actor even when its organization permission bucket contained org.members.admin. The role-assignment guard then required the caller to possess the exact store job function being assigned.

A separate invitation lifecycle bug caused the reported follow-on symptoms. auth automatically changed open merchant invites to accepted when the invitee first loaded merchant account context, before the invite acceptance endpoint provisioned IAM roles. The portal then rejected validation as already accepted, while store membership remained absent. Login must update login metadata only; explicit invite acceptance owns the status transition and IAM redemption.

Evidence

  • Staging returned 403 for the reported POST /api/v1/stores/{storeId}/invites request at 2026-08-21T06:57:25Z and 2026-08-21T06:59:33Z.
  • Both requests were logged by com.myriad.merchant_api.exception.GlobalExceptionHandler on revision peakpos-staging-merchant--r32447865439-1.
  • The auth service issued selected-store merchant tokens immediately before the failures (requested_store=true), followed by organization-scoped refreshes. This matches the failing branch in AuthorizationService.requireOrgAdminToAssignRoleForStore where claims.storeId == storeId.
  • The prior invite remediation changed StoreInviteController from requireStoreAdmin to requireStoreMemberWriteAccess, but it did not change the subsequent store-role assignment guard.

Root Cause

StoreInviteController.create performs these checks:

  1. @RequiresPermission(ORG_INVITES_WRITE) validates the invite capability.
  2. requireStoreMemberWriteAccess validates the store/org scope.
  3. requireOrgAdminToAssignRoleForStore validates the requested store job function.

Before this remediation, step 3 applied this rule whenever the token selected the requested store:

claims.storeId == storeId -> require store.members.admin
-> require caller possesses target role

The second condition is valid for a store-scoped manager delegating a role under the existing role-possession policy. It is not valid for an organization administrator/owner. Organization-level administrators carry org.admin or org.members.admin in the token's organization bucket and are authorized to assign a different store role to the invitee. The code ignored that organization-level authority whenever claims.storeId was populated.

The resulting bare ForbiddenException() was converted by the global exception handler to the observed generic Access denied response.

The follow-on lifecycle failure was in MerchantAuthService.updateLastLoginIfDue. After a merchant login-context read, it called acceptOpenByEmail, which changed every pending or expired invite for that email to accepted. That transition bypassed InviteService.acceptInvite, so OrgMemberService.redeemInvite never ran and the invitee received no organization/store IAM assignment. The portal's validation endpoint then returned the generic already-accepted error.

Remediation Applied

AuthorizationService.requireOrgAdminToAssignRoleForStore now:

  • Detects org.admin or org.members.admin in the token's organization permission bucket.
  • Preserves existing org/store scope checks, target-role organization/store validation, merchant-IAM validation, and store-admin requirements.
  • Skips the exact-target-role possession check for an organization administrator/owner, including when that administrator uses a selected-store token.
  • Continues requiring target-role possession for a store-only caller without organization-admin permissions.

Invitation lifecycle remediation now:

  • Removes automatic invite status transitions from merchant login; login only updates merchant_users.last_login.
  • Reports persisted invite status rather than inferring acceptance from merchant_users.last_login.
  • Allows validation of an already-accepted invite so an authenticated invitee can safely replay the idempotent redemption path.
  • Automatically reconciles that accepted-invite path in the portal instead of displaying a duplicate acceptance error.
  • Removes the obsolete acceptOpenByEmail SQL and repository path.

Regression coverage added:

  • Organization administrator with a selected-store token can assign a store role they do not possess.
  • Store-only caller without org.members.admin is still denied when they do not possess the target role.
  • Merchant login updates last_login without accepting pending invites.
  • Invite listings remain pending until explicit acceptance.
  • Already-accepted invite validation/reconciliation restores membership without a duplicate error.

QA / Rollout Acceptance

Developer B should independently verify after deployment:

  1. Sign in as an organization owner/admin with a fresh merchant token.
  2. Select the affected store and invite a new email with a normal store employee job function.
  3. Confirm the request succeeds and the pending invite contains the selected store role.
  4. Open the invite, set the password, sign in, and confirm the invite still validates as pending before the explicit acceptance click.
  5. Accept the invite and confirm the invitee appears in the selected store's staff list with the requested role and can load the overview.
  6. Reload the same invite link and confirm the portal restores membership instead of showing an already-accepted error.
  7. Confirm a store-only actor without org.members.admin cannot assign a role they do not possess.
  8. Confirm invite listing, revoke, and delete remain scoped to the organization/store.

No database migration is required. The change removes an obsolete query and changes application authorization/lifecycle behavior only.

Verification

The retail portal acceptance regression suite passed locally:

bazel test --config=local --remote_cache= --experimental_remote_downloader= \
//apps/websites/portals/retail:test

The focused merchant-api invite integration regression passed on an x86_64 OCI BuildBuddy workflow runner after provisioning its PostgreSQL fixture:

bb remote --arch=amd64 \
--container_image=docker://peakcirunnerscontainers.azurecr.io/buildbuddy-workflow@sha256:5c69b1ecac62e0987f0d5eeb6995ea12d832482f45385fbc0512de92654da4a7 \
--runner_exec_properties=workload-isolation-type=oci \
--runner_exec_properties=use-self-hosted-executors=true \
--runner_exec_properties=Pool=workflows \
--runner_exec_properties=dockerUser=root \
--script='bash scripts/ci/setup-invitation-test-postgres.sh && bazel test --config=ci --test_env=INVITE_INTEGRATION_JDBC_URL=<REDACTED> --test_env=INVITE_INTEGRATION_JDBC_USER=<REDACTED> --test_env=INVITE_INTEGRATION_JDBC_PASSWORD=<REDACTED> --test_output=errors //apps/microservices/merchant-api:invite_integration_test'

BuildBuddy invocation https://app.buildbuddy.io/invocation/977a33a3-5682-4420-9962-92f71c67245c reports //apps/microservices/merchant-api:invite_integration_test PASSED.

The ownership metadata regression also passed locally:

bazel test --config=local --remote_cache= --experimental_remote_downloader= \
//tools/table-ownership:write_ownership_test

The frontend lint target could not be analyzed on this ARM64 workstation because the checked-in SWC toolchain has no linux-arm64-gnu entry:

bazel test --config=local --remote_cache= --experimental_remote_downloader= \
//apps/websites/portals/retail:lint

Direct local backend execution remains unavailable on this ARM64 workstation: the default configuration timed out against the BuildBuddy cache endpoint, and the local configuration lacks the repository's linux_arm64 Dafny toolchain. No local backend result is claimed; the remote invite integration result above is the authoritative focused backend validation. The narrower auth and merchant-api Kotlin unit targets remain required in CI:

bazel test //apps/microservices/auth:merchant_auth_service_test
bazel test //apps/microservices/merchant-api:invite_service_test

Current Staging Rollout

The affected application images were redeployed from commit 66160544f383c9fcd9242b57a10ad6a3d6ee9baa by GitHub Actions run 32463883319:

  • peakpos-staging-merchant--r32463883319-1
  • peakpos-staging-auth--r32463883319-1

Both revisions reached Healthy / Running with one replica. The Azure staging smoke command passed with 191 ok, 0 warnings, 0 failures, including HTTP 200 responses from auth/health and merchant/health, and no recent Container App error rows:

AZURE_ENVIRONMENT=staging scripts/azure-staging-smoke.sh

The Azure manual-dispatch contract regression passed locally:

bazel test --config=local --remote_cache= --experimental_remote_downloader= \
//apps/specifications/sql:azure_manual_dispatch_scope_contract_test

The first post-deployment CI validation exposed an unfinished Mockito stub in the new login regression test; the test setup was corrected and pushed in commit ece8a25c7. The replacement auth test passed on the x86_64 OCI BuildBuddy runner (https://app.buildbuddy.io/invocation/3a0cdd2b-f0f9-4d88-a617-0ceb045ecb3f). The authenticated invite workflow remains credential-gated: the credentialed smoke preflight reported missing MERCHANT_TOKEN, MANAGEMENT_TOKEN, ORG_ID, and STORE_ID, and Chrome control was unavailable because no connected Chrome relay existed. No end-to-end invite success is claimed without those credentials.