Authenticated Authorization Cutover - RodrigoMattosoSilveira/EnterpriseRemoteSystems GitHub Wiki

Bite 28C — Authenticated Authorization Cutover

Complete Manual Acceptance Runbook Before Promotion

This runbook is a single, sequential procedure. It does not assume that a second tenant, test Person, Collaborator, Authorization Actor, role grant, authentication account, login, password, or session cookie already exists.

The setup phases create or refresh the complete local test dataset before the first acceptance assertion begins.

Local-only recovery procedure

The preparation process temporarily starts the pre-cutover Development backend against the same local SQLite database used by the Bite 28C branch. This permits the transitional bootstrap-admin Actor header to provision the first known Application Admin account. Never use this procedure against test or production databases.

Bite 28C identity rule

Protected requests resolve identity through:

Session → Authentication Account → Authorization Actor → persisted role grants

X-Actor-ID and X-Actor-Permissions must not authenticate a request, switch an authenticated identity, or grant authority. X-Tenant-ID selects tenant scope only.

Shell compatibility

The preparation script runs explicitly with macOS /bin/bash. Interactive commands may be entered in macOS Zsh. The login helper therefore uses http_status, not Zsh's read-only special parameter status.


Fixed local test dataset

The clean preparation script uses the following concrete values.

Authentication credentials

Identity Desired login Known local password Required role
Application Admin [email protected] Local-Application-Admin-28C! APPLICATION_ADMIN for *
Tenant Admin [email protected] Local-Tenant-Admin-28C! TENANT_ADMIN for default
Expenses Operator [email protected] Local-Expenses-Operator-28C! EXPENSE_OPERATOR for default
Earnings Operator [email protected] Local-Earnings-Operator-28C! EARNINGS_OPERATOR for default
Disposable Actor [email protected] Local-Disposable-Actor-28C! EXPENSE_OPERATOR for default
Grant/Revoke Actor [email protected] Local-Grant-Revoke-Actor-28C! No initial role

When an Actor already has an authentication account with a different login, the script retains the existing login, resets its password to the fixed password above, and writes the actual login to /tmp/ers-b28c-manual-env.sh.

Tenants

Purpose Value
Primary tenant Existing tenant ID default
Isolation tenant Code B28C_SECONDARY; name Bite 28C Secondary Tenant

The generated isolation-tenant ID is written to SECOND_TENANT_ID.

People

Purpose Nickname CPF Cellular Email
Tenant Admin B28C Tenant Admin 10020030088 11970000001 [email protected]
Expenses Operator B28C Expenses Operator 10020030169 11970000002 [email protected]
Earnings Operator B28C Earnings Operator 10020030240 11970000003 [email protected]
Disposable Actor B28C Disposable Actor 10020030320 11970000004 [email protected]
Grant/Revoke Actor B28C Grant Revoke 10020030401 11970000005 [email protected]
Second-tenant isolation record B28C Secondary Tenant Person 10020030592 11970000006 [email protected]

The five People in default receive active Collaborator journeys. The second-tenant Person intentionally remains a Person only, so it can be used to verify tenant isolation.


Test result record

Test Identity Tenant Expected Actual Pass
Unauthenticated protected request None default 401 authentication_required [ ]
Forged legacy headers None default 401 authentication_required [ ]
Application Admin authorization Application Admin default 200 [ ]
Expenses Operator allowed read Expenses Operator default 200 [ ]
Expenses Operator admin denial Expenses Operator default 403 [ ]
Earnings Operator Expense denial Earnings Operator default 403 before validation [ ]
Tenant Admin own tenant Tenant Admin default 200 [ ]
Tenant Admin other tenant Tenant Admin isolation tenant 403 [ ]
Inactive Actor existing session Disposable Actor default 401 actor_inactive [ ]
Inactive account existing session Disposable Actor default 401 account_inactive [ ]
Dynamic role grant Grant/Revoke Actor default 200 after grant [ ]
Dynamic role revoke Grant/Revoke Actor default 403 after revoke [ ]

Phase 1 — Verify the Bite 28C working tree

From the main project root:

cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems

git branch --show-current
git rev-parse HEAD
git status --short

Expected branch and HEAD:

668-bite-28c-authenticated-authorization-cutover
5bcb41e40daa2bb78d72709119303a808d153ea3

The working tree is expected to contain the Bite 28C patch stack and the clean preparation script. Do not reset or clean it.

Confirm the script exists:

test -f scripts/ers-b28c-prepare-test-data-final.sh \
  && echo "Preparation script found."

Phase 2 — Verify prerequisites and stop local services

Confirm required commands:

for command in git curl jq shasum lsof; do
  command -v "$command" >/dev/null 2>&1 || {
    echo "Missing required command: $command"
    exit 1
  }
done

Stop any running backend and frontend with Ctrl+C.

Confirm port 8080 is free:

lsof -nP -iTCP:8080 -sTCP:LISTEN

Expected: no output.

Confirm the main working tree has a generated backend environment file:

MAIN_ROOT="/Users/rodrigosilveira/projects/EnterpriseRemoteSystems"

test -f "$MAIN_ROOT/backend/.env" || {
  echo "Missing $MAIN_ROOT/backend/.env"
  echo "Start the local backend once from the main working tree, stop it, and repeat this phase."
  exit 1
}

Phase 3 — Create or reuse the pre-cutover Development worktree

Define the paths:

MAIN_ROOT="/Users/rodrigosilveira/projects/EnterpriseRemoteSystems"
DEVELOPMENT_ROOT="/Users/rodrigosilveira/projects/EnterpriseRemoteSystems-development-bootstrap"

Fetch Development:

cd "$MAIN_ROOT"
git fetch origin

Create the worktree only when it does not already exist:

if git -C "$DEVELOPMENT_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  echo "Reusing existing Development worktree: $DEVELOPMENT_ROOT"
else
  git worktree add --detach \
    "$DEVELOPMENT_ROOT" \
    origin/development
fi

Inspect it:

git -C "$DEVELOPMENT_ROOT" log -1 --oneline
git -C "$DEVELOPMENT_ROOT" status --short

Confirm Bite 28B authentication exists:

test -f \
  "$DEVELOPMENT_ROOT/backend/migrations/000042_user_authentication_foundation.up.sql" \
  && echo "Bite 28B authentication migration found."

Do not continue if the migration is absent.


Phase 4 — Identify the database and configure the worktree

4.1 Resolve and persist the main local database path

cd "$MAIN_ROOT"

BACKEND_ROOT="$MAIN_ROOT/backend"

RAW_DB_PATH=$(
  sed -nE 's/^(ERS_DATABASE_PATH|DB_PATH|DATABASE_PATH)=(.*)$/\2/p' \
    "$BACKEND_ROOT/.env" \
  | tail -1
)

