Repository navigation
Authenticated Authorization Cutover
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-adminActor 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-IDandX-Actor-Permissionsmust not authenticate a request, switch an authenticated identity, or grant authority.X-Tenant-IDselects 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 useshttp_status, not Zsh's read-only special parameterstatus.
The clean preparation script uses the following concrete values.
| Identity | Desired login | Known local password | Required role |
|---|---|---|---|
| Application Admin | local.application.admin@example.com |
Local-Application-Admin-28C! |
APPLICATION_ADMIN for *
|
| Tenant Admin | local.tenant.admin@example.com |
Local-Tenant-Admin-28C! |
TENANT_ADMIN for default
|
| Expenses Operator | local.expenses.operator@example.com |
Local-Expenses-Operator-28C! |
EXPENSE_OPERATOR for default
|
| Earnings Operator | local.earnings.operator@example.com |
Local-Earnings-Operator-28C! |
EARNINGS_OPERATOR for default
|
| Disposable Actor | local.disposable.actor@example.com |
Local-Disposable-Actor-28C! |
EXPENSE_OPERATOR for default
|
| Grant/Revoke Actor | local.grant.revoke.actor@example.com |
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.
| 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.
| Purpose | Nickname | CPF | Cellular | |
|---|---|---|---|---|
| Tenant Admin | B28C Tenant Admin |
10020030088 |
11970000001 |
b28c.tenant.admin.person@example.com |
| Expenses Operator | B28C Expenses Operator |
10020030169 |
11970000002 |
b28c.expenses.operator.person@example.com |
| Earnings Operator | B28C Earnings Operator |
10020030240 |
11970000003 |
b28c.earnings.operator.person@example.com |
| Disposable Actor | B28C Disposable Actor |
10020030320 |
11970000004 |
b28c.disposable.actor.person@example.com |
| Grant/Revoke Actor | B28C Grant Revoke |
10020030401 |
11970000005 |
b28c.grant.revoke.person@example.com |
| Second-tenant isolation record | B28C Secondary Tenant Person |
10020030592 |
11970000006 |
b28c.secondary.tenant.person@example.com |
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 | 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 |
[ ] |
From the main project root:
cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems
git branch --show-current
git rev-parse HEAD
git status --shortExpected 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."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
}
doneStop any running backend and frontend with Ctrl+C.
Confirm port 8080 is free:
lsof -nP -iTCP:8080 -sTCP:LISTENExpected: 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
}Define the paths:
MAIN_ROOT="/Users/rodrigosilveira/projects/EnterpriseRemoteSystems"
DEVELOPMENT_ROOT="/Users/rodrigosilveira/projects/EnterpriseRemoteSystems-development-bootstrap"Fetch Development:
cd "$MAIN_ROOT"
git fetch originCreate 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
fiInspect it:
git -C "$DEVELOPMENT_ROOT" log -1 --oneline
git -C "$DEVELOPMENT_ROOT" status --shortConfirm 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.
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" ]]; then
LOCAL_DB="$BACKEND_ROOT/data/app.db"
elif [[ "$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.
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-------.
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
}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-backendLeave 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.
In a separate API-test terminal:
BASE_URL="http://127.0.0.1:8080/api/v1"
curl -i "$BASE_URL/healthz"Expected: HTTP 200.
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.jsonExpected: 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.
- The Development worktree exists.
- Bite 28B migration
000042exists in the worktree. - The worktree has a copied
backend/.env. -
backend/data/app.dblinks to the main Bite 28C database. - Development started with
ERS_DATABASE_PATH="data/app.db". - Health returns HTTP
200. -
bootstrap-adminheaders can retrieve/authz/actorswith HTTP200.
This phase owns the entire handoff. Do not repeat these steps in another phase.
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.shExpected 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."
fiConfirm 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."
fiValidate syntax:
/bin/bash -n \
scripts/ers-b28c-prepare-test-data-final.shVerify 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
}Keep the pre-cutover Development backend running, then execute:
cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems
/bin/bash \
scripts/ers-b28c-prepare-test-data-final.shExpected 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.shExpected 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.shReturn to the terminal running the Development backend and press Ctrl+C.
Confirm port 8080 is free:
lsof -nP -iTCP:8080 -sTCP:LISTENExpected: no output.
In a dedicated backend terminal:
cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems
git branch --show-current
git rev-parse HEAD
make local-backendExpected 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-frontendLeave the frontend running.
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.jsonExpected:
- HTTP
401. - Error code
authentication_required.
If this returns HTTP 200, the pre-cutover Development backend is still running.
source /tmp/ers-b28c-manual-env.shConfirm 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.
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"Remove any earlier definition:
unset -f login_test_account 2>/dev/null || trueDefine 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" ]]; then
jq . "/tmp/ers-b28c-${label}-login.json"
return 1
fi
echo "$label session:"
curl -sS \
-b "$cookie_file" \
"$BASE_URL/auth/session" \
| jq .
}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-revokeExpected for every identity:
- Login HTTP
200. -
/auth/sessionHTTP200. - Session response shows the expected login and Actor key.
- The corresponding cookie file exists.
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.shexists with mode600. - 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.shwas sourced. - Cookie-file variables were defined.
- The corrected helper uses
http_status, notstatus. - 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.
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.jsonExpected:
-
HTTP
401. -
Error code
authentication_required. -
No business data.
-
Passed
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.jsonExpected:
-
HTTP
401. -
Error code
authentication_required. -
Passed
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.jsonExpected:
-
HTTP
200. -
.data.loginequals$ADMIN_LOGINafter normalization. -
.data.actorKeyidentifiesbootstrap-admin. -
Passed
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.jsonExpected: HTTP 200.
- Passed
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.jsonExpected:
-
HTTP remains
200. -
The session still acts as the Application Admin.
-
Forged headers neither switch identity nor change privileges.
-
Passed
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.jsonExpected:
-
HTTP
401. -
Error code
invalid_credentials. -
Passed
Create a second admin session:
login_test_account \
"$ADMIN_LOGIN" \
"$ADMIN_PASSWORD" \
"$ADMIN_LOGOUT_COOKIE" \
admin-logoutLog 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.jsonExpected:
-
HTTP
401. -
Error code
authentication_required. -
$ADMIN_COOKIEremains valid. -
Passed
These tests distinguish an authorized request that reaches payload validation from a forbidden request that must be rejected before payload validation.
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.jsonExpected: HTTP 200.
- Passed
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.jsonExpected:
-
HTTP
400. -
Error code
validation_failed. -
This proves the Expenses Operator passed authorization and reached domain validation.
-
Passed
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.jsonExpected: HTTP 403 with a forbidden authorization error.
- Passed
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.jsonExpected:
-
HTTP
403. -
The request must not reach payload validation.
-
Passed
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
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.jsonExpected:
-
HTTP
400. -
Error code
validation_failed. -
This proves the Earnings Operator passed authorization for planning creation.
-
Passed
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.jsonExpected:
-
HTTP
403. -
The result must not be
400 validation_failed. -
Passed
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.jsonExpected: HTTP 403 before payload validation.
- Passed
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.jsonExpected: HTTP 403.
- Passed
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.jsonExpected: HTTP 200.
- Passed
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.jsonExpected:
-
HTTP
403. -
No
B28C Secondary Tenant Persondata. -
Passed
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.jsonExpected:
-
Both responses are HTTP
200. -
The isolation-tenant response contains
B28C Secondary Tenant Person. -
Passed
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.jsonCompare 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:
-
sameActorKeyistrue. -
Tenant IDs differ.
-
Passed
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.jsonExpected: 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.jsonExpected: 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
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.jsonExpected:
- 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
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-testDeactivate 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.jsonExpected:
- 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.jsonExpected:
- 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
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.jsonExpected: 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.jsonExpected: 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
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:
- Copy the
ersLogingenerated login command. - Paste that identity's generated login command in the browser console.
- Confirm the returned status is
200. - 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.
Run the generated Application Admin browser command, confirm status 200, reload, and verify:
-
/admin/authorizationopens. - 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_actorerror appears. - No request-Actor reset control appears.
- No raw authentication JSON is rendered.
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-IDmay still be sent. - The session cookie is sent automatically.
- Local storage does not contain
ers.authzAdmin.requestActor.
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/authorizationis denied. - Administrative settings are denied.
- Denied pages show understandable guidance, not raw JSON.
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.
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 Personis never displayed. - Tenant selection does not change the authenticated Actor identity.
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_actormessage appears.
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.
Using the Application Admin session:
- Grant
EXPENSE_OPERATORto the Grant/Revoke Actor. - Revoke the role.
- 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.
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.jsonExpected: HTTP 200, with the same Actor and persisted grants.
- Passed
This test applies only to operations already protected by ERS recent-reauthentication policy.
Using a naturally stale session:
- Attempt a sensitive operation.
- Confirm
recent_reauthentication_required. - Log in again.
- Retry the permitted sensitive operation.
- 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
- View Actors.
- Grant and revoke a role.
- View Audit Logs.
- View both tenants.
- Administer tenant settings.
- View own-tenant People.
- Update an allowed own-tenant record.
- Confirm the second tenant is inaccessible.
- View Collaborators.
- Create an Expense.
- Confirm Current Account posting.
- Confirm receipt workflow.
- Confirm Authorization Administration remains forbidden.
- Open planning.
- Save an allowed planning change.
- Complete an allowed earnings operation.
- Confirm Expense creation remains forbidden.
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 .
doneRemove 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"
fiKeep /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.envAfter cleanup and before promotion, run the canonical local verification from the main project root:
cd /Users/rodrigosilveira/projects/EnterpriseRemoteSystems
make local-checkWhen relevant to the deployment path, also run:
make local-docker-checkRecord the results:
-
make local-checkpassed. -
make local-docker-checkpassed, or was documented as not applicable.
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-IDcannot authenticate or switch identity. -
X-Actor-Permissionscannot grant permissions. - Valid sessions resolve the expected persisted Actor.
- Application Admin permissions work.
- Tenant Admin remains restricted to
default. -
EXPENSE_OPERATORboundaries work. - Earnings Operator boundaries work.
- Inactive Actors are rejected with
401 actor_inactiveeven when a prior session exists. - Inactive accounts invalidate prior sessions with
401 account_inactiveand 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.
- Promote Bite 28C
- Do not promote — defects remain
1.
2.
3.