// lab_build
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
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.
// failure_modes_handled
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
// constraints
Constraint
Constraint
Constraint
// screens
Need a system like this behind your business? Same protocol, scoped to your failure modes.