RAW_DB_PATH="${RAW_DB_PATH%\"}"
RAW_DB_PATH="${RAW_DB_PATH#\"}"
RAW_DB_PATH="${RAW_DB_PATH%\'}"
RAW_DB_PATH="${RAW_DB_PATH#\'}"

if [ -z "$RAW_DB_PATH" ](/RodrigoMattosoSilveira/EnterpriseRemoteSystems/wiki/--z-"$RAW_DB_PATH"-); then
  LOCAL_DB="$BACKEND_ROOT/data/app.db"
elif [ "$RAW_DB_PATH" = /* ](/RodrigoMattosoSilveira/EnterpriseRemoteSystems/wiki/-"$RAW_DB_PATH"-=-/*-); then
  LOCAL_DB="$RAW_DB_PATH"
else
  LOCAL_DB="$BACKEND_ROOT/$RAW_DB_PATH"
fi

export LOCAL_DB
printf 'export LOCAL_DB=%q\n' "$LOCAL_DB" \
  > /tmp/ers-b28c-local-db.env
chmod 600 /tmp/ers-b28c-local-db.env

printf 'Local database: %s\n' "$LOCAL_DB"
ls -lh "$LOCAL_DB"

Do not continue unless the database exists.

4.2 Copy the generated backend environment file

Git worktrees do not include ignored local files such as backend/.env.

cp \
  "$MAIN_ROOT/backend/.env" \
  "$DEVELOPMENT_ROOT/backend/.env"

chmod 600 \
  "$DEVELOPMENT_ROOT/backend/.env"

ls -l \
  "$DEVELOPMENT_ROOT/backend/.env"

Expected permissions begin with -rw-------.

4.3 Link the Development worktree to the main database

source /tmp/ers-b28c-local-db.env

mkdir -p \
  "$DEVELOPMENT_ROOT/backend/data"

rm -f \
  "$DEVELOPMENT_ROOT/backend/data/app.db"

ln -s \
  "$LOCAL_DB" \
  "$DEVELOPMENT_ROOT/backend/data/app.db"

ls -l \
  "$DEVELOPMENT_ROOT/backend/data/app.db"

Confirm the link target:

WORKTREE_DB_TARGET=$(
  readlink "$DEVELOPMENT_ROOT/backend/data/app.db"
)

printf 'Main database:     %s\n' "$LOCAL_DB"
printf 'Worktree database: %s\n' "$WORKTREE_DB_TARGET"

test "$WORKTREE_DB_TARGET" = "$LOCAL_DB" || {
  echo "The Development worktree is not linked to the intended database."
  exit 1
}

Phase 5 — Start and verify the pre-cutover Development backend

5.1 Start Development

In a dedicated backend terminal:

source /tmp/ers-b28c-local-db.env

DEVELOPMENT_ROOT="/Users/rodrigosilveira/projects/EnterpriseRemoteSystems-development-bootstrap"

cd "$DEVELOPMENT_ROOT"

ERS_DATABASE_PATH="data/app.db" \
AUTHZ_BOOTSTRAP_ENABLED="true" \
AUTHZ_BOOTSTRAP_ACTOR_KEY="bootstrap-admin" \
AUTHZ_BOOTSTRAP_DISPLAY_NAME="Bootstrap Admin" \
AUTHZ_BOOTSTRAP_ROLE_CODE="APPLICATION_ADMIN" \
AUTHZ_BOOTSTRAP_TENANT_ID="*" \
make local-backend

Leave this terminal running.

Do not pass $LOCAL_DB through ERS_DATABASE_PATH, DB_PATH, and DATABASE_PATH. The startup script expects a database path relative to the worktree's backend directory; data/app.db resolves through the symbolic link created in Phase 4.

5.2 Verify health

In a separate API-test terminal:

BASE_URL="http://127.0.0.1:8080/api/v1"

curl -i "$BASE_URL/healthz"

Expected: HTTP 200.

5.3 Verify transitional bootstrap authorization

HTTP_STATUS=$(
  curl -sS \
    -D /tmp/ers-b28c-precutover.headers \
    -o /tmp/ers-b28c-precutover.json \
    -w '%{http_code}' \
    -H "X-Actor-ID: bootstrap-admin" \
    -H "X-Tenant-ID: default" \
    "$BASE_URL/authz/actors"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-precutover.json

Expected: HTTP 200 and an active bootstrap-admin Actor with APPLICATION_ADMIN scope *.

Do not continue if this request returns 401 authentication_required; that would mean Bite 28C is running instead of the pre-cutover backend.

Phase 5 completion gate

  • The Development worktree exists.
  • Bite 28B migration 000042 exists in the worktree.
  • The worktree has a copied backend/.env.
  • backend/data/app.db links to the main Bite 28C database.
  • Development started with ERS_DATABASE_PATH="data/app.db".
  • Health returns HTTP 200.
  • bootstrap-admin headers can retrieve /authz/actors with HTTP 200.

Phase 6 — Provision all test data and return to Bite 28C

This phase owns the entire handoff. Do not repeat these steps in another phase.

6.1 Verify the clean preparation script

From the main Bite 28C repository:

cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems

ls -l scripts/ers-b28c-prepare-test-data-final.sh
head -5 scripts/ers-b28c-prepare-test-data-final.sh

Expected first lines:

#!/usr/bin/env bash
set -euo pipefail

Reject conversation-text contamination:

if grep -nE \
  '^(ChatGPT|Sunday|EnterpriseRemoteSystems-development\(|```)' \
  scripts/ers-b28c-prepare-test-data-final.sh; then
  echo "The preparation script contains non-shell conversation text."
  exit 1
else
  echo "No conversation-text contamination found."
fi

Confirm the macOS Bash repair:

if grep -nE '(^|[^[:alnum:]_])mapfile([^[:alnum:]_]|$)' \
  scripts/ers-b28c-prepare-test-data-final.sh; then
  echo "Unsupported Bash-4 mapfile usage remains."
  exit 1
else
  echo "No mapfile usage remains."
fi

Validate syntax:

/bin/bash -n \
  scripts/ers-b28c-prepare-test-data-final.sh

Verify the intended clean-script checksum:

EXPECTED_SCRIPT_SHA="a7169b4f1c41513dc5ba3e0390a16acf7b3efaf7020c70e69d2277243df8e501"
ACTUAL_SCRIPT_SHA=$(
  shasum -a 256 \
    scripts/ers-b28c-prepare-test-data-final.sh \
  | awk '{print $1}'
)

printf 'Expected: %s\n' "$EXPECTED_SCRIPT_SHA"
printf 'Actual:   %s\n' "$ACTUAL_SCRIPT_SHA"

test "$ACTUAL_SCRIPT_SHA" = "$EXPECTED_SCRIPT_SHA" || {
  echo "Preparation-script checksum mismatch."
  echo "Reapply 668-bite-28c-replace-contaminated-prep-script.patch before continuing."
  exit 1
}

6.2 Run the preparation script with macOS Bash

Keep the pre-cutover Development backend running, then execute:

cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems

/bin/bash \
  scripts/ers-b28c-prepare-test-data-final.sh

Expected completion includes:

Bite 28C manual test data is ready.
Environment file: /tmp/ers-b28c-manual-env.sh
Stop the Development backend, restart Bite 28C, then run:
source /tmp/ers-b28c-manual-env.sh

Confirm the generated environment file:

test -f /tmp/ers-b28c-manual-env.sh || {
  echo "The preparation environment file was not created."
  exit 1
}

ls -l /tmp/ers-b28c-manual-env.sh

Expected permissions: owner read/write only.

Inspect non-secret generated values:

grep -E \
  '^(export )?(BASE_URL|PRIMARY_TENANT_ID|SECOND_TENANT_ID|ADMIN_LOGIN|TENANT_ADMIN_LOGIN|EXPENSES_LOGIN|EARNINGS_LOGIN|DISPOSABLE_LOGIN|GRANT_REVOKE_LOGIN|.*_ACTOR_ID|.*_ACTOR_KEY|.*_PERSON_ID|.*_COLLABORATOR_ID)=' \
  /tmp/ers-b28c-manual-env.sh

6.3 Stop the pre-cutover Development backend

Return to the terminal running the Development backend and press Ctrl+C.

Confirm port 8080 is free:

lsof -nP -iTCP:8080 -sTCP:LISTEN

Expected: no output.

6.4 Restart the Bite 28C backend

In a dedicated backend terminal:

cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems

git branch --show-current
git rev-parse HEAD

make local-backend

Expected branch and HEAD:

668-bite-28c-authenticated-authorization-cutover
5bcb41e40daa2bb78d72709119303a808d153ea3

Leave the backend running.

Start the frontend in a separate terminal:

cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems

make local-frontend

Leave the frontend running.

6.5 Verify that the cutover backend is active

In the API-test terminal:

BASE_URL="http://127.0.0.1:8080/api/v1"

curl -i "$BASE_URL/healthz"

Expected: HTTP 200.

Confirm legacy headers no longer authenticate:

HTTP_STATUS=$(
  curl -sS \
    -D /tmp/ers-b28c-cutover-check.headers \
    -o /tmp/ers-b28c-cutover-check.json \
    -w '%{http_code}' \
    -H "X-Actor-ID: bootstrap-admin" \
    -H "X-Actor-Permissions: *" \
    -H "X-Tenant-ID: default" \
    "$BASE_URL/authz/actors"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-cutover-check.json

Expected:

  • HTTP 401.
  • Error code authentication_required.

If this returns HTTP 200, the pre-cutover Development backend is still running.

6.6 Load the complete generated environment

source /tmp/ers-b28c-manual-env.sh

Confirm non-secret values:

printf '%-30s %s\n' \
  "BASE_URL" "$BASE_URL" \
  "PRIMARY_TENANT_ID" "$PRIMARY_TENANT_ID" \
  "SECOND_TENANT_ID" "$SECOND_TENANT_ID" \
  "ADMIN_LOGIN" "$ADMIN_LOGIN" \
  "TENANT_ADMIN_LOGIN" "$TENANT_ADMIN_LOGIN" \
  "EXPENSES_LOGIN" "$EXPENSES_LOGIN" \
  "EARNINGS_LOGIN" "$EARNINGS_LOGIN" \
  "DISPOSABLE_LOGIN" "$DISPOSABLE_LOGIN" \
  "GRANT_REVOKE_LOGIN" "$GRANT_REVOKE_LOGIN"

The environment file also defines all generated Person, Collaborator, Actor record IDs, Actor keys, and the six known password variables.

Run the remaining API commands in this shell. In every new terminal, source the environment file again.

6.7 Define cookie-file variables

The preparation environment does not define cookie paths, so define them now:

ADMIN_COOKIE="/tmp/ers-b28c-admin.cookies"
ADMIN_LOGOUT_COOKIE="/tmp/ers-b28c-admin-logout.cookies"
TENANT_ADMIN_COOKIE="/tmp/ers-b28c-tenant-admin.cookies"
EXPENSES_COOKIE="/tmp/ers-b28c-expenses.cookies"
EARNINGS_COOKIE="/tmp/ers-b28c-earnings.cookies"
DISPOSABLE_COOKIE="/tmp/ers-b28c-disposable.cookies"
GRANT_REVOKE_COOKIE="/tmp/ers-b28c-grant-revoke.cookies"

export \
  ADMIN_COOKIE \
  ADMIN_LOGOUT_COOKIE \
  TENANT_ADMIN_COOKIE \
  EXPENSES_COOKIE \
  EARNINGS_COOKIE \
  DISPOSABLE_COOKIE \
  GRANT_REVOKE_COOKIE

rm -f \
  "$ADMIN_COOKIE" \
  "$ADMIN_LOGOUT_COOKIE" \
  "$TENANT_ADMIN_COOKIE" \
  "$EXPENSES_COOKIE" \
  "$EARNINGS_COOKIE" \
  "$DISPOSABLE_COOKIE" \
  "$GRANT_REVOKE_COOKIE"

6.8 Define the corrected Zsh-compatible login helper

Remove any earlier definition:

unset -f login_test_account 2>/dev/null || true

Define the helper. It deliberately uses http_status, because status is read-only in Zsh:

login_test_account() {
  local login="$1"
  local password="$2"
  local cookie_file="$3"
  local label="$4"
  local http_status

  rm -f "$cookie_file"

  http_status=$(
    jq -n \
      --arg login "$login" \
      --arg password "$password" \
      '{login: $login, password: $password}' \
    | curl -sS \
        -D "/tmp/ers-b28c-${label}-login.headers" \
        -o "/tmp/ers-b28c-${label}-login.json" \
        -w '%{http_code}' \
        -c "$cookie_file" \
        -H "Content-Type: application/json" \
        --data-binary @- \
        "$BASE_URL/auth/login"
  )

  echo "$label login: HTTP $http_status"

  if [ "$http_status" != "200" ](/RodrigoMattosoSilveira/EnterpriseRemoteSystems/wiki/-"$http_status"-!=-"200"-); then
    jq . "/tmp/ers-b28c-${label}-login.json"
    return 1
  fi

  echo "$label session:"

  curl -sS \
    -b "$cookie_file" \
    "$BASE_URL/auth/session" \
  | jq .
}

6.9 Log in all six identities

login_test_account \
  "$ADMIN_LOGIN" \
  "$ADMIN_PASSWORD" \
  "$ADMIN_COOKIE" \
  admin

login_test_account \
  "$TENANT_ADMIN_LOGIN" \
  "$TENANT_ADMIN_PASSWORD" \
  "$TENANT_ADMIN_COOKIE" \
  tenant-admin

login_test_account \
  "$EXPENSES_LOGIN" \
  "$EXPENSES_PASSWORD" \
  "$EXPENSES_COOKIE" \
  expenses

login_test_account \
  "$EARNINGS_LOGIN" \
  "$EARNINGS_PASSWORD" \
  "$EARNINGS_COOKIE" \
  earnings

login_test_account \
  "$DISPOSABLE_LOGIN" \
  "$DISPOSABLE_PASSWORD" \
  "$DISPOSABLE_COOKIE" \
  disposable

login_test_account \
  "$GRANT_REVOKE_LOGIN" \
  "$GRANT_REVOKE_PASSWORD" \
  "$GRANT_REVOKE_COOKIE" \
  grant-revoke

Expected for every identity:

  • Login HTTP 200.
  • /auth/session HTTP 200.
  • Session response shows the expected login and Actor key.
  • The corresponding cookie file exists.

Phase 6 completion gate

Do not begin acceptance assertions until every item is true:

  • The clean preparation script passed contamination, mapfile, syntax, and checksum checks.
  • The preparation script completed successfully.
  • /tmp/ers-b28c-manual-env.sh exists with mode 600.
  • The pre-cutover Development backend was stopped.
  • Bite 28C was restarted from the main working tree.
  • Bite 28C health returns HTTP 200.
  • Legacy Actor and permission headers alone return 401 authentication_required.
  • /tmp/ers-b28c-manual-env.sh was sourced.
  • Cookie-file variables were defined.
  • The corrected helper uses http_status, not status.
  • Application Admin login succeeds.
  • Tenant Admin login succeeds.
  • Expenses Operator login succeeds.
  • Earnings Operator login succeeds.
  • Disposable Actor login succeeds.
  • Grant/Revoke Actor login succeeds.

Phase 7 — Mandatory authentication API tests

7.1 Unauthenticated protected requests are rejected

HTTP_STATUS=$(
  curl -sS \
    -D /tmp/ers-b28c-unauthenticated.headers \
    -o /tmp/ers-b28c-unauthenticated.json \
    -w '%{http_code}' \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/people?page=1&pageSize=1"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-unauthenticated.json

Expected:

  • HTTP 401.

  • Error code authentication_required.

  • No business data.

  • Passed

7.2 Forged legacy headers cannot authenticate

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-forged.json \
    -w '%{http_code}' \
    -H "X-Actor-ID: bootstrap-admin" \
    -H "X-Actor-Permissions: *" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/authz/actors"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-forged.json

Expected:

  • HTTP 401.

  • Error code authentication_required.

  • Passed

7.3 Application Admin session is valid

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-admin-session.json \
    -w '%{http_code}' \
    -b "$ADMIN_COOKIE" \
    "$BASE_URL/auth/session"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-admin-session.json

Expected:

  • HTTP 200.

  • .data.login equals $ADMIN_LOGIN after normalization.

  • .data.actorKey identifies bootstrap-admin.

  • Passed

7.4 Application Admin can read Authorization Actors

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-admin-actors.json \
    -w '%{http_code}' \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/authz/actors"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-admin-actors.json

Expected: HTTP 200.

  • Passed

7.5 Forged headers cannot change an authenticated identity

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-forged-session.json \
    -w '%{http_code}' \
    -b "$ADMIN_COOKIE" \
    -H "X-Actor-ID: $EXPENSES_ACTOR_KEY" \
    -H "X-Actor-Permissions: none" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/authz/actors"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-forged-session.json

Expected:

  • HTTP remains 200.

  • The session still acts as the Application Admin.

  • Forged headers neither switch identity nor change privileges.

  • Passed

7.6 Invalid credentials do not create a session

HTTP_STATUS=$(
  jq -n \
    --arg login "$ADMIN_LOGIN" \
    '{login: $login, password: "definitely-not-the-password"}' \
  | curl -sS \
      -o /tmp/ers-b28c-invalid-login.json \
      -w '%{http_code}' \
      -c /tmp/ers-b28c-invalid.cookies \
      -H "Content-Type: application/json" \
      --data-binary @- \
      "$BASE_URL/auth/login"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-invalid-login.json

Expected:

  • HTTP 401.

  • Error code invalid_credentials.

  • Passed

7.7 Logout invalidates only the logged-out session

Create a second admin session:

login_test_account \
  "$ADMIN_LOGIN" \
  "$ADMIN_PASSWORD" \
  "$ADMIN_LOGOUT_COOKIE" \
  admin-logout

Log it out:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-admin-logout.json \
    -w '%{http_code}' \
    -X POST \
    -b "$ADMIN_LOGOUT_COOKIE" \
    -c "$ADMIN_LOGOUT_COOKIE" \
    "$BASE_URL/auth/logout"
)

echo "HTTP $HTTP_STATUS"

Expected: HTTP 204.

Reuse it:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-after-logout.json \
    -w '%{http_code}' \
    -b "$ADMIN_LOGOUT_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/people?page=1&pageSize=1"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-after-logout.json

Expected:

  • HTTP 401.

  • Error code authentication_required.

  • $ADMIN_COOKIE remains valid.

  • Passed


Phase 8 — Role-boundary API tests

These tests distinguish an authorized request that reaches payload validation from a forbidden request that must be rejected before payload validation.

8.1 Expenses Operator can read Collaborators

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-expenses-collaborators.json \
    -w '%{http_code}' \
    -b "$EXPENSES_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/collaborators?page=1&pageSize=1"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-expenses-collaborators.json

Expected: HTTP 200.

  • Passed

8.2 Expenses Operator reaches Expense validation

Send an intentionally empty Expense payload:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-expenses-empty-expense.json \
    -w '%{http_code}' \
    -b "$EXPENSES_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    -H "Content-Type: application/json" \
    -d '{}' \
    "$BASE_URL/expenses"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-expenses-empty-expense.json

Expected:

  • HTTP 400.

  • Error code validation_failed.

  • This proves the Expenses Operator passed authorization and reached domain validation.

  • Passed

8.3 Expenses Operator cannot administer Authorization

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-expenses-authz.json \
    -w '%{http_code}' \
    -b "$EXPENSES_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/authz/actors"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-expenses-authz.json

Expected: HTTP 403 with a forbidden authorization error.

  • Passed

8.4 Expenses Operator cannot change second-person approval settings

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-expenses-settings.json \
    -w '%{http_code}' \
    -X PUT \
    -b "$EXPENSES_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    -H "Content-Type: application/json" \
    -d '{}' \
    "$BASE_URL/current-accounts/settings/second-person-approval"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-expenses-settings.json

Expected:

  • HTTP 403.

  • The request must not reach payload validation.

  • Passed

8.5 Earnings Operator can read Collaborators and Work Periods

COLLABORATORS_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-earnings-collaborators.json \
    -w '%{http_code}' \
    -b "$EARNINGS_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/collaborators?page=1&pageSize=1"
)

WORK_PERIODS_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-earnings-work-periods.json \
    -w '%{http_code}' \
    -b "$EARNINGS_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/work-periods?pageSize=1"
)

echo "Collaborators HTTP $COLLABORATORS_STATUS"
echo "Work Periods HTTP $WORK_PERIODS_STATUS"

Expected: both requests return HTTP 200.

  • Passed

8.6 Earnings Operator reaches Work Period validation

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-earnings-empty-work-period.json \
    -w '%{http_code}' \
    -b "$EARNINGS_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    -H "Content-Type: application/json" \
    -d '{}' \
    "$BASE_URL/work-periods"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-earnings-empty-work-period.json

Expected:

  • HTTP 400.

  • Error code validation_failed.

  • This proves the Earnings Operator passed authorization for planning creation.

  • Passed

8.7 Earnings Operator cannot create Expenses

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-earnings-expense.json \
    -w '%{http_code}' \
    -b "$EARNINGS_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    -H "Content-Type: application/json" \
    -d '{}' \
    "$BASE_URL/expenses"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-earnings-expense.json

Expected:

  • HTTP 403.

  • The result must not be 400 validation_failed.

  • Passed

8.8 Earnings Operator cannot create Price List items

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-earnings-price-list.json \
    -w '%{http_code}' \
    -b "$EARNINGS_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    -H "Content-Type: application/json" \
    -d '{}' \
    "$BASE_URL/price-list-items"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-earnings-price-list.json

Expected: HTTP 403 before payload validation.

  • Passed

8.9 Earnings Operator cannot administer Authorization

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-earnings-authz.json \
    -w '%{http_code}' \
    -b "$EARNINGS_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/authz/actors"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-earnings-authz.json

Expected: HTTP 403.

  • Passed

8.10 Tenant Admin can access its own tenant

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-tenant-admin-own.json \
    -w '%{http_code}' \
    -b "$TENANT_ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/people?page=1&pageSize=1"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-tenant-admin-own.json

Expected: HTTP 200.

  • Passed

8.11 Tenant Admin cannot access the isolation tenant

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-tenant-admin-other.json \
    -w '%{http_code}' \
    -b "$TENANT_ADMIN_COOKIE" \
    -H "X-Tenant-ID: $SECOND_TENANT_ID" \
    "$BASE_URL/people?page=1&pageSize=20"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-tenant-admin-other.json

Expected:

  • HTTP 403.

  • No B28C Secondary Tenant Person data.

  • Passed

8.12 Application Admin can access both tenants

PRIMARY_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-admin-primary.json \
    -w '%{http_code}' \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/people?page=1&pageSize=20"
)

SECOND_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-admin-secondary.json \
    -w '%{http_code}' \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $SECOND_TENANT_ID" \
    "$BASE_URL/people?search=B28C%20Secondary%20Tenant%20Person&page=1&pageSize=20"
)

echo "Primary tenant HTTP $PRIMARY_STATUS"
echo "Isolation tenant HTTP $SECOND_STATUS"
jq . /tmp/ers-b28c-admin-secondary.json

Expected:

  • Both responses are HTTP 200.

  • The isolation-tenant response contains B28C Secondary Tenant Person.

  • Passed

8.13 Tenant selection changes scope, not identity

curl -sS \
  -b "$ADMIN_COOKIE" \
  -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
  "$BASE_URL/authz/current-actor" \
  > /tmp/ers-b28c-current-primary.json

curl -sS \
  -b "$ADMIN_COOKIE" \
  -H "X-Tenant-ID: $SECOND_TENANT_ID" \
  "$BASE_URL/authz/current-actor" \
  > /tmp/ers-b28c-current-secondary.json

jq . /tmp/ers-b28c-current-primary.json
jq . /tmp/ers-b28c-current-secondary.json

Compare identity and tenant scope:

jq -n \
  --slurpfile primary /tmp/ers-b28c-current-primary.json \
  --slurpfile secondary /tmp/ers-b28c-current-secondary.json \
  '{
    sameActorKey: ($primary[0].data.actorKey == $secondary[0].data.actorKey),
    primaryTenant: $primary[0].data.tenantId,
    secondaryTenant: $secondary[0].data.tenantId
  }'

Expected:

  • sameActorKey is true.

  • Tenant IDs differ.

  • Passed

8.14 Role-grant scope rules are enforced

An Application Admin grant must use global scope *. Attempting to grant it to default must fail:

HTTP_STATUS=$(
  jq -n \
    --arg tenantId "$PRIMARY_TENANT_ID" \
    '{roleCode: "APPLICATION_ADMIN", tenantId: $tenantId}' \
  | curl -sS \
      -o /tmp/ers-b28c-invalid-application-scope.json \
      -w '%{http_code}' \
      -b "$ADMIN_COOKIE" \
      -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
      -H "Content-Type: application/json" \
      --data-binary @- \
      "$BASE_URL/authz/actors/$GRANT_REVOKE_ACTOR_ID/role-grants"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-invalid-application-scope.json

Expected: HTTP 400 with tenantId validation guidance.

A tenant-scoped operational role must not use global scope *:

HTTP_STATUS=$(
  jq -n \
    '{roleCode: "EXPENSE_OPERATOR", tenantId: "*"}' \
  | curl -sS \
      -o /tmp/ers-b28c-invalid-tenant-scope.json \
      -w '%{http_code}' \
      -b "$ADMIN_COOKIE" \
      -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
      -H "Content-Type: application/json" \
      --data-binary @- \
      "$BASE_URL/authz/actors/$GRANT_REVOKE_ACTOR_ID/role-grants"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-invalid-tenant-scope.json

Expected: HTTP 400 with tenantId validation guidance.

Confirm the Grant/Revoke Actor still has no active grant before Phase 9:

curl -sS \
  -b "$ADMIN_COOKIE" \
  -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
  "$BASE_URL/authz/actors" \
| jq \
    --arg actorId "$GRANT_REVOKE_ACTOR_ID" '
      def actors:
        if (.data | type) == "array" then .data
        elif (.data.items | type) == "array" then .data.items
        else [] end;
      actors
      | map(select(.id == $actorId))
      | first
      | (.roleGrants // .grants // [])
      | map(select(.active == true))
    '

Expected: [].

  • Passed

Phase 9 — Actor and account lifecycle tests

9.1 Deactivating an Actor invalidates its existing session

Confirm the disposable Actor currently works:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-disposable-before.json \
    -w '%{http_code}' \
    -b "$DISPOSABLE_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/collaborators?page=1&pageSize=1"
)

echo "Before deactivation: HTTP $HTTP_STATUS"

Expected: HTTP 200.

Deactivate it as the Application Admin:

HTTP_STATUS=$(
  jq -n '{active: false}' \
  | curl -sS \
      -o /tmp/ers-b28c-deactivate-actor.json \
      -w '%{http_code}' \
      -X PATCH \
      -b "$ADMIN_COOKIE" \
      -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
      -H "Content-Type: application/json" \
      --data-binary @- \
      "$BASE_URL/authz/actors/$DISPOSABLE_ACTOR_ID/active"
)

echo "Deactivate Actor: HTTP $HTTP_STATUS"

Expected: HTTP 200.

Reuse the existing disposable cookie:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-disposable-after.json \
    -w '%{http_code}' \
    -b "$DISPOSABLE_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/collaborators?page=1&pageSize=1"
)

echo "After deactivation: HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-disposable-after.json

Expected:

  • HTTP 401.
  • Error code actor_inactive.
  • The cookie is cleared or rendered unusable.

Reactivate the Actor:

jq -n '{active: true}' \
| curl -sS \
    -X PATCH \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    -H "Content-Type: application/json" \
    --data-binary @- \
    "$BASE_URL/authz/actors/$DISPOSABLE_ACTOR_ID/active" \
| jq .

Log in again for later tests if needed:

login_test_account \
  "$DISPOSABLE_LOGIN" \
  "$DISPOSABLE_PASSWORD" \
  "$DISPOSABLE_COOKIE" \
  disposable-restored
  • Passed

9.2 Deactivating an authentication account invalidates its existing session

Find the disposable account ID:

curl -sS \
  -b "$ADMIN_COOKIE" \
  -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
  "$BASE_URL/auth/accounts" \
  > /tmp/ers-b28c-accounts.json

DISPOSABLE_ACCOUNT_ID=$(
  jq -er \
    --arg actorId "$DISPOSABLE_ACTOR_ID" '
      def rows:
        if (.data | type) == "array" then .data
        elif (.data.items | type) == "array" then .data.items
        else [] end;
      rows[] | select(.actorId == $actorId) | .id
    ' \
    /tmp/ers-b28c-accounts.json
)

echo "$DISPOSABLE_ACCOUNT_ID"

Ensure a current disposable session exists:

login_test_account \
  "$DISPOSABLE_LOGIN" \
  "$DISPOSABLE_PASSWORD" \
  "$DISPOSABLE_COOKIE" \
  disposable-account-test

Deactivate the account:

jq -n '{active: false}' \
| curl -sS \
    -X PATCH \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    -H "Content-Type: application/json" \
    --data-binary @- \
    "$BASE_URL/auth/accounts/$DISPOSABLE_ACCOUNT_ID/active" \
| jq .

Reuse the existing session:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-account-inactive-session.json \
    -w '%{http_code}' \
    -b "$DISPOSABLE_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/collaborators?page=1&pageSize=1"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-account-inactive-session.json

Expected:

  • HTTP 401.
  • Error code account_inactive.

Attempt a new login with the correct password:

HTTP_STATUS=$(
  jq -n \
    --arg login "$DISPOSABLE_LOGIN" \
    --arg password "$DISPOSABLE_PASSWORD" \
    '{login: $login, password: $password}' \
  | curl -sS \
      -o /tmp/ers-b28c-account-inactive-login.json \
      -w '%{http_code}' \
      -H "Content-Type: application/json" \
      --data-binary @- \
      "$BASE_URL/auth/login"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-account-inactive-login.json

Expected:

  • HTTP 401.
  • Error code invalid_credentials.

Reactivate the account:

jq -n '{active: true}' \
| curl -sS \
    -X PATCH \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    -H "Content-Type: application/json" \
    --data-binary @- \
    "$BASE_URL/auth/accounts/$DISPOSABLE_ACCOUNT_ID/active" \
| jq .
  • Passed

9.3 Grant and revoke affect an existing session

The Grant/Revoke Actor starts with no active role.

Confirm denial:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-grant-before.json \
    -w '%{http_code}' \
    -b "$GRANT_REVOKE_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/collaborators?page=1&pageSize=1"
)

echo "Before grant: HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-grant-before.json

Expected: HTTP 403.

Grant EXPENSE_OPERATOR:

HTTP_STATUS=$(
  jq -n \
    --arg tenantId "$PRIMARY_TENANT_ID" \
    '{roleCode: "EXPENSE_OPERATOR", tenantId: $tenantId}' \
  | curl -sS \
      -o /tmp/ers-b28c-grant-role.json \
      -w '%{http_code}' \
      -b "$ADMIN_COOKIE" \
      -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
      -H "Content-Type: application/json" \
      --data-binary @- \
      "$BASE_URL/authz/actors/$GRANT_REVOKE_ACTOR_ID/role-grants"
)

echo "Grant role: HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-grant-role.json

Expected: HTTP 201.

Capture the grant ID:

GRANT_REVOKE_GRANT_ID=$(jq -er '.data.id' /tmp/ers-b28c-grant-role.json)

Reuse the same Actor session:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-grant-after.json \
    -w '%{http_code}' \
    -b "$GRANT_REVOKE_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/collaborators?page=1&pageSize=1"
)

echo "After grant: HTTP $HTTP_STATUS"

Expected: HTTP 200.

Revoke the grant:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-revoke-role.json \
    -w '%{http_code}' \
    -X DELETE \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/authz/actors/$GRANT_REVOKE_ACTOR_ID/role-grants/$GRANT_REVOKE_GRANT_ID"
)

echo "Revoke role: HTTP $HTTP_STATUS"

Expected: HTTP 200.

Reuse the same session again:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-revoke-after.json \
    -w '%{http_code}' \
    -b "$GRANT_REVOKE_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/collaborators?page=1&pageSize=1"
)

echo "After revoke: HTTP $HTTP_STATUS"

Expected: HTTP 403.

  • Passed

Phase 10 — Frontend acceptance tests

Bite 28C does not yet include Bite 28D's final login page. Authenticate through the frontend origin using the browser console.

Open:

http://localhost:5173

Before each identity login, paste this helper into Developer Tools → Console:

async function ersLogin(login, password) {
  const response = await fetch("/api/v1/auth/login", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ login, password }),
  });

  return {
    status: response.status,
    body: await response.json().catch(() => null),
  };
}

The helper exists only until the page reloads. Paste it again before logging in as the next identity.

Generate exact browser-console login commands from the sourced environment:

printf 'Application Admin browser command:\n'
printf 'await ersLogin(%s, %s);\n' \
  "$(jq -Rn --arg value "$ADMIN_LOGIN" '$value')" \
  "$(jq -Rn --arg value "$ADMIN_PASSWORD" '$value')"

printf '\nExpenses Operator browser command:\n'
printf 'await ersLogin(%s, %s);\n' \
  "$(jq -Rn --arg value "$EXPENSES_LOGIN" '$value')" \
  "$(jq -Rn --arg value "$EXPENSES_PASSWORD" '$value')"

printf '\nEarnings Operator browser command:\n'
printf 'await ersLogin(%s, %s);\n' \
  "$(jq -Rn --arg value "$EARNINGS_LOGIN" '$value')" \
  "$(jq -Rn --arg value "$EARNINGS_PASSWORD" '$value')"

printf '\nTenant Admin browser command:\n'
printf 'await ersLogin(%s, %s);\n' \
  "$(jq -Rn --arg value "$TENANT_ADMIN_LOGIN" '$value')" \
  "$(jq -Rn --arg value "$TENANT_ADMIN_PASSWORD" '$value')"

For each identity:

  1. Copy the ersLogin generated login command.
  2. Paste that identity's generated login command in the browser console.
  3. Confirm the returned status is 200.
  4. Run location.reload().

To log out before changing identities, use this standalone command; it does not depend on the helper:

await fetch("/api/v1/auth/logout", {
  method: "POST",
  credentials: "include",
});
location.reload();

Do not include passwords in screenshots or shared logs.

10.1 Application Admin UI

Run the generated Application Admin browser command, confirm status 200, reload, and verify:

  • /admin/authorization opens.
  • Actor nickname filtering works.
  • Create Actor Collaborator progressive filtering works.
  • Role grant and revoke controls work.
  • A non-current Actor can be activated and deactivated.
  • Audit Log Viewer opens.
  • Tenant Administration opens.
  • Second-person approval settings open.
  • Gold-price administration opens.
  • Price List administration opens.
  • No missing_actor error appears.
  • No request-Actor reset control appears.
  • No raw authentication JSON is rendered.

10.2 Legacy identity mechanism is absent

Open Developer Tools → Network and reload /admin/authorization.

Confirm:

  • Protected frontend requests do not send X-Actor-ID.
  • Protected frontend requests do not send X-Actor-Permissions.
  • X-Tenant-ID may still be sent.
  • The session cookie is sent automatically.
  • Local storage does not contain ers.authzAdmin.requestActor.

10.3 Expenses Operator UI

Log out with the standalone logout command above. Paste the ersLogin helper again, run the generated Expenses Operator login command, confirm status 200, and reload.

Verify:

  • Collaborators can be viewed.
  • New Expense opens.
  • Progressive Collaborator search works.
  • A valid Expense can be created.
  • The Expense appears in the Collaborator Current Account.
  • The corresponding receipt workflow remains available.
  • /admin/authorization is denied.
  • Administrative settings are denied.
  • Denied pages show understandable guidance, not raw JSON.

10.4 Earnings Operator UI

Log out with the standalone logout command. Paste the ersLogin helper again, run the generated Earnings Operator login command, confirm status 200, and reload.

Verify:

  • Work Period planning opens.
  • Planning rows load.
  • An allowed planning change can be saved.
  • An allowed earnings operation can be completed.
  • New Expense is denied.
  • Price List administration is denied.
  • Authorization administration is denied.
  • Denied pages show understandable guidance.

10.5 Tenant Admin UI

Log out with the standalone logout command. Paste the ersLogin helper again, run the generated Tenant Admin login command, confirm status 200, and reload.

Verify:

  • Own-tenant People can be viewed.
  • An allowed own-tenant record can be created or updated.
  • Selecting the second tenant is denied.
  • B28C Secondary Tenant Person is never displayed.
  • Tenant selection does not change the authenticated Actor identity.

10.6 Direct URL and page-reload behavior

With an authorized session, directly open and reload:

http://localhost:5173/people
http://localhost:5173/collaborators
http://localhost:5173/expenses
http://localhost:5173/admin/authorization

Confirm:

  • Authorized pages survive reload.
  • Session resolution does not require an Actor header.
  • Unauthorized roles receive the correct access-denied state.
  • No transient missing_actor message appears.

10.7 Browser logout

await fetch("/api/v1/auth/logout", {
  method: "POST",
  credentials: "include",
});
location.reload();

Confirm:

  • Protected requests return authentication_required.
  • The frontend does not restore bootstrap-admin.
  • Refreshing does not silently recreate a session.

Phase 11 — Audit and security tests

11.1 Audit attribution uses the session-derived Actor

Using the Application Admin session:

  1. Grant EXPENSE_OPERATOR to the Grant/Revoke Actor.
  2. Revoke the role.
  3. Open the Audit Log Viewer.

Confirm:

  • The event identifies the authenticated Application Admin Actor.
  • Tenant attribution is correct.
  • Action, target, and timestamp are present.

Repeat an admin curl request with a forged X-Actor-ID while using $ADMIN_COOKIE.

Confirm:

  • Audit attribution remains attached to the Application Admin session Actor.
  • The forged Actor key is not recorded as the acting identity.

11.2 Backend restart preserves valid persisted sessions

Confirm the Application Admin cookie works:

curl -sS \
  -b "$ADMIN_COOKIE" \
  -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
  "$BASE_URL/people?page=1&pageSize=1" \
| jq .

Restart the Bite 28C backend.

Reuse the same cookie:

HTTP_STATUS=$(
  curl -sS \
    -o /tmp/ers-b28c-after-restart.json \
    -w '%{http_code}' \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/people?page=1&pageSize=1"
)

echo "HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-after-restart.json

Expected: HTTP 200, with the same Actor and persisted grants.

  • Passed

11.3 Recent reauthentication continuity

This test applies only to operations already protected by ERS recent-reauthentication policy.

Using a naturally stale session:

  1. Attempt a sensitive operation.
  2. Confirm recent_reauthentication_required.
  3. Log in again.
  4. Retry the permitted sensitive operation.
  5. Confirm forged Actor headers cannot bypass the requirement.

Do not change environment configuration merely to force expiration. When no naturally stale session is available, record:

  • Passed
  • Not manually exercised; automated recent-reauthentication coverage reviewed

Phase 12 — Final regression smoke tests

Application Admin

  • View Actors.
  • Grant and revoke a role.
  • View Audit Logs.
  • View both tenants.
  • Administer tenant settings.

Tenant Admin

  • View own-tenant People.
  • Update an allowed own-tenant record.
  • Confirm the second tenant is inaccessible.

Expenses Operator

  • View Collaborators.
  • Create an Expense.
  • Confirm Current Account posting.
  • Confirm receipt workflow.
  • Confirm Authorization Administration remains forbidden.

Earnings Operator

  • Open planning.
  • Save an allowed planning change.
  • Complete an allowed earnings operation.
  • Confirm Expense creation remains forbidden.

Phase 13 — Cleanup

Use the same API-test shell. If necessary, restore the generated variables and cookie paths:

source /tmp/ers-b28c-manual-env.sh

ADMIN_COOKIE="${ADMIN_COOKIE:-/tmp/ers-b28c-admin.cookies}"

Refresh the Application Admin session before cleanup without relying on a previously defined shell function:

rm -f "$ADMIN_COOKIE"

HTTP_STATUS=$(
  jq -n \
    --arg login "$ADMIN_LOGIN" \
    --arg password "$ADMIN_PASSWORD" \
    '{login: $login, password: $password}' \
  | curl -sS \
      -o /tmp/ers-b28c-admin-cleanup-login.json \
      -w '%{http_code}' \
      -c "$ADMIN_COOKIE" \
      -H "Content-Type: application/json" \
      --data-binary @- \
      "$BASE_URL/auth/login"
)

echo "Cleanup login: HTTP $HTTP_STATUS"
jq . /tmp/ers-b28c-admin-cleanup-login.json

test "$HTTP_STATUS" = "200" || {
  echo "Application Admin cleanup login failed."
  exit 1
}

Ensure the Disposable Actor is active:

jq -n '{active: true}' \
| curl -sS \
    -X PATCH \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    -H "Content-Type: application/json" \
    --data-binary @- \
    "$BASE_URL/authz/actors/$DISPOSABLE_ACTOR_ID/active" \
| jq .

Resolve the Disposable authentication account ID even when the account-lifecycle test was skipped:

curl -sS \
  -b "$ADMIN_COOKIE" \
  -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
  "$BASE_URL/auth/accounts" \
  > /tmp/ers-b28c-cleanup-accounts.json

DISPOSABLE_ACCOUNT_ID=$(
  jq -er \
    --arg actorId "$DISPOSABLE_ACTOR_ID" '
      def accounts:
        if (.data | type) == "array" then .data
        elif (.data.items | type) == "array" then .data.items
        else [] end;
      accounts[] | select(.actorId == $actorId) | .id
    ' \
    /tmp/ers-b28c-cleanup-accounts.json
)

Ensure the account is active:

jq -n '{active: true}' \
| curl -sS \
    -X PATCH \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    -H "Content-Type: application/json" \
    --data-binary @- \
    "$BASE_URL/auth/accounts/$DISPOSABLE_ACCOUNT_ID/active" \
| jq .

Remove every active role grant from the Grant/Revoke Actor:

curl -sS \
  -b "$ADMIN_COOKIE" \
  -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
  "$BASE_URL/authz/actors" \
  > /tmp/ers-b28c-cleanup-actors.json

for grant_id in $(
  jq -r \
    --arg actorId "$GRANT_REVOKE_ACTOR_ID" '
      def actors:
        if (.data | type) == "array" then .data
        elif (.data.items | type) == "array" then .data.items
        else [] end;
      actors
      | map(select(.id == $actorId))
      | first
      | (.roleGrants // .grants // [])[]?
      | select(.active == true)
      | .id
    ' \
    /tmp/ers-b28c-cleanup-actors.json
); do
  curl -sS \
    -X DELETE \
    -b "$ADMIN_COOKIE" \
    -H "X-Tenant-ID: $PRIMARY_TENANT_ID" \
    "$BASE_URL/authz/actors/$GRANT_REVOKE_ACTOR_ID/role-grants/$grant_id" \
  | jq .
done

Remove test cookies:

rm -f \
  "${ADMIN_LOGOUT_COOKIE:-/tmp/ers-b28c-admin-logout.cookies}" \
  "${TENANT_ADMIN_COOKIE:-/tmp/ers-b28c-tenant-admin.cookies}" \
  "${EXPENSES_COOKIE:-/tmp/ers-b28c-expenses.cookies}" \
  "${EARNINGS_COOKIE:-/tmp/ers-b28c-earnings.cookies}" \
  "${DISPOSABLE_COOKIE:-/tmp/ers-b28c-disposable.cookies}" \
  "${GRANT_REVOKE_COOKIE:-/tmp/ers-b28c-grant-revoke.cookies}"

Remove the temporary Development worktree. First remove the copied ignored files and database link:

MAIN_ROOT="/Users/rodrigosilveira/projects/EnterpriseRemoteSystems"
DEVELOPMENT_ROOT="/Users/rodrigosilveira/projects/EnterpriseRemoteSystems-development-bootstrap"

rm -f \
  "$DEVELOPMENT_ROOT/backend/.env" \
  "$DEVELOPMENT_ROOT/backend/data/app.db"

# make local-backend chmods this tracked script; restore the temporary worktree copy.
git -C "$DEVELOPMENT_ROOT" checkout -- scripts/dev-backend.sh

cd "$MAIN_ROOT"

if git -C "$DEVELOPMENT_ROOT" \
  rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  git worktree remove \
    "$DEVELOPMENT_ROOT"
fi

Keep /tmp/ers-b28c-manual-env.sh until all acceptance evidence has been recorded. It contains local test passwords and has mode 600.

After the run is fully complete:

rm -f \
  /tmp/ers-b28c-manual-env.sh \
  /tmp/ers-b28c-local-db.env

Phase 14 — Final automated verification

After cleanup and before promotion, run the canonical local verification from the main project root:

cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems

make local-check

When relevant to the deployment path, also run:

make local-docker-check

Record the results:

  • make local-check passed.
  • make local-docker-check passed, or was documented as not applicable.

Promotion gate

Promote Bite 28C only when every applicable item is true:

  • The preparation script completed successfully.
  • The generated environment file contains all tenant, Person, Collaborator, Actor, login, and password variables.
  • All six authentication accounts logged in successfully before acceptance testing.
  • Unauthenticated protected requests return authentication_required.
  • X-Actor-ID cannot authenticate or switch identity.
  • X-Actor-Permissions cannot grant permissions.
  • Valid sessions resolve the expected persisted Actor.
  • Application Admin permissions work.
  • Tenant Admin remains restricted to default.
  • EXPENSE_OPERATOR boundaries work.
  • Earnings Operator boundaries work.
  • Inactive Actors are rejected with 401 actor_inactive even when a prior session exists.
  • Inactive accounts invalidate prior sessions with 401 account_inactive and reject new login.
  • Role grants and revocations affect an existing session correctly.
  • Tenant selection changes scope but not identity.
  • Audit events use the session-derived Actor.
  • Logout invalidates only the logged-out session.
  • Persisted sessions behave correctly after backend restart.
  • The frontend no longer sends legacy Actor or permission headers.
  • The frontend no longer exposes request-Actor recovery.
  • Unauthorized pages show useful guidance rather than raw JSON.
  • People, Collaborator, planning, Expense, ledger, receipt, and Current Account workflows remain operational.
  • Every failed observation was recorded with login, tenant, operation, HTTP status, and error code.

Promotion decision

  • Promote Bite 28C
  • Do not promote — defects remain

Defects or observations

1.

2.

3.