// --------------------------------------------------------------------------- // settings / permsModel.ts — wave 15, contract C-PERM (ruling R9). // // The permission editor's PURE HALF: the wire shapes, the parse, the draft the // admin is editing, and the body the PUT sends. No React, no fetch, so the // rules that decide who can see what are testable under node without a browser // or a server (verify_login's `_test` harness runs them). // // ⛔ NOTHING HERE ENFORCES ANYTHING. Every rule below is about producing a // WELL-FORMED and HONEST payload; the wall is `core/perm_scope.permits()` on the // server, and it re-validates all of this. An editor that produced a malformed // record would be refused — the point of the care here is that it never gets // that far, and that the admin is never shown a state the server does not hold. // // THREE RULES THAT LOOK LIKE STYLE AND ARE NOT: // // 1. **A module with no readable schema renders as an ACCESS TOGGLE ONLY, and // saves `{access, filter: null, hiddenFields: []}`** (R9). The failure mode // it prevents: half a filter tree, PUT against a field list nobody could // read. Fail-closed is not "deny everything" here — it is "never write a // restriction you could not show the admin". // 2. **An unknown field TYPE lands on `text`**, it does not disappear and it // is not passed through. Passing it through would hand the condition // builder an operator set for a type it does not have; dropping the field // would take away an admin's ability to HIDE a column just because they // cannot filter on it. `text` is the house fallback the viz layer already // uses for exactly this (verify_ui's `field-vocabulary-not-whitelisted`). // 3. **A module the server no longer declares is DROPPED by the whole-record // replace — and the editor says so out loud** (`orphanModules`). A silent // drop of somebody's permanent filter is a permission change nobody made. // --------------------------------------------------------------------------- import type { Field, FieldType, FilterTree } from "../customer-grid/types"; // --- who may reach the editor at all ---------------------------------------- /** The modal's rail entries. Declared HERE, beside the rule that gates them, so * the component and the gate read one definition of the vocabulary rather than * two that can drift by one member. `SettingsModal` re-exports it. */ export type SettingsSection = | "account" | "scope" | "users" // Wave 18 (C7): the tenant's credential store and its data-source status board. | "keychains" | "connectors" /** EXIT-6: the statement-of-account sender, ported off `app.py` when Streamlit * was deleted. Admin-only for the same reason the Streamlit section was — * it is THE one sanctioned Odoo writer in the product. */ | "statements" /** * Wave 19 (R3 / contract C2): the Loopable admin plane — the cross-TENANT * console, visible only to a `platform_admin` account. * * ⛔ DELIBERATELY ABSENT FROM `reachableSection` BELOW, and that is not an * oversight. Every other admin room is gated on `admin`, a boolean the shell * already holds on the user record. `platform_admin` is a different predicate * that arrives ASYNCHRONOUSLY on the settings payload — and R3 is explicit * that a tenant-scoped `is_admin` does NOT qualify for it. Folding it into a * function whose only input is `admin` would either grant the section to every * tenant admin or deny it to the one account that has it. It is gated where * the flag actually lives, at the render site in `SettingsModal`. */ | "padmin"; /** * A non-admin never LANDS on the users pane — and therefore never reaches the * permission editor, which lives inside it. * * ⛔ THIS IS A COURTESY, NOT THE CHECK. The server refuses every `/admin/*` * route to a member regardless, and `/perms` is one of them. What this prevents * is a member deep-linked (or restored) into a room whose every control would * 403 — an empty frame that reads as a broken product rather than as a closed * door. It is a named function instead of an inline ternary for one reason: an * inline ternary cannot have a negative control, and "the pane a non-admin gets * bounced out of" is exactly the kind of rule that gets refactored away by * someone who does not know it is load-bearing. */ export function reachableSection(section: SettingsSection, admin: boolean): SettingsSection { // ⭐⭐ WAVE 32 · R4 — `keychains` AND `connectors` LEFT THIS LIST, and the ruling is the reason. // // Wave 18 (C7) put them here because "every control inside them would 403 a member" — which was // TRUE while every credential was tenant-wide. R4 ends that: *"the business-wide vs personal // split lands on ALL connections … business-wide is admin-only"*, i.e. a member now genuinely // owns something in these rooms — their own personal connections. // // ⛔ THE SERVER MOVED FIRST, and this line follows it rather than leading. `routes_keychain` // dropped `admin_gate` for `require_session` and put the wall in the ROW (`may_see` / // `_may_touch`): a member sees the business-wide entries plus their own, may create only a // personal one, and gets `403 not_admin` with a sentence if they ask for business-wide. Had // this list changed alone, a member would land in a room whose every control 403s — the empty // frame this function's own header calls "a broken product rather than a closed door". const adminOnly = section === "users" || // EXIT-6: every control in the statements room would 403 a member, and the // one at the bottom sends customer email. It belongs in this list twice over. section === "statements"; return adminOnly && !admin ? "account" : section; } // --- the wire (C-PERM) ------------------------------------------------------ /** One module's rule for one user. The shape `PUT /admin/users/{u}/perms` takes. */ export interface PermsEntry { /** May this account open the module at all. `false` ⇒ `may_open` denies. */ access: boolean; /** The PERMANENT filter, AND-ed under everything the user does. `null` = none. * ⚠ The whole tree — `{conj?, nodes}` — never a bare node list: `[A, B]` under * `or` means something entirely different from `[A, B]` under `and`, and the * loss is invisible in every payload (C-PERM amendment 2). */ filter: FilterTree | null; /** Field keys this account never receives. Server-stripped from every wire. */ hiddenFields: string[]; } export type PermsRecord = Record; /** A module the editor can draw a full rule for: it came with a field list. */ export interface PermsModule { key: string; label: string; /** Empty ⇒ no readable schema ⇒ access toggle only (rule 1 above). */ fields: Field[]; } export interface PermsPayload { /** In server order — the order the sections render in. */ modules: PermsModule[]; entries: PermsRecord; /** Keys in `perms` that the server no longer declares. A whole-record replace * drops them; the editor states that before the admin saves. */ orphanModules: string[]; /** C-PERM amendment 4's migration marker. ABSENT means this record still runs * under the LEGACY wall (`bus`/`agent` query scope + the `modules` grant), so * the editor must not present empty perms as "no access" — that would be a * confident lie about an account that can currently see everything. */ migrated: boolean; /** This account is an ADMIN, and admins bypass `perms` entirely. Every rule * the editor can draw for them is inert — which it has to say out loud. */ isAdmin: boolean; } // --- parsing ---------------------------------------------------------------- /** Every `FieldType`, as a runtime set. * * ⚠ The `Record` is the point, not the Set: it is a * COMPILE-TIME exhaustiveness check. Add a type to the union in * `customer-grid/types.ts` and this file stops compiling until it is listed * here — which is how a whitelist stays honest across a tree boundary S3 does * not own. A hand-maintained array would silently narrow instead. */ const FIELD_TYPE_TABLE: Record = { text: true, status: true, currency: true, int: true, date: true, pct: true, select: true, user: true, multiselect: true, checkbox: true, phone: true, email: true, url: true, rating: true, created_time: true, formula: true, automation: true, // Wave-22 C7 (added by C, the same one-key edit the alarm demands). metric: true, // Wave-19 R7 (added by session A — see the dated amendment in the split doc). This ONE key is // the whole edit: the exhaustiveness alarm above did exactly what it promises, and the fix it // names is a listing here. Nowhere near `SettingsSection` / the rail, which is B's half of // this file. image: true, // Wave-23 C7 (added by session D — `settings/**` is frozen this wave and this ONE key is the // exception the freeze cannot cover: the alarm four lines up is a COMPILE error, so the union // and this listing cannot land in two different changes. Posted in D's mailbox for C.) json: true, // 2026-08-07 — the relational pair, listed for the reason the alarm above states and for no // other: the exhaustiveness check is a COMPILE error, so the union and this listing cannot // land in two separate changes. Nothing about the permissions wall treats either kind // specially — a link/rollup column is granted and hidden like any other column. link: true, rollup: true, // ⭐ Wave-27 item 13 (R13) — same one-key edit, same reason, and `settings/**` is D's fence // this wave so it is not even an exception: the alarm above is a COMPILE error, so the union // and this listing cannot land in two changes. Nothing about the permissions wall treats a // code column specially — it is granted and hidden like any other column. code: true, }; export const KNOWN_FIELD_TYPES: ReadonlySet = new Set(Object.keys(FIELD_TYPE_TABLE)); /** Rule 2: whitelist, never pass through, never drop. */ export function fieldType(raw: unknown): FieldType { return typeof raw === "string" && KNOWN_FIELD_TYPES.has(raw) ? (raw as FieldType) : "text"; } function asStringArray(raw: unknown): string[] { return Array.isArray(raw) ? raw.filter((v) => typeof v === "string") : []; } /** One field of a module's schema, from the nav/schema payload shape. * `key` and `label` are the minimum that makes a row renderable — a field * without them cannot be shown OR named in a rule, so it is skipped rather * than rendered as a blank the admin might tick. */ export function parseField(raw: unknown): Field | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record; if (typeof r.key !== "string" || r.key === "") return null; const label = typeof r.label === "string" && r.label !== "" ? r.label : r.key; const options = asStringArray(r.options); return { key: r.key, label, type: fieldType(r.type), // Anything not explicitly the overlay stratum is treated as source data. // Consequence in this editor: nothing here offers to EDIT a field, so the // only thing `source` drives is the "Pre-set" chip in the hide list. source: r.source === "overlay" ? "overlay" : "odoo", // Carried because `identityKey` reads it — see the rule there. ...(r.pinned === true ? { pinned: true } : {}), ...(options.length ? { options } : {}), ...(typeof r.note === "string" ? { note: r.note } : {}), ...(typeof r.description === "string" && r.description !== "" ? { note: r.description } : {}), }; } /** `fields_by_module[key]` is a BARE ARRAY of fields — S1's canonical answer * (routes_admin `get_perms`), not the nav/schema envelope the contract line * implied. The earlier tolerant branch that also read `{fields: […]}` is gone: * one shape, asserted here, beats two readings of a sentence. * * The LABEL does not come from here — it rides the payload's `modules` list, * which is also what fixes the ORDER. Anything unreadable yields an EMPTY field * list, which R9 already has a defined rendering for (access toggle only). */ export function parseModule(key: string, label: string, raw: unknown): PermsModule { const fields: Field[] = []; for (const f of Array.isArray(raw) ? raw : []) { const parsed = parseField(f); if (parsed) fields.push(parsed); } return { key, label: label || key, fields }; } /** A filter is taken WHOLE or not at all. A tree whose `nodes` is not a list is * not a narrower filter, it is an unreadable one — and this editor's job is to * never show a rule it could not also save back. */ export function parseFilter(raw: unknown): FilterTree | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const r = raw as Record; if (!Array.isArray(r.nodes)) return null; const conj = r.conj === "or" ? "or" : r.conj === "and" ? "and" : undefined; return { ...(conj ? { conj } : {}), nodes: r.nodes as FilterTree["nodes"], }; } /** ⚠ ABSENT ENTRY ⇒ `access: false`, matching what the server will ENFORCE for a * migrated record (C-PERM amendment 4: `perms_v == 1` + no entry ⇒ DENY). The * editor showing "access on" for a module the wall denies would send an admin * to debug a permission that was never granted. * * For an UN-migrated record the same absence means the opposite — the legacy * wall still applies and may grant everything — which is why `PermsPayload` * carries `migrated` and the editor states it rather than letting this default * speak for a case it does not describe. */ export function parseEntry(raw: unknown): PermsEntry { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return { access: false, filter: null, hiddenFields: [] }; } const r = raw as Record; return { access: r.access === true, filter: parseFilter(r.filter), hiddenFields: normalizeHidden(asStringArray(r.hiddenFields)), }; } /** Sorted + de-duplicated, always. Dirty-detection compares payloads, so a list * whose ORDER can drift would make an untouched module read as edited. */ export function normalizeHidden(keys: readonly string[]): string[] { return [...new Set(keys)].sort(); } /** * The IDENTITY column — the one field a module may never hide. * * ⛔ WHY THIS EXISTS AT ALL. `FieldsHidePanel` takes an optional `lockedKey` and * documents that "absent = nothing is locked". Absent, a single click on * "Hide all" hides EVERY field including the row's own name — and with * amendment 5's server-side transitive closure behind it, that writes a record * whose faithful enforcement is a table of blank rows. The user could open the * module and see nothing in it, the wall doing exactly what the record said. * Nothing in C-PERM's PUT validation refuses that: it refuses UNKNOWN field * keys, and the identity column is perfectly known. * * ⚠ MIRRORED, NOT INVENTED: this is `useGridColumns.ts:201-204`'s rule verbatim * — the pinned field, else the first. The grid computes the same key to decide * which column its own Hide-fields panel locks, so an editor using a different * rule would lock a different column from the one the table protects. */ export function identityKey(fields: readonly Field[]): string { return fields.find((f) => f.pinned)?.key ?? fields[0]?.key ?? ""; } /** Every field a module MAY hide — the whole list minus its identity column. */ export function hideableKeys(fields: readonly Field[]): string[] { const locked = identityKey(fields); return fields.filter((f) => f.key !== locked).map((f) => f.key); } /** `{perms, fields_by_module}` → everything the editor renders. Returns `null` * only for a body that is not an object at all; a body missing either half * parses to an editor with nothing to offer, which is the honest rendering of * "the server told us nothing". */ export function parsePermsPayload(body: unknown): PermsPayload | null { if (!body || typeof body !== "object") return null; const b = body as Record; const byModule = b.fields_by_module && typeof b.fields_by_module === "object" ? (b.fields_by_module as Record) : {}; const rawPerms = b.perms && typeof b.perms === "object" && !Array.isArray(b.perms) ? (b.perms as Record) : {}; // ⚠ `modules` CARRIES THE LABELS AND THE ORDER, and `fields_by_module` carries // neither. Deriving sections from the field map alone renders every heading as // a registry KEY — "customer_data" where the product says "Customer" — and in // whatever order the JSON happens to enumerate. The field map is still the // outer bound: a module named in `modules` with no field list is the // schema-less case, not an error. const declared: Array<{ key: string; label: string }> = Array.isArray(b.modules) ? (b.modules as unknown[]).flatMap((m) => { if (!m || typeof m !== "object") return []; const r = m as Record; return typeof r.key === "string" && r.key ? [{ key: r.key, label: typeof r.label === "string" ? r.label : r.key }] : []; }) : Object.keys(byModule).map((k) => ({ key: k, label: k })); const modules = declared.map((d) => parseModule(d.key, d.label, byModule[d.key])); const entries: PermsRecord = {}; for (const m of modules) entries[m.key] = parseEntry(rawPerms[m.key]); return { modules, entries, orphanModules: Object.keys(rawPerms).filter((k) => !(k in entries)).sort(), migrated: typeof b.perms_v === "number" && b.perms_v >= 1, // `role == 'admin'` bypasses `perms` entirely (C-PERM amendment 4). Sent by // the route so the editor can SAY so instead of rendering stored rules that // do not apply — the one misreading of that clause that could hurt. isAdmin: b.is_admin === true, }; } // --- the draft the admin is editing ----------------------------------------- /** Immutable edits: every setter returns a NEW record, so React sees the change * and `isDirty` compares against a snapshot that no setter has mutated under * it. In-place edits are how a Save button ends up permanently greyed. */ function withEntry(rec: PermsRecord, key: string, patch: Partial): PermsRecord { const cur = rec[key] ?? { access: false, filter: null, hiddenFields: [] }; return { ...rec, [key]: { ...cur, ...patch } }; } export function setAccess(rec: PermsRecord, key: string, access: boolean): PermsRecord { return withEntry(rec, key, { access }); } export function setFilter(rec: PermsRecord, key: string, filter: FilterTree | null): PermsRecord { // An empty tree is NO filter, not an empty one. `{nodes: []}` matches every // row, so persisting it would mean "restricted, to everything" — a rule that // reads as a restriction in the record and is not one on screen. const empty = !filter || filter.nodes.length === 0; return withEntry(rec, key, { filter: empty ? null : filter }); } export function setHidden(rec: PermsRecord, key: string, hidden: readonly string[]): PermsRecord { return withEntry(rec, key, { hiddenFields: normalizeHidden(hidden) }); } export function toggleHidden(rec: PermsRecord, key: string, fieldKey: string): PermsRecord { const cur = rec[key]?.hiddenFields ?? []; const next = cur.includes(fieldKey) ? cur.filter((k) => k !== fieldKey) : [...cur, fieldKey]; return setHidden(rec, key, next); } export function hiddenSet(rec: PermsRecord, key: string): ReadonlySet { return new Set(rec[key]?.hiddenFields ?? []); } export function filterOf(rec: PermsRecord, key: string): FilterTree { // The panel takes a tree, never null — an absent filter is an EMPTY tree to // edit, which is what "add your first condition" has to render against. return rec[key]?.filter ?? { nodes: [] }; } // --- what gets sent --------------------------------------------------------- /** * The PUT body. Whole-record replace, so this emits an entry for EVERY module * the server declared — including the ones the admin never touched, because a * missing key in a replace is a deletion, not a no-op. * * ⚠ A module with no readable schema is emitted `{access, filter: null, * hiddenFields: []}` (R9). Its access toggle is real and is honoured; what is * refused is inventing a restriction against a field list nobody could read. */ export function toPutBody(payload: PermsPayload, rec: PermsRecord): { perms: PermsRecord } { const perms: PermsRecord = {}; for (const m of payload.modules) { const e = rec[m.key] ?? { access: false, filter: null, hiddenFields: [] }; // ⚠ The identity column is stripped at the BOUNDARY as well as withheld // from the UI. The panel never offers it, but a record written before this // rule — or by anything else — could still name it, and this editor is the // last place that record passes through before the wall enforces it // faithfully. Two layers for the same reason C-PERM validates at PUT time // AND `permits()` re-checks: records go stale in ways forms cannot. const locked = identityKey(m.fields); perms[m.key] = m.fields.length ? { access: e.access, filter: e.filter, hiddenFields: normalizeHidden(e.hiddenFields.filter((k) => k !== locked)), } : { access: e.access, filter: null, hiddenFields: [] }; } return { perms }; } /** Which slice of one account's access a copy carries onto another. */ export type CopyScope = { kind: "all" } | { kind: "module"; key: string }; /** * C-PERMCOPY (wave 17, item 16) — the record to PUT onto ONE target, composed * from that target's OWN payload and this account's SAVED entries. * * ⛔ WHY THIS IS A FUNCTION AND NOT FOUR LINES IN THE COMPONENT. It is the * SECOND producer of a PUT body in the product, and the first one aimed at * somebody else's record. A PUT is a whole-record replace: a key that does not * come back is DELETED. `toPutBody` already carries a negative control named * `replace-omits-untouched-modules` for exactly that failure — and every way of * getting it wrong from here reaches the same place by a different road. Inline * in a `useCallback`, no gate could see any of it. * * THE RULE, in one line: **the target's tree is the base; the copy overwrites * a slice of it.** Never the source's tree with the target's bits merged in — * that composes a record out of modules the TARGET may not declare. * * · `{kind:'all'}` — the whole record is replaced by the source's. * · `{kind:'module'}` — that one module is replaced; every other module keeps * the value the target's own GET returned, byte for byte. * * ⚠ TWO EDGES, both currently unreachable because `_PERM_MODULES` is server-wide * (every payload declares the same modules), and both named rather than left to * be discovered if that ever stops being true: * 1. The source has a module the TARGET does not declare. `toPutBody` iterates * the TARGET's modules, so the copy is dropped — silently, under a message * that says it was copied. `copyDropped()` below is what lets the caller * tell the truth about that. * 2. The target declares a module the SOURCE has no entry for. It lands on * `NO_ACCESS`, which is the honest reading of "apply this account's access": * if the source does not grant it, the target must not keep it. Deliberate, * and it is why a whole-record copy is offered as "replace", not "merge". */ export function copyTargetRecord( target: PermsPayload, source: PermsRecord, scope: CopyScope ): PermsRecord { if (scope.kind === "all") { const out: PermsRecord = {}; for (const m of target.modules) { out[m.key] = source[m.key] ?? { access: false, filter: null, hiddenFields: [] }; } return out; } return { ...target.entries, [scope.key]: source[scope.key] ?? { access: false, filter: null, hiddenFields: [] }, }; } /** The modules a copy CANNOT carry, because the target does not declare them. * Empty in every deployment where the module list is server-wide; a caller that * reports "copied" without consulting it would be guessing. */ export function copyDropped( target: PermsPayload, source: PermsRecord, scope: CopyScope ): string[] { const declared = new Set(target.modules.map((m) => m.key)); const wanted = scope.kind === "all" ? Object.keys(source) : [scope.key]; return wanted.filter((k) => !declared.has(k)).sort(); } /** Compares the SENT SHAPE, not the draft, so a change the payload cannot carry * (a hidden-field list on a schema-less module) never lights up Save. */ export function isDirty(payload: PermsPayload, saved: PermsRecord, draft: PermsRecord): boolean { return ( JSON.stringify(toPutBody(payload, saved)) !== JSON.stringify(toPutBody(payload, draft)) ); } // --- how a rule reads ------------------------------------------------------- /** Counts a tree's leaves, groups included. The editor states the size of a * restriction rather than showing "Filtered" for one condition and for twenty. */ export function countLeaves(tree: FilterTree | null): number { if (!tree) return 0; let n = 0; const walk = (nodes: readonly unknown[]) => { for (const node of nodes) { if (node && typeof node === "object" && Array.isArray((node as { children?: unknown[] }).children)) { walk((node as { children: unknown[] }).children); } else { n += 1; } } }; walk(tree.nodes); return n; } /** * One sentence per module for the accounts list and the section head. * * ⚠ NO EMOJI, no icon vocabulary — this string is read aloud by a screen reader * and printed in the gate's output. It also never says "restricted" without * saying to WHAT: "Filtered" alone is the kind of summary that makes an admin * open every section to find the one that is set. */ export function moduleSummary(entry: PermsEntry | undefined, schemaless = false): string { if (!entry || !entry.access) return "No access"; if (schemaless) return "Full access"; const conds = countLeaves(entry.filter); const hidden = entry.hiddenFields.length; if (!conds && !hidden) return "Full access"; const parts: string[] = []; if (conds) parts.push(`${conds} condition${conds === 1 ? "" : "s"}`); if (hidden) parts.push(`${hidden} field${hidden === 1 ? "" : "s"} hidden`); return parts.join(", "); } /** The account-list cell: what this user may open, across all modules. */ export function accessSummary(payload: PermsPayload | null, rec: PermsRecord): string { if (!payload || payload.modules.length === 0) return ""; const open = payload.modules.filter((m) => rec[m.key]?.access); if (open.length === 0) return "No access"; // ⚠ A SCHEMA-LESS MODULE CANNOT BE RESTRICTED, so it is never counted as one // — the same guard `moduleSummary` takes. Without it a draft filter on a // module whose rule `toPutBody` strips would be summarised as "1 restricted", // describing a restriction that is not going to be saved. The header and the // Save confirmation both read this sentence, so it must describe the PAYLOAD. const restricted = open.filter( (m) => m.fields.length > 0 && ((rec[m.key]?.filter?.nodes.length ?? 0) > 0 || (rec[m.key]?.hiddenFields.length ?? 0) > 0) ).length; const all = open.length === payload.modules.length; const head = all ? `All ${open.length} module${open.length === 1 ? "" : "s"}` : `${open.length} of ${payload.modules.length} modules`; return restricted ? `${head}, ${restricted} restricted` : head; }