From 7d658b502b3f23dfb796db81648444989aa90c12 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:51:00 +0000 Subject: [PATCH 1/2] feat(security): positions, permission sets, sharing rules, FLS and the position-set bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN.md §04 as metadata: the seven flat positions, the five permission sets with the permission matrix, the six-row sharing table (five criteria rules plus the own_and_reports depth on clm_requester), the field-level security table, and the kernel:bootstrapped binder that writes the sys_position_permission_set rows a seed cannot. Why the shape: - "all contracts" for legal is spelled as `status in [every status]` read from the object, because the platform refuses a match-all criteria (ADR-0049). - every set that edits an object another set narrows with a row policy carries its own permissive policy: RLS policies OR-merge across the sets a person holds, and a set that says nothing would inherit the narrower window. - clm_requester is not isDefault: a set carrying a system permission is high-privilege by the platform's rule and cannot bind to `everyone`, so the default is distributed through every position's second binding. - requires gains `sharing` and `hierarchy-security`; validate refuses the own_and_reports scope without the latter (measured), and the open edition fails it closed to owner-only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V --- objectstack.config.ts | 52 ++++++++++- src/profiles/_grants.ts | 101 ++++++++++++++++++++ src/profiles/admin.profile.ts | 55 +++++++++++ src/profiles/finance.profile.ts | 76 +++++++++++++++ src/profiles/index.ts | 9 ++ src/profiles/legal.profile.ts | 90 ++++++++++++++++++ src/profiles/records.profile.ts | 61 +++++++++++++ src/profiles/requester.profile.ts | 109 ++++++++++++++++++++++ src/security/bind-position-sets.ts | 142 +++++++++++++++++++++++++++++ src/security/index.ts | 2 + src/sharing/_lifecycle.ts | 64 +++++++++++++ src/sharing/contract.sharing.ts | 107 ++++++++++++++++++++++ src/sharing/index.ts | 13 +++ src/sharing/positions.ts | 66 ++++++++++++++ 14 files changed, 944 insertions(+), 3 deletions(-) create mode 100644 src/profiles/_grants.ts create mode 100644 src/profiles/admin.profile.ts create mode 100644 src/profiles/finance.profile.ts create mode 100644 src/profiles/index.ts create mode 100644 src/profiles/legal.profile.ts create mode 100644 src/profiles/records.profile.ts create mode 100644 src/profiles/requester.profile.ts create mode 100644 src/security/bind-position-sets.ts create mode 100644 src/security/index.ts create mode 100644 src/sharing/_lifecycle.ts create mode 100644 src/sharing/contract.sharing.ts create mode 100644 src/sharing/index.ts create mode 100644 src/sharing/positions.ts diff --git a/objectstack.config.ts b/objectstack.config.ts index e6993b7..05b7384 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -1,6 +1,9 @@ import { defineStack } from '@objectstack/spec'; import * as objects from './src/objects/index.js'; import { allHooks } from './src/objects/hooks.js'; +import * as profiles from './src/profiles/index.js'; +import { ClmPositions, ClmSharingRules } from './src/sharing/index.js'; +import { registerClmPositionBindings, type BindHostContext } from './src/security/index.js'; /** * HotCLM — contract lifecycle management on ObjectStack. @@ -32,15 +35,58 @@ export default defineStack({ // already prescribed `auth`; this is the implementation catching up, not a // capability expansion. // + // `sharing` seeds the §04 sharing rules into `sys_sharing_rule` and + // materialises their grants (card 04). + // + // `hierarchy-security` is the one enterprise-edition capability this app + // declares. `clm_requester` authors `readScope` / `writeScope: + // 'own_and_reports'` on `clm_contract` — the `contract_manager_reports` row + // of DESIGN.md §04 and §13 Q2 — which is an ADR-0057 HIERARCHY scope + // resolved by a service that ships only in `@objectstack/security-enterprise`. + // Measured on `@objectstack/spec` 17.3.0 (card 04): `defineStack` refuses the + // grant outright without the token ("uses readScope='own_and_reports', a + // HIERARCHY scope. Declare `requires: ['hierarchy-security']`"), so the pair + // is one declaration — move them together or not at all. + // + // Safe on an OPEN-edition boot: the token is in the platform's known + // vocabulary, so the capability resolver takes its deliberate "stay quiet" + // branch (no warning, no abort — HotCRM's config note measured the same + // against the CLI's serve command). What the open edition then delivers is + // the EDITION BOUNDARY: with no resolver present the scope fails CLOSED to + // owner-only, so a requester reads exactly their own contracts and a manager + // does not reach their reports' — the M1 acceptance measurement is taken on + // that edition. On the enterprise edition the same metadata widens to the + // manager chain. The app states what it MEANS; the edition supplies it. + // // The rest arrive with the cards that need them (DESIGN.md §06: // `automation` + `triggers` + `approvals` + `messaging` for F1–F15, - // `sharing` for §04, `analytics` for §09). Capability expansion stays tight - // — a card names the token it adds (AGENTS.md). - requires: ['ui', 'auth'], + // `analytics` for §09). Capability expansion stays tight — a card names the + // token it adds (AGENTS.md). + requires: ['ui', 'auth', 'sharing', 'hierarchy-security'], objects: Object.values(objects), // Lifecycle hooks (numbering, type-derived stamps, the state machines and // the display_name mirrors). A metadata `Hook` is only registered from // here — `hooks` is a top-level stack key, not an object key. hooks: allHooks, + + // Security (DESIGN.md §04, card 04): seven positions, five permission sets, + // the sharing rules. Positions and sets are containers — the join rows that + // make a position grant anything are written by `onEnable` below. + positions: ClmPositions, + permissions: Object.values(profiles), + sharingRules: ClmSharingRules, }); + +/** + * Declaring a position and a permission set grants nobody anything until a + * `sys_position_permission_set` row joins them, and that row cannot be a seed + * (the seed loader runs before the security bootstrap creates the rows it + * would reference). Bind them on `kernel:bootstrapped` instead — the shape + * ATS ships. A module-level named export, which the CLI preserves beside the + * default `defineStack()` export on the config-load path and grafts back onto + * the app bundle on the artifact-boot path. + */ +export const onEnable = async (ctx: unknown): Promise => { + registerClmPositionBindings(ctx as BindHostContext); +}; diff --git a/src/profiles/_grants.ts b/src/profiles/_grants.ts new file mode 100644 index 0000000..f9a93b0 --- /dev/null +++ b/src/profiles/_grants.ts @@ -0,0 +1,101 @@ +import type { FieldPermission } from '@objectstack/spec/security'; +import { Contract } from '../objects/contract.object.js'; + +/** + * Helpers the five permission sets share, so a field-level or row-level rule + * is derived from the object it governs instead of retyped beside it. + * + * Field-level security (FLS) on this platform is an allow-list only for the + * fields a set enumerates: an unlisted field is fully readable and editable + * whenever the object grant allows the verb. "Lock everything but X" is + * therefore spelled as one `editable: false` entry per other field + * ({@link editableOnly}), and every helper refuses a field name the object does + * not declare — an FLS key on a phantom field is silently inert + * (`security-fls-unqualified-key` catches the unqualified spelling, nothing + * catches a typo in the field half). + * + * Enforcement, measured on `@objectstack/plugin-security` 17.3.0: a write that + * touches a non-editable field is REFUSED with a 403 `PermissionDeniedError` + * ("Field write denied: not permitted to edit [...]"), never silently stripped; + * a non-readable field is removed from every read. Fields a hook stamps inside + * the write (`submitted_at`, `approved_at`, …) are not the caller's payload and + * pass — the check runs on what the caller sent, before the hooks run. + */ + +export type FieldSecurity = Record; + +export interface ObjectLike { + name: string; + fields: Record; +} + +function assertFields(object: ObjectLike, fields: readonly string[], helper: string): void { + const missing = fields.filter((field) => !(field in object.fields)); + if (missing.length > 0) { + throw new Error( + `${helper}: ${object.name} declares no field ${missing.join(', ')} — a field-level rule on a field that does not exist is silently inert.`, + ); + } +} + +/** Readable, not editable. */ +export function readOnly(object: ObjectLike, fields: readonly string[]): FieldSecurity { + assertFields(object, fields, 'readOnly'); + return Object.fromEntries(fields.map((field) => [`${object.name}.${field}`, { readable: true, editable: false }])); +} + +/** Neither readable nor editable — the value is absent from every read. */ +export function hidden(object: ObjectLike, fields: readonly string[]): FieldSecurity { + assertFields(object, fields, 'hidden'); + return Object.fromEntries(fields.map((field) => [`${object.name}.${field}`, { readable: false, editable: false }])); +} + +/** + * Every authored field of the object except `editable` becomes read-only. + * Spread a {@link hidden} entry AFTER this one when a locked field must also + * be withheld from reads. + */ +export function editableOnly(object: ObjectLike, editable: readonly string[]): FieldSecurity { + assertFields(object, editable, 'editableOnly'); + const locked = Object.keys(object.fields).filter((field) => !editable.includes(field)); + return readOnly(object, locked); +} + +/** The names of the object's fields whose `group` is one of `groups`. */ +export function fieldsInGroups(object: ObjectLike, groups: readonly string[]): string[] { + const names = Object.entries(object.fields) + .filter(([, def]) => groups.includes((def as { group?: string }).group ?? '')) + .map(([name]) => name); + if (names.length === 0) { + throw new Error(`fieldsInGroups: ${object.name} has no field in group(s) ${groups.join(', ')}.`); + } + return names; +} + +/** + * A row-level-security predicate `field in [...]` in the canonical CEL the RLS + * compiler lowers (`in` against a list literal). Values are JSON-quoted so a + * status value can never break out of the literal. + */ +export function inList(field: string, values: readonly string[]): string { + return `${field} in [${values.map((value) => JSON.stringify(value)).join(', ')}]`; +} + +/** + * DESIGN.md §04 field-level security, the two rows that apply to EVERY + * position: `route_*` and `approval_status` (written only by the routing hook + * and the approval flow), the stage timestamps (written only by the state + * machine), and the four `ai_*` fields (written only by the "adopt + * suggestion" action). Read from the object's field groups so a new stamp + * lands under the lock without a second edit here. + */ +export const CONTRACT_STAMPED_FIELDS = fieldsInGroups(Contract, ['routing', 'lifecycle', 'ai']); + +/** + * The legal fields of a contract — what DESIGN.md §04 locks for finance + * ("FLS 锁法律字段") and, together with `status`, what §13 Q1 rules finance may + * not touch on an approved contract: the `legal` field group plus the two + * commercial-looking fields §04 assigns to legal's assessment (`risk_level`, + * `liability_cap`). + */ +export const CONTRACT_LEGAL_FIELDS = [...fieldsInGroups(Contract, ['legal']), 'risk_level', 'liability_cap']; diff --git a/src/profiles/admin.profile.ts b/src/profiles/admin.profile.ts new file mode 100644 index 0000000..a1b4ab8 --- /dev/null +++ b/src/profiles/admin.profile.ts @@ -0,0 +1,55 @@ +import { definePermissionSet } from '@objectstack/spec/security'; +import { Contract } from '../objects/contract.object.js'; +import { CONTRACT_STAMPED_FIELDS, readOnly } from './_grants.js'; + +/** + * `clm_admin` — the CLM administrator (DESIGN.md §04): RCUD on every object, + * write access to the four configuration objects that every other set only + * reads, and all six action gates. + * + * `viewAllRecords` / `modifyAllRecords` on the private contract and its + * children: an administrator reaches every row without a sharing rule, and + * `modifyAllRecords` is what lets them reassign a contract's business owner + * (`allowTransfer` is authored beside it so the capability is readable here + * rather than implied). The super-user bits also bypass business row-level + * security, which is why this set carries none of the write-window policies + * the other four do — one here would be inert metadata. + * + * What even the administrator may not do by hand is write the fields §04 locks + * for every position: the routing flags, the stage timestamps and the AI + * fields have exactly one writer each (hooks, flows and the adopt action, + * under a system context), and a hand edit would forge an audit trail. + */ +const full = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true } as const; +const config = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true } as const; + +export const AdminSet = definePermissionSet({ + name: 'clm_admin', + label: 'CLM Administrator', + description: 'Full reach over every CLM object and the configuration objects: contract types, the clause playbook, the approval matrix, counterparties.', + systemPermissions: [ + 'clm_admin.access', + 'approve_contract', + 'execute_contract', + 'archive_contract', + 'terminate_contract', + 'manage_clauses', + 'manage_approval_rules', + ], + objects: { + clm_contract: { ...full, allowTransfer: true }, + clm_contract_version: full, + clm_review: full, + clm_deviation: full, + clm_signature: full, + clm_obligation: full, + clm_payment_plan: full, + clm_party: config, + clm_contract_type: config, + clm_clause: config, + clm_approval_rule: config, + }, + fields: { + ...readOnly(Contract, CONTRACT_STAMPED_FIELDS), + }, +}); diff --git a/src/profiles/finance.profile.ts b/src/profiles/finance.profile.ts new file mode 100644 index 0000000..1004b61 --- /dev/null +++ b/src/profiles/finance.profile.ts @@ -0,0 +1,76 @@ +import { definePermissionSet } from '@objectstack/spec/security'; +import { Contract } from '../objects/contract.object.js'; +import { Party } from '../objects/party.object.js'; +import { Review } from '../objects/review.object.js'; +import { POST_APPROVAL_STATUSES } from '../sharing/_lifecycle.js'; +import { CONTRACT_LEGAL_FIELDS, CONTRACT_STAMPED_FIELDS, editableOnly, hidden, inList, readOnly } from './_grants.js'; + +/** + * `clm_finance` — the finance controller (DESIGN.md §04). + * + * "RU(approved 及之后,FLS 锁法律字段)" on contracts, ruled in §13 Q1 as `edit` + * with field-level security locking the legal fields AND `status`. The rows + * arrive through `contract_finance_post_approval` (`edit`, approved onward); + * the row policy below repeats that window so it survives the OR-merge with + * `clm_requester` (see `legal.profile.ts`); and the FLS entries are the lock: + * a finance user's write that names `status` — or any legal field — is refused + * with 403 (`Field write denied`). Card 04 measured exactly that on a running + * app; the PR carries the transcript. + * + * On `clm_review`, "RCU(stage=finance)": finance reads every review of a + * contract it can see (legal's `internal_note` withheld) and creates or + * updates only finance-stage ones. On `clm_party`, "RU(银行信息)": every + * field readable, only the two banking fields editable. + */ +export const FinanceSet = definePermissionSet({ + name: 'clm_finance', + label: 'Finance', + description: 'The finance controller: approved contracts, the payment schedule, finance reviews and counterparty banking details.', + systemPermissions: ['clm_finance.access', 'approve_contract'], + objects: { + clm_contract: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false, readScope: 'own' }, + clm_contract_version: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_review: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + clm_deviation: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_signature: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_obligation: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_payment_plan: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + clm_party: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, + clm_contract_type: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_clause: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_approval_rule: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + }, + fields: { + ...readOnly(Contract, CONTRACT_STAMPED_FIELDS), + // §04 "FLS 锁法律字段" + §13 Q1 "与 status". + ...readOnly(Contract, [...CONTRACT_LEGAL_FIELDS, 'status']), + ...hidden(Review, ['internal_note']), + ...editableOnly(Party, ['bank_name', 'bank_account']), + }, + rowLevelSecurity: [ + { + name: 'contract_finance_post_approval_window', + label: 'Finance edits a contract from approved onward', + description: 'DESIGN.md §04 contract_finance_post_approval, restated as the row policy that survives the OR-merge with clm_requester.', + object: 'clm_contract', + operation: 'update', + using: inList('status', POST_APPROVAL_STATUSES), + }, + { + name: 'review_finance_stage_insert', + label: 'Finance records finance-stage reviews', + description: 'DESIGN.md §04: RCU(stage=finance).', + object: 'clm_review', + operation: 'insert', + check: 'stage == "finance"', + }, + { + name: 'review_finance_stage_update', + label: 'Finance updates finance-stage reviews', + description: 'DESIGN.md §04: RCU(stage=finance).', + object: 'clm_review', + operation: 'update', + using: 'stage == "finance"', + }, + ], +}); diff --git a/src/profiles/index.ts b/src/profiles/index.ts new file mode 100644 index 0000000..7bd94f1 --- /dev/null +++ b/src/profiles/index.ts @@ -0,0 +1,9 @@ +// The five permission sets of DESIGN.md §04, and nothing else: this barrel is +// spread into `defineStack({ permissions })` via `Object.values()`, so a helper +// exported here would be registered as a permission set. Helpers live in +// `_grants.ts` and are imported by the profiles directly. +export { RequesterSet } from './requester.profile.js'; +export { LegalSet } from './legal.profile.js'; +export { FinanceSet } from './finance.profile.js'; +export { RecordsSet } from './records.profile.js'; +export { AdminSet } from './admin.profile.js'; diff --git a/src/profiles/legal.profile.ts b/src/profiles/legal.profile.ts new file mode 100644 index 0000000..48a77cb --- /dev/null +++ b/src/profiles/legal.profile.ts @@ -0,0 +1,90 @@ +import { definePermissionSet } from '@objectstack/spec/security'; +import { Contract } from '../objects/contract.object.js'; +import { CONTRACT_STATUSES, OBLIGATION_STATUSES, REVIEW_STAGES } from '../sharing/_lifecycle.js'; +import { CONTRACT_STAMPED_FIELDS, inList, readOnly } from './_grants.js'; + +/** + * `clm_legal` — legal counsel and the head of legal (DESIGN.md §04). + * + * "RCU(全部)" on contracts: the object grant here is owner-scoped like every + * other set's (`readScope: 'own'`, authored so the intent is explicit — the + * `security-private-no-readscope` rule otherwise reads the omission as an + * unmade decision); the ROWS arrive through `contract_legal_all`, a sharing + * rule at `edit` level naming both legal positions + * (`src/sharing/contract.sharing.ts`). Deliberately not `viewAllRecords` / + * `modifyAllRecords`: those are super-user bypasses that skip row-level + * security and widen delete, and §04 grants legal no delete. + * + * ## Why this set carries "allow every status" row policies + * + * RLS policies are collected from EVERY set a person holds and OR-combined; + * a set that says nothing contributes nothing. `clm_requester` — which every + * legal user also holds (§04 "所有员工默认") — narrows contract updates to + * `draft`/`submitted` and obligation updates to the caller's own. Without a + * permissive policy of its own on the same object and operation, legal would + * inherit the requester's window and lose the ability to edit a contract in + * review — the OR-merge trap. Each policy below is therefore the whole + * vocabulary of the field it names, read from the object. + * + * `manage_clauses` (clause RCU is legal's alone), `terminate_contract` (legal + * and admin are the only sets that may write `status` on an active contract: + * finance's is locked by §13 Q1, records' edit is limited to execution and + * archive) and `approve_contract` (the head of legal is a ladder rung) are the + * action gates §04 assigns here. + */ +export const LegalSet = definePermissionSet({ + name: 'clm_legal', + label: 'Legal', + description: 'Legal counsel and the head of legal: every contract, every review, the clause playbook.', + systemPermissions: ['clm_legal.access', 'approve_contract', 'terminate_contract', 'manage_clauses'], + objects: { + clm_contract: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, readScope: 'own' }, + clm_contract_version: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + clm_review: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + clm_deviation: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + clm_signature: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + clm_obligation: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + clm_payment_plan: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, + clm_party: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + clm_contract_type: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_clause: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + clm_approval_rule: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + }, + fields: { + ...readOnly(Contract, CONTRACT_STAMPED_FIELDS), + }, + rowLevelSecurity: [ + { + name: 'contract_legal_edit_any_status', + label: 'Legal edits a contract in any status', + description: 'The permissive companion of contract_requester_edit_window (policies OR-merge across the sets a person holds).', + object: 'clm_contract', + operation: 'update', + using: inList('status', CONTRACT_STATUSES), + }, + { + name: 'review_legal_insert_any_stage', + label: 'Legal records a review at any stage', + description: 'The permissive companion of the finance stage policies on clm_review.', + object: 'clm_review', + operation: 'insert', + check: inList('stage', REVIEW_STAGES), + }, + { + name: 'review_legal_update_any_stage', + label: 'Legal updates a review at any stage', + description: 'The permissive companion of the finance stage policies on clm_review.', + object: 'clm_review', + operation: 'update', + using: inList('stage', REVIEW_STAGES), + }, + { + name: 'obligation_legal_update_any', + label: 'Legal updates any obligation of a contract it can edit', + description: 'The permissive companion of obligation_requester_own.', + object: 'clm_obligation', + operation: 'update', + using: inList('status', OBLIGATION_STATUSES), + }, + ], +}); diff --git a/src/profiles/records.profile.ts b/src/profiles/records.profile.ts new file mode 100644 index 0000000..f83678b --- /dev/null +++ b/src/profiles/records.profile.ts @@ -0,0 +1,61 @@ +import { definePermissionSet } from '@objectstack/spec/security'; +import { Contract } from '../objects/contract.object.js'; +import { Party } from '../objects/party.object.js'; +import { Signature } from '../objects/signature.object.js'; +import { EXECUTION_STATUSES } from '../sharing/_lifecycle.js'; +import { editableOnly, hidden, inList } from './_grants.js'; + +/** + * `clm_records` — the records manager (DESIGN.md §04, global-first revision: + * no seal keeper; execution registration and archive are one position). + * + * "RU(signing 及之后;执行与归档字段)" on contracts. The rows arrive through + * `contract_records_execution` (`edit`, signing onward); the row policy + * repeats the window for the OR-merge; and field-level security limits the + * edit to the two execution/archive fields a person actually writes: + * + * - `status` — execution IS the `signing → active` transition the + * `execute_contract` gate authorises (the state machine guards it); + * - `archive_no` — the archive reference F14 stamps `archived_at` from. + * + * Everything else on the contract, including the stamps §04 locks for every + * position, is readable and locked (`editableOnly`). On `clm_signature`, + * "RU(执行形式、执行副本)": the formalities, the executed copy, the completion + * date a wet-ink round records, and the round's status so the round can be + * completed at all (F7's wet-ink path names this position). On `clm_party` + * the banking and phone fields are withheld (§04 FLS table). + */ +export const RecordsSet = definePermissionSet({ + name: 'clm_records', + label: 'Records', + description: 'The records manager: execution registration, archive and the contract ledger.', + systemPermissions: ['clm_records.access', 'execute_contract', 'archive_contract'], + objects: { + clm_contract: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false, readScope: 'own' }, + clm_contract_version: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, + clm_review: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_deviation: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_signature: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, + clm_obligation: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_payment_plan: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_party: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_contract_type: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_clause: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_approval_rule: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + }, + fields: { + ...editableOnly(Contract, ['status', 'archive_no']), + ...editableOnly(Signature, ['status', 'formalities_done', 'executed_file', 'completed_at']), + ...hidden(Party, ['bank_account', 'contact_phone']), + }, + rowLevelSecurity: [ + { + name: 'contract_records_execution_window', + label: 'Records edits a contract from signing onward', + description: 'DESIGN.md §04 contract_records_execution, restated as the row policy that survives the OR-merge with clm_requester.', + object: 'clm_contract', + operation: 'update', + using: inList('status', EXECUTION_STATUSES), + }, + ], +}); diff --git a/src/profiles/requester.profile.ts b/src/profiles/requester.profile.ts new file mode 100644 index 0000000..2d9924f --- /dev/null +++ b/src/profiles/requester.profile.ts @@ -0,0 +1,109 @@ +import { definePermissionSet } from '@objectstack/spec/security'; +import { Contract } from '../objects/contract.object.js'; +import { Party } from '../objects/party.object.js'; +import { Review } from '../objects/review.object.js'; +import { REQUESTER_EDITABLE_STATUSES } from '../sharing/_lifecycle.js'; +import { CONTRACT_STAMPED_FIELDS, hidden, inList, readOnly } from './_grants.js'; + +/** + * `clm_requester` — every employee (DESIGN.md §04 "所有员工默认"). + * + * ## How the default is distributed + * + * The platform's mechanism for "the set every member holds" is `isDefault: + * true`, which auto-binds the set to the built-in `everyone` anchor. It is NOT + * used here, and cannot be: §04 grants `clm_requester.access` through + * `systemPermissions`, and a set carrying ANY system permission is + * high-privilege by the platform's definition (ADR-0090 D5/D9, + * `describeHighPrivilegeBits`) — the runtime refuses the binding at boot with + * a warning, and `os lint` refuses the declaration outright + * (`security-anchor-high-privilege`, an error). So under this platform version + * the default is DISTRIBUTED, not implicit: + * + * - every one of the seven positions is bound to this set as well as to its + * own (`src/security/bind-position-sets.ts`), so legal, finance, records, + * admin and the two leadership rungs are all requesters too — which is also + * the only way an executive holds an object-level read on `clm_contract` + * for the rows `contract_executive_routed` shares with them; + * - an employee who holds no position receives the set in Setup (a + * `sys_user_permission_set` grant), which is the platform's supported path. + * + * Whether §04's wording should change, or the platform's high-privilege rule + * should learn to tell an app capability token from `manage_users`, is a + * maintainer decision; the card-04 PR files it. Nothing here pre-empts it. + * + * ## Row scope + * + * `own_and_reports` on both axes is the `contract_manager_reports` row of the + * §04 sharing table and §13 Q2 ("部门内可见性 … 靠 own_and_reports"): a manager + * reaches the contracts of their reports through the manager chain. It is a + * HIERARCHY depth (ADR-0057) resolved by `@objectstack/security-enterprise`; + * the open edition has no resolver and fails CLOSED to owner-only, so on an + * open-edition boot a requester reads exactly their own contracts — the M1 + * acceptance measurement. `requires: ['hierarchy-security']` in + * `objectstack.config.ts` is the other half of this declaration. + * + * The write window ("`draft`/`submitted` 可改") is a row-level policy: once the + * contract leaves the requester's hands it is legal's to edit. RLS policies + * from every set a person holds are OR-combined, so every other set that edits + * contracts carries its own window (see the note in `legal.profile.ts`). + * + * ## `approve_contract` + * + * The first rung of the approval ladder is the requester's direct manager + * (§06 F5, `type: 'manager'`), who holds no position and therefore only this + * set. The gate is held here so that rung can act; WHICH approval is theirs is + * the flow's assignment, not the gate's. The executive and general-manager + * rungs hold it the same way. + */ +export const RequesterSet = definePermissionSet({ + name: 'clm_requester', + label: 'Requester', + description: 'Every employee: launches contracts, follows their own through review and approval, performs the obligations assigned to them.', + systemPermissions: ['clm_requester.access', 'approve_contract'], + objects: { + clm_contract: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, readScope: 'own_and_reports', writeScope: 'own_and_reports' }, + // Children follow the contract (controlled_by_parent, ADR-0055): a + // readScope here would be inert. Creating a version or a deviation needs + // the contract to be editable, so in practice both happen while it is + // still draft or submitted — legal uploads the negotiation rounds (§03). + clm_contract_version: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, + clm_review: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_deviation: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, + clm_signature: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_obligation: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, + clm_payment_plan: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + // Configuration objects are public_read: everyone reads every row. + clm_party: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_contract_type: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_clause: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + clm_approval_rule: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, + }, + fields: { + ...readOnly(Contract, CONTRACT_STAMPED_FIELDS), + // Assessed by legal (§04 FLS table). + ...readOnly(Contract, ['risk_level', 'liability_cap']), + // Legal's working note; the conclusion for the requester goes in `comments`. + ...hidden(Review, ['internal_note']), + // Payment and contact details are the easiest to leak (§04 FLS table). + ...hidden(Party, ['bank_account', 'contact_phone']), + }, + rowLevelSecurity: [ + { + name: 'contract_requester_edit_window', + label: 'Requesters edit a contract only while it is draft or submitted', + description: 'DESIGN.md §04: RCU(本人发起,draft/submitted 可改). From review onward the contract is edited by legal, finance and records through their own grants.', + object: 'clm_contract', + operation: 'update', + using: inList('status', REQUESTER_EDITABLE_STATUSES), + }, + { + name: 'obligation_requester_own', + label: 'Requesters update the obligations they own', + description: 'DESIGN.md §04: RU(本人负责). Reading follows the contract; updating needs the obligation to be assigned to the caller.', + object: 'clm_obligation', + operation: 'update', + using: 'owner == current_user.id', + }, + ], +}); diff --git a/src/security/bind-position-sets.ts b/src/security/bind-position-sets.ts new file mode 100644 index 0000000..8685842 --- /dev/null +++ b/src/security/bind-position-sets.ts @@ -0,0 +1,142 @@ +import { AdminSet, FinanceSet, LegalSet, RecordsSet, RequesterSet } from '../profiles/index.js'; +import { CLM_POSITION } from '../sharing/positions.js'; + +/** + * Position ↔ permission-set bindings (DESIGN.md §04). + * + * The permission model is record-authoritative (ADR-0090/0094): declaring a + * position and declaring a set grants nobody anything until a + * `sys_position_permission_set` row joins them. Without this file every + * persona silently degrades to the platform baseline — which reads as + * "permissions are broken" rather than "a binding is missing". + * + * This cannot be a declarative seed: the seed loader runs before the security + * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the + * name references would not resolve. We play the admin's part imperatively on + * `kernel:bootstrapped` — the anchor that fires only after every + * `kernel:ready` handler, the security bootstrap included, has settled (the + * shape ATS ships in its `bind-position-sets.ts`). + * + * Two rows per position. Each position binds its own set, and every position + * also binds `clm_requester`: §04 makes that set every employee's default, the + * platform refuses to bind a set carrying a system permission to the + * `everyone` anchor (see `requester.profile.ts`), and the two leadership + * rungs hold no set of their own — the object-level read on `clm_contract` + * that lets `contract_executive_routed` / `contract_gm_routed` deliver rows + * comes from this binding. + * + * Catalog rows are per organization ("unique per organization"), so a + * position is looked up by name WITHOUT a limit of one and paired with the + * set row of the same organization; a single-tenant boot has one of each. + * Idempotent: a binding that already exists is left alone, so a restart never + * duplicates a row and an administrator's removal is not re-created behind + * their back within a boot. The log line at the end is the boot-time + * evidence that the grants landed. + */ + +const BINDINGS: ReadonlyArray = [ + [CLM_POSITION.legalCounsel, LegalSet.name], + [CLM_POSITION.legalHead, LegalSet.name], + [CLM_POSITION.financeController, FinanceSet.name], + [CLM_POSITION.recordsManager, RecordsSet.name], + [CLM_POSITION.admin, AdminSet.name], + ...Object.values(CLM_POSITION).map((position) => [position, RequesterSet.name] as const), +]; + +const SYS = { isSystem: true } as const; + +interface CatalogRow { + id?: string; + organization_id?: string | null; +} + +export interface BindHostContext { + ql: { + find: (object: string, query: unknown, options?: unknown) => Promise; + insert: (object: string, data: Record, options?: unknown) => Promise; + }; + logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; + hook?: (event: string, handler: () => Promise | void) => void; +} + +function rowsOf(result: unknown): CatalogRow[] { + if (Array.isArray(result)) return result as CatalogRow[]; + const records = (result as { records?: unknown })?.records; + return Array.isArray(records) ? (records as CatalogRow[]) : []; +} + +function organizationOf(row: CatalogRow): string | null { + return typeof row.organization_id === 'string' && row.organization_id !== '' ? row.organization_id : null; +} + +/** Every catalog row of that name, across organizations, under the system context the engine's read path expects. */ +async function findAllByName(ctx: BindHostContext, object: string, name: string): Promise { + try { + return rowsOf(await ctx.ql.find(object, { where: { name }, limit: 50, context: SYS })); + } catch (err) { + ctx.logger?.warn?.('[clm] position binding lookup failed', { + object, name, error: err instanceof Error ? err.message : String(err), + }); + return []; + } +} + +export function registerClmPositionBindings(ctx: BindHostContext): void { + const run = async (): Promise => { + let created = 0; + let existing = 0; + let skipped = 0; + for (const [positionName, setName] of BINDINGS) { + const positions = await findAllByName(ctx, 'sys_position', positionName); + const sets = await findAllByName(ctx, 'sys_permission_set', setName); + if (positions.length === 0 || sets.length === 0) { + ctx.logger?.warn?.('[clm] position binding skipped (row missing)', { position: positionName, set: setName }); + skipped += 1; + continue; + } + for (const position of positions) { + const organization = organizationOf(position); + const set = sets.find((candidate) => organizationOf(candidate) === organization); + if (!position.id || !set?.id) { + ctx.logger?.warn?.('[clm] position binding skipped (no set in the same organization)', { + position: positionName, set: setName, organization, + }); + skipped += 1; + continue; + } + const hit = rowsOf(await ctx.ql.find( + 'sys_position_permission_set', + { where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS }, + ))[0]; + if (hit) { + existing += 1; + continue; + } + try { + await ctx.ql.insert( + 'sys_position_permission_set', + { + id: `ppsb_clm_${positionName}_${setName}${organization ? `_${organization.slice(0, 8)}` : ''}`, + position_id: position.id, + permission_set_id: set.id, + }, + { context: SYS }, + ); + created += 1; + } catch (err) { + ctx.logger?.warn?.('[clm] position binding insert failed', { + position: positionName, set: setName, error: err instanceof Error ? err.message : String(err), + }); + skipped += 1; + } + } + } + ctx.logger?.info?.('[clm] position bindings ensured', { created, existing, skipped, declared: BINDINGS.length }); + }; + + if (typeof ctx.hook === 'function') { + ctx.hook('kernel:bootstrapped', run); + } else { + void Promise.resolve().then(run); + } +} diff --git a/src/security/index.ts b/src/security/index.ts new file mode 100644 index 0000000..499aba9 --- /dev/null +++ b/src/security/index.ts @@ -0,0 +1,2 @@ +export { registerClmPositionBindings } from './bind-position-sets.js'; +export type { BindHostContext } from './bind-position-sets.js'; diff --git a/src/sharing/_lifecycle.ts b/src/sharing/_lifecycle.ts new file mode 100644 index 0000000..1f08287 --- /dev/null +++ b/src/sharing/_lifecycle.ts @@ -0,0 +1,64 @@ +import { Contract } from '../objects/contract.object.js'; +import { Obligation } from '../objects/obligation.object.js'; +import { Review } from '../objects/review.object.js'; + +/** + * The status vocabularies the security surface reasons about, READ FROM THE + * OBJECTS rather than retyped. Every sharing-rule condition and every + * row-level write window in `src/profiles/` is a subset of one of these lists; + * a subset that names a value the object no longer declares throws at load, + * which `pnpm validate` reports — a rule that quietly matched nothing would + * otherwise read as "the grant holds". + */ + +interface SelectLike { + options?: ReadonlyArray<{ value: string }>; +} + +interface ObjectLike { + name: string; + fields: Record; +} + +function selectValues(object: ObjectLike, field: string): string[] { + const def = object.fields[field] as SelectLike | undefined; + const values = def?.options?.map((option) => option.value) ?? []; + if (values.length === 0) { + throw new Error(`${object.name}.${field} is not a select field with options; the lifecycle constants derive from it.`); + } + return values; +} + +function subset(all: readonly string[], wanted: readonly string[], what: string): string[] { + const unknown = wanted.filter((value) => !all.includes(value)); + if (unknown.length > 0) { + throw new Error(`${what} names value(s) the object does not declare: ${unknown.join(', ')}.`); + } + return [...wanted]; +} + +/** Every `clm_contract.status` value — what "all contracts" spells as a criteria (see contract.sharing.ts). */ +export const CONTRACT_STATUSES = selectValues(Contract, 'status'); + +/** DESIGN.md §04: a requester edits their own contract only while it is `draft` or `submitted`. */ +export const REQUESTER_EDITABLE_STATUSES = subset(CONTRACT_STATUSES, ['draft', 'submitted'], 'REQUESTER_EDITABLE_STATUSES'); + +/** DESIGN.md §04 `contract_finance_post_approval`: `approved` and everything after it. */ +export const POST_APPROVAL_STATUSES = subset( + CONTRACT_STATUSES, + ['approved', 'signing', 'active', 'expired', 'terminated'], + 'POST_APPROVAL_STATUSES', +); + +/** DESIGN.md §04 `contract_records_execution`: `signing` and everything after it. */ +export const EXECUTION_STATUSES = subset( + CONTRACT_STATUSES, + ['signing', 'active', 'expired', 'terminated'], + 'EXECUTION_STATUSES', +); + +/** Every `clm_review.stage` value. */ +export const REVIEW_STAGES = selectValues(Review, 'stage'); + +/** Every `clm_obligation.status` value. */ +export const OBLIGATION_STATUSES = selectValues(Obligation, 'status'); diff --git a/src/sharing/contract.sharing.ts b/src/sharing/contract.sharing.ts new file mode 100644 index 0000000..5935faf --- /dev/null +++ b/src/sharing/contract.sharing.ts @@ -0,0 +1,107 @@ +import { P } from '@objectstack/spec'; +import { defineSharingRule } from '@objectstack/spec/security'; +import { CONTRACT_STATUSES, EXECUTION_STATUSES, POST_APPROVAL_STATUSES } from './_lifecycle.js'; +import { CLM_POSITION } from './positions.js'; + +/** + * The sharing rules of DESIGN.md §04 — how legal, finance, records and the two + * leadership rungs reach contracts they do not own (`clm_contract` is + * `private`; the five children are `controlled_by_parent` and follow it). + * + * §04 lists six rows. Five are criteria rules and live here. The sixth, + * `contract_manager_reports`, is not a rule at all: it is the + * `own_and_reports` DEPTH scope on the `clm_requester` grant + * (`src/profiles/requester.profile.ts`), exactly as the §04 table spells it. + * + * Two shapes the platform imposes on the table, neither a change of meaning: + * + * - A rule has ONE recipient. `contract_legal_all` names two positions, so it + * is two rows here, one per position, sharing the same criteria and level. + * - "全部" cannot be authored as an empty criteria: a match-all rule is refused + * at seed and by `defineRule`, and one that slipped through would match + * NOTHING (ADR-0049, `plugin-sharing`'s `isMatchAllCriteria`). "Every + * contract" is therefore spelled as `status in [every status]`, with the + * list read from the object so a new status cannot fall out of legal's + * reach silently. + * + * A `position` recipient expands once per rule at materialisation; a person + * given the position after the contracts exist receives the grants on the + * next re-materialisation (a write to the record, or + * `POST /api/v1/sharing/rules/:id/evaluate`). The PR for card 04 records what + * was measured. + */ + +export const ContractLegalAllSharingRules = [ + defineSharingRule({ + name: 'contract_legal_all_counsel', + label: 'Contracts — Legal Counsel (all)', + description: 'DESIGN.md §04 contract_legal_all: legal counsel reads and edits every contract.', + object: 'clm_contract', + type: 'criteria', + condition: P`record.status in ${CONTRACT_STATUSES}`, + accessLevel: 'edit', + sharedWith: { type: 'position', value: CLM_POSITION.legalCounsel }, + }), + defineSharingRule({ + name: 'contract_legal_all_head', + label: 'Contracts — Head of Legal (all)', + description: 'DESIGN.md §04 contract_legal_all: the head of legal reads and edits every contract.', + object: 'clm_contract', + type: 'criteria', + condition: P`record.status in ${CONTRACT_STATUSES}`, + accessLevel: 'edit', + sharedWith: { type: 'position', value: CLM_POSITION.legalHead }, + }), +]; + +export const ContractFinancePostApprovalSharingRule = defineSharingRule({ + name: 'contract_finance_post_approval', + label: 'Contracts — Finance (approved onward)', + description: 'DESIGN.md §04: the finance controller edits contracts from approved onward; field-level security locks the legal fields and the status (§13 Q1).', + object: 'clm_contract', + type: 'criteria', + condition: P`record.status in ${POST_APPROVAL_STATUSES}`, + accessLevel: 'edit', + sharedWith: { type: 'position', value: CLM_POSITION.financeController }, +}); + +export const ContractRecordsExecutionSharingRule = defineSharingRule({ + name: 'contract_records_execution', + label: 'Contracts — Records (signing onward)', + description: 'DESIGN.md §04: the records manager edits contracts from signing onward; field-level security limits the edit to the execution and archive fields.', + object: 'clm_contract', + type: 'criteria', + condition: P`record.status in ${EXECUTION_STATUSES}`, + accessLevel: 'edit', + sharedWith: { type: 'position', value: CLM_POSITION.recordsManager }, +}); + +export const ContractExecutiveRoutedSharingRule = defineSharingRule({ + name: 'contract_executive_routed', + label: 'Contracts — Executive (routed)', + description: 'DESIGN.md §04: an executive reads the contracts the approval matrix routed to the executive rung.', + object: 'clm_contract', + type: 'criteria', + condition: P`record.route_executive == true`, + accessLevel: 'read', + sharedWith: { type: 'position', value: CLM_POSITION.executive }, +}); + +export const ContractGmRoutedSharingRule = defineSharingRule({ + name: 'contract_gm_routed', + label: 'Contracts — General Manager (routed)', + description: 'DESIGN.md §04: the general manager reads the contracts the approval matrix routed to the general-manager rung.', + object: 'clm_contract', + type: 'criteria', + condition: P`record.route_gm == true`, + accessLevel: 'read', + sharedWith: { type: 'position', value: CLM_POSITION.generalManager }, +}); + +export const ClmSharingRules = [ + ...ContractLegalAllSharingRules, + ContractFinancePostApprovalSharingRule, + ContractRecordsExecutionSharingRule, + ContractExecutiveRoutedSharingRule, + ContractGmRoutedSharingRule, +]; diff --git a/src/sharing/index.ts b/src/sharing/index.ts new file mode 100644 index 0000000..02ab0e4 --- /dev/null +++ b/src/sharing/index.ts @@ -0,0 +1,13 @@ +// Positions and record-sharing rules (DESIGN.md §04). The lifecycle constants +// in `_lifecycle.ts` are shared with `src/profiles/` and imported from there +// directly; they are not stack metadata. +export { CLM_POSITION, ClmPositions } from './positions.js'; +export type { ClmPositionName } from './positions.js'; +export { + ClmSharingRules, + ContractExecutiveRoutedSharingRule, + ContractFinancePostApprovalSharingRule, + ContractGmRoutedSharingRule, + ContractLegalAllSharingRules, + ContractRecordsExecutionSharingRule, +} from './contract.sharing.js'; diff --git a/src/sharing/positions.ts b/src/sharing/positions.ts new file mode 100644 index 0000000..c3567b9 --- /dev/null +++ b/src/sharing/positions.ts @@ -0,0 +1,66 @@ +import { definePosition } from '@objectstack/spec/identity'; + +/** + * The seven positions of DESIGN.md §04 — flat capability-distribution groups + * (ADR-0090 D3): no parent, no hierarchy, no visibility roll-up. A position + * grants nothing by itself. Capability reaches it through the + * `sys_position_permission_set` bindings `src/security/bind-position-sets.ts` + * writes on `kernel:bootstrapped`, and rows reach it through the sharing rules + * in `contract.sharing.ts` that name the position as their recipient. + * + * There is deliberately no "employee" position. `clm_requester` is the set + * every employee holds by default (§04), and a default is not a position — + * see the distribution note at the top of `src/profiles/requester.profile.ts`. + * + * The names are exported as constants so the sharing rules, the bindings and + * a future approval ladder (card 06) spell them from one place. + */ +export const CLM_POSITION = { + legalCounsel: 'clm_legal_counsel', + legalHead: 'clm_legal_head', + financeController: 'clm_finance_controller', + executive: 'clm_executive', + generalManager: 'clm_general_manager', + recordsManager: 'clm_records_manager', + admin: 'clm_admin', +} as const; + +export type ClmPositionName = (typeof CLM_POSITION)[keyof typeof CLM_POSITION]; + +export const ClmPositions = [ + definePosition({ + name: CLM_POSITION.legalCounsel, + label: 'Legal Counsel', + description: 'Reviews and negotiates contracts. Reads and edits every contract through the legal sharing rule; accepts submissions and runs the review.', + }), + definePosition({ + name: CLM_POSITION.legalHead, + label: 'Head of Legal', + description: 'The legal rung of the approval ladder. Same contract reach as legal counsel; receives the review-overdue escalations.', + }), + definePosition({ + name: CLM_POSITION.financeController, + label: 'Finance Controller', + description: 'The finance rung of the approval ladder. Edits contracts from approved onward with the legal fields and the status locked; owns the payment schedule.', + }), + definePosition({ + name: CLM_POSITION.executive, + label: 'Executive', + description: 'Reads the contracts routed to the executive rung of the approval ladder.', + }), + definePosition({ + name: CLM_POSITION.generalManager, + label: 'General Manager', + description: 'Reads the contracts routed to the general-manager rung of the approval ladder.', + }), + definePosition({ + name: CLM_POSITION.recordsManager, + label: 'Records Manager', + description: 'Execution registration, archive and the contract ledger: edits contracts from signing onward on the execution and archive fields only.', + }), + definePosition({ + name: CLM_POSITION.admin, + label: 'CLM Administrator', + description: 'Maintains the configuration objects and holds full reach over every CLM object.', + }), +]; From 8440537ac35b37c82d62e6b12b3e7bc54a5d3824 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:57:44 +0000 Subject: [PATCH 2/2] fix(security): open the fields clm_requester masks for legal and admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field permissions merge most permissively, but only among the sets that DECLARE a field: a set that says nothing about clm_party.bank_account does not out-vote a co-held set that hides it. Measured on the first verification run: a legal user, who also holds clm_requester, lost the bank account on a party read and was refused internal_note on a review insert (403 Field write denied). Legal and admin now open every contract, signature, review and party field they edit explicitly (openAllExcept / open in _grants.ts), with the §04 stamps kept read-only on top. Second run: legal reads the bank account, records the review with its internal note, and the contract reaches approved — where the finance status lock (§13 Q1) was then measured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V --- src/profiles/_grants.ts | 23 +++++++++++++++++++++++ src/profiles/admin.profile.ts | 15 ++++++++++++++- src/profiles/legal.profile.ts | 20 +++++++++++++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/profiles/_grants.ts b/src/profiles/_grants.ts index f9a93b0..fe25c9e 100644 --- a/src/profiles/_grants.ts +++ b/src/profiles/_grants.ts @@ -61,6 +61,29 @@ export function editableOnly(object: ObjectLike, editable: readonly string[]): F return readOnly(object, locked); } +/** + * Readable AND editable, declared explicitly. + * + * Field permissions merge MOST PERMISSIVELY across the sets a person holds, + * but only the sets that DECLARE a field vote: a set that says nothing about + * `clm_party.bank_account` does not out-vote a co-held set that hides it + * (`getFieldPermissions`, plugin-security 17.3.0 — measured on card 04: a + * legal user who also held `clm_requester` lost the bank account and was + * refused `internal_note`). So a set that must see or edit what another + * co-holdable set locks says so here, in so many words — the same discipline + * HotCRM adopted for its manager sets (#488). + */ +export function open(object: ObjectLike, fields: readonly string[]): FieldSecurity { + assertFields(object, fields, 'open'); + return Object.fromEntries(fields.map((field) => [`${object.name}.${field}`, { readable: true, editable: true }])); +} + +/** Every authored field of the object except `except` is {@link open}. */ +export function openAllExcept(object: ObjectLike, except: readonly string[]): FieldSecurity { + assertFields(object, except, 'openAllExcept'); + return open(object, Object.keys(object.fields).filter((field) => !except.includes(field))); +} + /** The names of the object's fields whose `group` is one of `groups`. */ export function fieldsInGroups(object: ObjectLike, groups: readonly string[]): string[] { const names = Object.entries(object.fields) diff --git a/src/profiles/admin.profile.ts b/src/profiles/admin.profile.ts index a1b4ab8..85b9aa6 100644 --- a/src/profiles/admin.profile.ts +++ b/src/profiles/admin.profile.ts @@ -1,6 +1,9 @@ import { definePermissionSet } from '@objectstack/spec/security'; import { Contract } from '../objects/contract.object.js'; -import { CONTRACT_STAMPED_FIELDS, readOnly } from './_grants.js'; +import { Party } from '../objects/party.object.js'; +import { Review } from '../objects/review.object.js'; +import { Signature } from '../objects/signature.object.js'; +import { CONTRACT_STAMPED_FIELDS, openAllExcept, open, readOnly } from './_grants.js'; /** * `clm_admin` — the CLM administrator (DESIGN.md §04): RCUD on every object, @@ -15,6 +18,12 @@ import { CONTRACT_STAMPED_FIELDS, readOnly } from './_grants.js'; * security, which is why this set carries none of the write-window policies * the other four do — one here would be inert metadata. * + * The super-user bits do NOT bypass field-level security, and field masks + * merge most permissively only among the sets that declare a field + * (`_grants.ts`, `open`): an administrator also holds `clm_requester`, whose + * masks would otherwise apply to them. Every field another set locks or hides + * is opened here explicitly. + * * What even the administrator may not do by hand is write the fields §04 locks * for every position: the routing flags, the stage timestamps and the AI * fields have exactly one writer each (hooks, flows and the adopt action, @@ -50,6 +59,10 @@ export const AdminSet = definePermissionSet({ clm_approval_rule: config, }, fields: { + ...openAllExcept(Contract, CONTRACT_STAMPED_FIELDS), ...readOnly(Contract, CONTRACT_STAMPED_FIELDS), + ...openAllExcept(Signature, []), + ...open(Review, ['internal_note']), + ...open(Party, ['bank_account', 'contact_phone']), }, }); diff --git a/src/profiles/legal.profile.ts b/src/profiles/legal.profile.ts index 48a77cb..3be0f92 100644 --- a/src/profiles/legal.profile.ts +++ b/src/profiles/legal.profile.ts @@ -1,7 +1,10 @@ import { definePermissionSet } from '@objectstack/spec/security'; import { Contract } from '../objects/contract.object.js'; +import { Party } from '../objects/party.object.js'; +import { Review } from '../objects/review.object.js'; +import { Signature } from '../objects/signature.object.js'; import { CONTRACT_STATUSES, OBLIGATION_STATUSES, REVIEW_STAGES } from '../sharing/_lifecycle.js'; -import { CONTRACT_STAMPED_FIELDS, inList, readOnly } from './_grants.js'; +import { CONTRACT_STAMPED_FIELDS, inList, openAllExcept, open, readOnly } from './_grants.js'; /** * `clm_legal` — legal counsel and the head of legal (DESIGN.md §04). @@ -26,6 +29,17 @@ import { CONTRACT_STAMPED_FIELDS, inList, readOnly } from './_grants.js'; * review — the OR-merge trap. Each policy below is therefore the whole * vocabulary of the field it names, read from the object. * + * ## Why this set spells out fields it could have left alone + * + * Field permissions merge the same way, most permissively — but only among + * the sets that DECLARE the field (`_grants.ts`, `open`). `clm_requester` + * hides `clm_party.bank_account`, `contact_phone` and `clm_review.internal_note` + * and locks `risk_level` / `liability_cap`; `clm_records` locks every contract + * and signature field but its own two. A legal user holds the first always + * and may hold the second, and would inherit those locks by silence. Every + * contract, signature, review and party field legal edits is therefore opened + * here explicitly, with the §04 stamps kept read-only on top. + * * `manage_clauses` (clause RCU is legal's alone), `terminate_contract` (legal * and admin are the only sets that may write `status` on an active contract: * finance's is locked by §13 Q1, records' edit is limited to execution and @@ -51,7 +65,11 @@ export const LegalSet = definePermissionSet({ clm_approval_rule: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, }, fields: { + ...openAllExcept(Contract, CONTRACT_STAMPED_FIELDS), ...readOnly(Contract, CONTRACT_STAMPED_FIELDS), + ...openAllExcept(Signature, []), + ...open(Review, ['internal_note']), + ...open(Party, ['bank_account', 'contact_phone']), }, rowLevelSecurity: [ {