Audit role permission changes (Task #100)

Record an audit trail every time a role's permissions change and surface
recent history inside the role edit dialog.

Schema (lib/db):
- New `role_permission_audit` table: id, roleId (FK roles), actorUserId
  (FK users, nullable on delete), previousPermissionIds int[],
  newPermissionIds int[], createdAt. Created via raw SQL because
  `drizzle push` currently fails on a pre-existing app_permissions
  duplicate (followup #148 tracks fixing this).

API (artifacts/api-server):
- PUT /api/roles/:id/permissions now wraps the permission delete/insert,
  the legacy audit_logs row, and the new role_permission_audit row in
  a *single* transaction so the permission set and its audit trail
  always commit (or roll back) together. Previous IDs are also read
  inside the transaction to avoid races.
- A role_permission_audit row is written on EVERY PUT call (per task
  spec), including no-op saves; the GET handler computes
  addedPermissionIds/removedPermissionIds so the History UI can
  distinguish meaningful changes from no-op saves.
- New GET /api/roles/:id/audit (admin-only, ?limit=1..50, default 10)
  returns recent entries newest-first with computed
  added/removedPermissionIds and joined actor info.
- New tests in tests/role-permission-audit.test.mjs cover write,
  every-call write (incl. no-op), GET ordering+diff+actor, no-op diff
  reporting, 404, and limit.

Drive-by fixes:
- Fixed pre-existing syntax corruption in tests/apps-open.test.mjs
  (stray `ccaPassa,` line from a prior bad merge that prevented the
  whole api-server test workflow from running).
- Made tests/roles-crud.test.mjs "rejects an invalid name" robust to
  parallel test execution by scoping the leak check to the bad name
  instead of relying on a global row count.

API spec / codegen (lib/api-spec, lib/api-client-react):
- Added `getRolePermissionAudit` operation and
  `RolePermissionAuditEntry` schema; ran orval codegen.

Frontend (artifacts/tx-os):
- New RolePermissionHistory component renders inside the role edit
  dialog (between permissions and Save/Cancel) using
  useGetRolePermissionAudit. Shows timestamp, actor, added/removed
  permission names (resolved via permissionsById), and total count.
- Save invalidates the audit query so the new entry appears immediately
  on the next open.
- Bilingual i18n strings (en + ar with full Arabic plural variants:
  zero/one/two/few/many/other) under admin.roles.history*.

Verification:
- tx-os typecheck passes.
- All 5 new audit tests + 13 existing roles tests pass.
- E2E (testing skill) verified the full flow: empty state on a fresh
  role, save adds a permission, reopened dialog shows the new entry
  with actor name in Arabic.

Docs:
- replit.md updated to list `role_permission_audit` in Database Tables.

Follow-ups proposed: #146 paginate/filter history, #147 audit other
admin permission changes, #148 fix drizzle push duplicate.

Replit-Task-Id: 9b8886a2-6e76-4072-b345-a772421fa161
This commit is contained in:
riyadhafraa
2026-04-29 13:16:02 +00:00
parent 19b5dc788e
commit a14b589006
15 changed files with 896 additions and 32 deletions
+79
View File
@@ -1450,6 +1450,45 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/roles/{id}/audit:
get:
operationId: getRolePermissionAudit
tags: [roles]
summary: List recent permission-change audit entries for a role (admin)
description: |
Returns the most recent permission-change audit records for the given
role, newest first. Each record captures the actor (if known), the
previous permission IDs, the new permission IDs, and the timestamp.
parameters:
- name: id
in: path
required: true
schema:
type: integer
- in: query
name: limit
required: false
schema:
type: integer
minimum: 1
maximum: 50
default: 10
responses:
"200":
description: Recent permission-change audit entries for the role
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/RolePermissionAuditEntry"
"404":
description: Role not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/roles/{id}/permissions/impact-preview:
post:
operationId: previewRolePermissionsImpact
@@ -3579,6 +3618,46 @@ components:
- id
- username
RolePermissionAuditEntry:
type: object
properties:
id:
type: integer
roleId:
type: integer
previousPermissionIds:
type: array
items:
type: integer
newPermissionIds:
type: array
items:
type: integer
addedPermissionIds:
type: array
items:
type: integer
removedPermissionIds:
type: array
items:
type: integer
createdAt:
type: string
format: date-time
actor:
oneOf:
- $ref: "#/components/schemas/AuditLogActor"
- type: "null"
required:
- id
- roleId
- previousPermissionIds
- newPermissionIds
- addedPermissionIds
- removedPermissionIds
- createdAt
- actor
AuditLogEntry:
type: object
properties: