Skip to content

PR #614: Super-admin impersonation + /admin/users + /admin/audit-log#640

Merged
barach6662001-bit merged 8 commits intomainfrom
feat/pr-614-super-admin-impersonation
Apr 25, 2026
Merged

PR #614: Super-admin impersonation + /admin/users + /admin/audit-log#640
barach6662001-bit merged 8 commits intomainfrom
feat/pr-614-super-admin-impersonation

Conversation

@barach6662001-bit
Copy link
Copy Markdown
Owner

Scope (locked per TZ ПУНКТ 14)

This is the core of PR #614. Sub-PRs #614a (system-wide actions), #614b (catalog editor), #614c (broadcast notifications) remain queued in ROADMAP Upcoming.

Backend

  • JWT impersonation token: 60-min hardcoded TTL, identity = target user, extra claims `impersonated_by_user_id`, `original_tenant_id`, `impersonation_reason`, `is_super_admin=false`. Non-renewable.
  • `ICurrentUserService`: `IsImpersonating`, `ImpersonatedByUserId`.
  • `IImpersonationService` (`ImpersonationService`):
    • Validates reason ≥10 chars, blocks self-impersonation, blocks impersonating other super-admins, blocks inactive users.
    • Rate limit 3/24h per (admin, target) — backed by partial composite index (migration in this PR).
    • Writes `SuperAdminAuditLog` rows with actions `impersonate.start`, `impersonate.end`, `impersonate.forbidden_attempt`.
    • Persists in-app `Notification` (severity=warning, title "Сесія імперсонації", Ukrainian body with full name + Kyiv timestamp + reason).
    • Best-effort email; silent skip when not configured.
  • `ImpersonationController`: `POST /api/admin/impersonate` (`[SuperAdminRequired]`) and `POST /api/admin/impersonate/end` (`[Authorize]` only — impersonation tokens carry `is_super_admin=false`).
  • `AdminController`: `GET /api/admin/users` (global cross-tenant search) and `GET /api/admin/audit-log` filters (action, adminUserId, fromUtc, toUtc, tenantId).
  • `ForbiddenDuringImpersonationAttribute` filter applied to `AuthController.ChangePassword`, `ApiKeysController.CreateApiKey/RevokeApiKey`, `UsersController.UpdateUserRole/ResetUserPassword` — returns 403 + audits `impersonate.forbidden_attempt`.

Migration

`20260425093014_AddImpersonationRateLimitIndex` — raw SQL partial composite index:
`CREATE INDEX IF NOT EXISTS ix_superadminauditlogs_impersonation_ratelimit ON SuperAdminAuditLogs (AdminUserId, TargetId, OccurredAt DESC) WHERE Action = 'impersonate.start';`

Tests (6 minimum, required)

`tests/AgroPlatform.IntegrationTests/SuperAdmin/ImpersonationIntegrationTests.cs`:

  1. Non-super-admin → 403.
  2. Super-admin without MFA → 403 + `X-Mfa-Required`.
  3. Happy path → 200 + token + audit row + Notification row ("Сесія імперсонації" + reason).
  4. Reason <10 chars → 400.
  5. 4th attempt within 24h → 429 (pre-seeded 3 audit rows).
  6. Forbidden change-password during impersonation → 403 + `impersonate.forbidden_attempt` audit row.

`TestAuthHandler` extended with `X-Test-ImpersonatedBy` header.

All 441 tests pass (315 unit + 126 integration).

Frontend

  • `authStore`: `impersonation` slice with target + original snapshots and ISO `expiresAtUtc`.
  • `ImpersonationBanner`: red, NOT closable / dismissable, z-index 9999, full viewport width, live countdown, "Вийти з режиму імперсонації" button (calls `/end` then locally restores `originalToken`).
  • `/admin/users`: global users table with email/name search, "Impersonate" button (disabled for super-admins) → reason modal (≥10 chars) → token swap → redirect to `/dashboard`.
  • `/admin/audit-log`: paginated audit table with filters (action, adminUserId, tenantId, fromUtc, toUtc) + expandable JSON payload row.
  • i18n: `uk.ts` + `en.ts` extended with users / audit-log / banner / modal keys.

ROADMAP / TZ

ROADMAP physically split: PR #614 = core (this PR), and #614a / #614b / #614c queued separately under Upcoming.

…alogs) + #614c (broadcast)

PR #614 scope as a single ticket was too broad (3 admin pages + impersonation
engine + audit log + global users + 4 sub-features each non-trivial). Split
into a sequenced set so each is reviewable:

- #614 (in progress): impersonation engine + /admin/users + /admin/audit-log
  + 6 integration tests + red banner + rate limit + forbidden-action filter
- #614a: /admin/system read-only health dashboard + audit-log CSV export
- #614b: /admin/catalogs (global reference data CRUD)
- #614c: /admin/broadcast (depends on PR #617 notifications fixes)

TZ.md status marker updated to reflect the split.
…filter, /admin/users + audit-log filters

- ICurrentUserService gains IsImpersonating + ImpersonatedByUserId
- JwtTokenService.GenerateImpersonationToken issues a 60min token with claims
  is_super_admin=false, impersonated_by_user_id, original_tenant_id, impersonation_reason
