NOTIFICATION_ADMIN_EMAILS made "add Beto to the summaries" a redeploy — the wrong unit of work for a list that changes when office staff change. Adds `app_settings`, a key/value table for the configuration staff must be able to change without a deploy, and `SettingsService`, which resolves every key db -> env -> default and reports which of the three a value came from. That ladder is what makes the move safe: a deployment behaves exactly as before until somebody saves in the UI, and the screen can say "this is still coming from the deployment" rather than implying somebody chose it. - new ability `setting:manage` (ADMIN) — deliberately above `notification:send`, since redirecting the audit summaries is how someone would quietly stop them being read - GET/PUT /notifications/settings/admin-emails; read is open to any logged-in user so the UI can display the list, write is gated - resolved per job, not cached at boot, or we would reintroduce exactly the restart-to-apply behaviour being removed - a saved empty list means "nobody" and does NOT fall through to the env, or clearing the field would keep mailing the people just removed Credentials stay in env — see the model doc for where the line is drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
101 lines
3.5 KiB
TypeScript
101 lines
3.5 KiB
TypeScript
// Server-authoritative permission matrix. Roles form an ordered rank
|
|
// (ADMIN > MANAGER > STAFF > VIEWER — this is the "level" concept); every
|
|
// write action carries a minimum rank. VIEWER holds rank 0 and is the
|
|
// read-only role. Reads are not listed here — they stay on AuthenticatedGuard
|
|
// alone, so any logged-in user (including VIEWER) can read.
|
|
//
|
|
// This is the single source of truth: the API enforces it via AbilityGuard and
|
|
// ships the resolved per-user map to the web through /auth/me, so the UI never
|
|
// keeps its own copy of the rules.
|
|
|
|
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
|
|
|
export const ROLE_RANK: Record<Role, number> = {
|
|
VIEWER: 0,
|
|
STAFF: 1,
|
|
MANAGER: 2,
|
|
ADMIN: 3,
|
|
};
|
|
|
|
export type Ability =
|
|
| "customer:create"
|
|
| "customer:update"
|
|
| "customer:delete"
|
|
| "policy:create"
|
|
| "policy:update"
|
|
| "policy:delete"
|
|
| "policy:ingest"
|
|
| "policy:ocr-review"
|
|
| "renewal:send"
|
|
| "property:create"
|
|
| "property:update"
|
|
| "property:delete"
|
|
| "ledger:create"
|
|
| "ledger:void"
|
|
| "bank:create"
|
|
| "bank:void"
|
|
| "bank:manage-accounts"
|
|
| "statement:ingest"
|
|
| "statement:review"
|
|
| "lookup:manage"
|
|
| "user:manage"
|
|
| "db:manage"
|
|
| "notification:send"
|
|
| "setting:manage";
|
|
|
|
/** Minimum role required for each ability. */
|
|
export const ABILITY_MIN: Record<Ability, Role> = {
|
|
"customer:create": "STAFF",
|
|
"customer:update": "STAFF",
|
|
"customer:delete": "ADMIN",
|
|
"policy:create": "STAFF",
|
|
"policy:update": "STAFF",
|
|
"policy:delete": "MANAGER",
|
|
// Insurance OCR intake is the same trust tier as statement OCR: STAFF can
|
|
// upload + confirm, nothing reaches the books unconfirmed.
|
|
"policy:ingest": "STAFF",
|
|
"policy:ocr-review": "STAFF",
|
|
"renewal:send": "MANAGER",
|
|
"property:create": "STAFF",
|
|
"property:update": "STAFF",
|
|
"property:delete": "MANAGER",
|
|
"ledger:create": "STAFF",
|
|
"ledger:void": "MANAGER",
|
|
"bank:create": "STAFF",
|
|
"bank:void": "MANAGER",
|
|
// Opening or renaming a chequera is rarer and higher-stakes than posting a
|
|
// movement into one — a wrong account silently mixes two sets of books.
|
|
"bank:manage-accounts": "MANAGER",
|
|
// Uploading a stack of scans and reviewing what the OCR read are both
|
|
// "capturing a receipt" — the same trust tier as ledger:create, since
|
|
// confirming a statement *is* capturing it. The review step is what makes
|
|
// this safe at STAFF level: nothing reaches the ledger unconfirmed.
|
|
"statement:ingest": "STAFF",
|
|
"statement:review": "STAFF",
|
|
"lookup:manage": "MANAGER",
|
|
"user:manage": "ADMIN",
|
|
"db:manage": "ADMIN",
|
|
// Mass email notifications — fires mail to customers on the office's
|
|
// behalf, with no per-row review. Same trust tier as `renewal:send`:
|
|
// a STAFF user typing one customer receipt is fine; a STAFF user firing
|
|
// 260 mail merges on the customer base is not.
|
|
"notification:send": "MANAGER",
|
|
// Editing operator configuration. Above `notification:send` on purpose:
|
|
// firing a sweep is the day job, but changing WHERE the audit summaries
|
|
// land is how someone would quietly stop them being read.
|
|
"setting:manage": "ADMIN",
|
|
};
|
|
|
|
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
|
|
|
export function can(role: Role, ability: Ability): boolean {
|
|
return ROLE_RANK[role] >= ROLE_RANK[ABILITY_MIN[ability]];
|
|
}
|
|
|
|
/** Resolved {ability: boolean} map for a role — sent to the web via /auth/me. */
|
|
export function abilitiesFor(role: Role): Record<Ability, boolean> {
|
|
return Object.fromEntries(
|
|
ALL_ABILITIES.map((a) => [a, can(role, a)]),
|
|
) as Record<Ability, boolean>;
|
|
}
|