Ops Console

Internal operations console with role-based access, an append-only audit log, and bulk actions that can be undone.

Status

Reference build

Stack

Next.jsTypeScriptPostgreSQLSupabase

Date

Jun 30, 2026

The Problem

Internal tools are where permission models go to rot. They start as one admin page for one team, then grow endpoints, roles, and bulk operations without anyone redesigning access control. The result is a console where support staff can reach billing mutations because roles were stacked additively, and where the answer to "who changed this customer's plan" is a shrug.

Four failure modes define this build:

  • ADMIN_ACTION_WITHOUT_AUDIT_LOG — a record changes and no one can reconstruct who did it, when, or what the value was before.
  • PERMISSION_LEAK — roles accumulate grants over time; a role built for one job quietly authorizes another.
  • BULK_ACTION_NO_UNDO — an operator selects 400 rows, runs the wrong action, and the only path back is a database restore.
  • STALE_LIST_AFTER_MUTATION — the operator acts on a list that no longer reflects the database, and compounds the first error with a second.

If you are still deciding whether a console like this is worth building at all, that math is covered in build vs buy for internal tools.

The System

Five entities: operators, roles, records, bulk jobs, and audit entries. Every mutation flows through one server-side action layer. There is no client-side path to the database.

Access control. Roles are deny-by-default. A role is a flat list of capability grants — records.read, records.update, jobs.execute — and anything not granted is denied. Checks run server-side on every action, not in the UI. Hiding a button is presentation; enforcement is the action layer rejecting the call.

Audit log. Every mutation writes an audit entry in the same transaction as the change itself. If the audit write fails, the mutation fails. The table is append-only at the database level, not by convention:

create table audit_log (
  id          bigint generated always as identity primary key,
  actor_id    uuid        not null references operators (id),
  action      text        not null,  -- 'record.update', 'job.execute'
  target_type text        not null,
  target_id   text        not null,
  before      jsonb,
  after       jsonb,
  created_at  timestamptz not null default now()
);

revoke update, delete on audit_log from app_role;

The before and after columns hold full snapshots, so the log answers "what did it look like" without replaying history.

Bulk actions. A bulk operation never mutates directly. It is staged as a job: the operator previews the affected rows, the job executes, and the inverse operations are stored alongside the result. For 60 seconds after execution the console shows an undo control that applies the inverse set. The undo is itself a job, and itself audited.

List freshness. Mutations apply optimistically in the UI, then reconcile against the server response. If server state diverges from the optimistic state — another operator changed the row, a job touched it — the row is flagged in place rather than silently corrected. When the primary store is slow, reads fall back to a cache and the console labels the data stale instead of spinning.

Constraint

The audit log covers the application. An operator with direct SQL access can bypass it. This build restricts that access to migrations run through CI; it does not pretend the hole is closed.

Design Decisions

Append-only table, not application-level logging. Log statements can be skipped by a new code path; a transaction-coupled audit write cannot. Tradeoff: every mutation pays a second write, and the table grows without bound — it is partitioned by month for that reason.

Deny-by-default roles, not permission subtraction. Subtractive models — "admin minus billing" — leak the moment a new capability ships, because new capabilities default to granted. Additive grants mean new features are invisible until explicitly assigned. Tradeoff: more role-management friction on day one, which is the correct place for the friction to live.

Staged jobs with undo, not confirmation dialogs. Confirmation dialogs train operators to click through them. A 60-second undo window assumes the mistake will happen and makes it cheap. Tradeoff: destructive effects are deferred until the window closes, which complicates anything downstream that expects immediate consistency.

Reconciliation, not aggressive cache invalidation. Invalidation-based freshness fails exactly when it matters — under concurrent edits. Reconciling optimistic state against the server response catches divergence per row and flags it to the human instead of guessing. Tradeoff: the UI needs a visible "this row changed under you" state, which is more design work than pretending the case cannot happen.

Named first, then built against. The write-up above covers the mechanism for each.

ADMIN_ACTION_WITHOUT_AUDIT_LOG

PERMISSION_LEAK

BULK_ACTION_NO_UNDO

STALE_LIST_AFTER_MUTATION

Constraint

The undo window is 60 seconds. After it closes, reversal is a new operation with its own audit entry, not a rollback.

Constraint

The audit log captures application-level actions. Direct database access bypasses it. That access is restricted and reviewed, not eliminated.

Constraint

Degraded-mode reads can be up to 30 seconds stale. The console labels stale data instead of hiding the condition.
audit_log
CAPTURE PENDING // audit_log
bulk_action_undo
CAPTURE PENDING // bulk_action_undo

Need a system like this behind your business? Same protocol, scoped to your failure modes.