- IImpersonationService + ImpersonationService:
  * StartAsync: validates target (not self, not super-admin, active), enforces 3/24h
    rate limit by querying SuperAdminAuditLog, audits 'impersonate.start',
    inserts in-app Notification (severity warning, body in Ukrainian with Kyiv
    timestamp + reason), best-effort email
  * EndAsync: requires impersonated_by_user_id claim, audits 'impersonate.end',
    re-issues a fresh super-admin token for the original admin
  * LogForbiddenAttemptAsync: writes 'impersonate.forbidden_attempt' audit row
- ForbiddenDuringImpersonationAttribute (filter): on 403 also writes the
  forbidden-attempt audit row with the attempted route. Applied to:
  AuthController.ChangePassword, ApiKeysController create + revoke,
  UsersController.UpdateUserRole + ResetUserPassword
- ImpersonationController: POST /api/admin/impersonate (super-admin only),
  POST /api/admin/impersonate/end (callable from impersonation token)
- AdminController: GET /api/admin/users (global search across tenants with
  tenant name), GET /api/admin/audit-log gains action / adminUserId / fromUtc /
  toUtc / tenantId filters
CREATE INDEX ix_superadminauditlogs_impersonation_ratelimit
  ON SuperAdminAuditLogs (AdminUserId, TargetId, OccurredAt DESC)
  WHERE Action = 'impersonate.start'

Backs the 3/24h-per-(admin,target) rate-limit lookup in
ImpersonationService.StartAsync. Partial filter keeps the index small as
the audit log grows across all super-admin actions.
… with X-Test-ImpersonatedBy

- (1) non-super-admin caller -> 403
- (2) super-admin without MFA -> 403 + X-Mfa-Required
- (3) happy path -> 200 + 60min token + audit row + Notification row (Ukrainian
      title 'Сесія імперсонації', warning severity, body contains the reason)
- (4) reason < 10 chars -> 400
- (5) rate limit: 4th impersonation in 24h on same target -> 429
      (3 prior 'impersonate.start' rows pre-seeded directly into
      SuperAdminAuditLogs to keep the test deterministic and fast)
- (6) forbidden action under impersonation token (POST /api/auth/change-password)
      -> 403 AND writes a separate 'impersonate.forbidden_attempt' audit row

TestAuthHandler now also accepts X-Test-ImpersonatedBy for forbidden-action
coverage, matching the JWT contract (impersonation tokens always carry
is_super_admin=false).

All 441 tests pass (315 unit + 126 integration).
…min/audit-log

- authStore: add impersonation slice (target + original snapshots, expires)
- api/admin: startImpersonation / endImpersonation / listAdminUsers / listAdminAuditLog
- ImpersonationBanner: red, non-closable, z-index 9999, full viewport, countdown, exit restores original token
- /admin/users: global users table with reason modal (>=10 chars) + impersonate
- /admin/audit-log: filtered audit log with expandable payload
- i18n keys (uk + en) for users page, audit log, banner, modal
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd45e6addc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +176 to +180
await _audit.LogAsync(
ActionEnd,
targetType: "User",
targetId: _currentUser.UserId,
before: null,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use initiating admin ID for impersonation end audits

EndAsync calls _audit.LogAsync while the active principal is still the impersonated target user, so SuperAdminAuditService records AdminUserId as the target account (it derives this from ICurrentUserService.UserId). This misattributes impersonate.end events, breaks adminUserId filtering, and weakens audit accountability for who actually ended the session; the log path should pass/override the original adminId from ImpersonatedByUserId.

Useful? React with 👍 / 👎.

Comment on lines +49 to +53
setAuth(
impersonation.originalToken,
impersonation.originalEmail,
impersonation.originalRole,
impersonation.originalTenantId,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore session using server end-token, not stale snapshot

After POST /api/admin/impersonate/end, the code immediately restores impersonation.originalToken instead of using the fresh token returned by the backend. If the original token has expired during impersonation, the next API call fails with 401 even though /end succeeded, causing an avoidable forced re-login path; the exit flow should apply the response token as the source of truth.

Useful? React with 👍 / 👎.

Comment on lines +229 to +233
if (tenantId.HasValue)
{
var tid = tenantId.Value.ToString();
items = items.Where(x => x.TargetId == tid).ToList();
total = items.Count;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply tenant filter before paginating audit log

The tenantId predicate is applied only after Skip/Take, so each page is filtered in memory and total becomes the count of matches on that single page, not the full result set. This yields empty/partial pages and incorrect totals whenever tenant-matching rows fall outside the current page window; the tenant condition should be part of the database query before CountAsync and pagination.

Useful? React with 👍 / 👎.

… on ICurrentUserService

Avoids breaking 5 unit-test stubs (FakeCurrentUserService + per-test TestCurrentUserService) without forcing them to update.
@barach6662001-bit barach6662001-bit merged commit b029b0c into main Apr 25, 2026
3 checks passed
@barach6662001-bit barach6662001-bit deleted the feat/pr-614-super-admin-impersonation branch April 25, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant