Files
jorgecuadros-platform/apps/api/src/auth/abilities.ts
T
rmancinasandClaude Opus 5 a491ef3eed
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m32s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m28s
feat(notificaciones): edit summary recipients in the UI
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>
2026-08-02 11:58:42 -07:00

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>;
}