From 029d8c6a34d78c835e3caca3d65200b6394ae17d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:37:53 +0000 Subject: [PATCH 1/2] fix(security): let a requester create a counterparty, risk fields read-only The intake flow offers every launcher the inline "new counterparty" step and runs its writes as the launching user, but clm_requester had no create on clm_party, so the step always failed for a plain member. Ruling A on #133: clm_requester gets allowCreate on clm_party (allowEdit stays false), and the risk, screening and bank-name fields are read-only for it. The ruling's verified_at is screening_status since the global-first revision. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn --- src/profiles/requester.profile.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/profiles/requester.profile.ts b/src/profiles/requester.profile.ts index b669533..dcaa3d8 100644 --- a/src/profiles/requester.profile.ts +++ b/src/profiles/requester.profile.ts @@ -74,7 +74,16 @@ export const RequesterSet = definePermissionSet({ 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 }, + // + // A requester may CREATE a counterparty, because the intake flow (§06 F1 + // "相对方查找或新建") offers every launcher the inline branch and runs its + // writes as the launching user (#133, ruling A). Editing stays legal's + // (§04 RCU), so a requester cannot change a party once it exists, theirs + // included. The risk and screening fields and the bank are read-only + // below, so a new party is born unflagged and unscreened whatever the + // caller sends. Create is not high-privilege (ADR-0090 D5), so the + // `everyone` anchor binding stands. + clm_party: { allowCreate: true, 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 }, @@ -146,6 +155,10 @@ export const RequesterSet = definePermissionSet({ ...hidden(Review, ['internal_note']), // Payment and contact details are the easiest to leak (§04 FLS table). ...hidden(Party, ['bank_account', 'contact_phone']), + // Assessed by legal, or stamped by the screening connector (§08), never + // by the person who adds the party (#133, ruling A). The ruling's + // `verified_at` is `screening_status` since the global-first revision. + ...readOnly(Party, ['risk_flag', 'risk_note', 'screening_status', 'is_active', 'bank_name']), }, rowLevelSecurity: [ { From d21ffc958e92c4555a409d9bdcf6f17d3a6f7c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:50:16 +0000 Subject: [PATCH 2/2] fix(security): legal, admin and finance open the party fields the requester holds read-only Only the permission sets that declare a field vote on it, and every member holds clm_requester, so its read-only lock on the assessed party fields bound legal and admin too (measured: Legal Counsel 1 refused risk_flag on her own party). One exported list, PARTY_ASSESSED_FIELDS, is read-only for clm_requester and opened by clm_legal and clm_admin; clm_finance opens the banking pair, which also ends the bank_account mask finance carried by the same mechanism. The intake flow's header no longer says a requester is refused on clm_party. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn --- src/flows/contract-intake.flow.ts | 15 ++++++++++----- src/profiles/_grants.ts | 16 ++++++++++++++++ src/profiles/admin.profile.ts | 4 +++- src/profiles/finance.profile.ts | 8 +++++++- src/profiles/legal.profile.ts | 4 +++- src/profiles/requester.profile.ts | 8 ++++---- 6 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/flows/contract-intake.flow.ts b/src/flows/contract-intake.flow.ts index 2fa95b1..b76bfde 100644 --- a/src/flows/contract-intake.flow.ts +++ b/src/flows/contract-intake.flow.ts @@ -88,11 +88,16 @@ import { must, ref } from './_values.js'; * version were already committed, orphaning both. WHERE the intake-captured * arrangement F9 reads should be stored is a design gap, raised as its own * decision card rather than guessed at here. - * - `clm_party`: refused for a requester the same way, granted to legal - * (§04 gives the requester R, legal RCU). The inline-create branch stays - * because DESIGN.md §06 F1 names it ("相对方查找或新建") and legal and - * admin do launch contracts; it fails ATOMICALLY — `create_party` runs - * before any other write, so a refused requester leaves nothing behind. + * - `clm_party`: created (201) by a requester as well as by legal (#133, + * ruling A: the requester may create a counterparty but not edit one, and + * holds the risk, screening and bank-name fields read-only). That is why + * `create_party` writes only the identity and contact fields the screen + * asks for: naming a locked field would refuse the whole write with 403 + * "Field write denied". A new party is therefore born unflagged and + * unscreened, and legal maintains those fields afterwards (on a party + * legal does not own, writes are owner-scoped today, see #152). Should the + * create still be refused, it fails ATOMICALLY: `create_party` runs before + * any other write, so the run leaves nothing behind. * * Flow copy is English-only (the source language, DESIGN.md §01); a flow has * no entry in the translation bundles. diff --git a/src/profiles/_grants.ts b/src/profiles/_grants.ts index b4d66a6..add44ee 100644 --- a/src/profiles/_grants.ts +++ b/src/profiles/_grants.ts @@ -151,3 +151,19 @@ export const CONTRACT_STAMPED_FIELDS = fieldsInGroups(Contract, ['routing', 'lif * `liability_cap`). */ export const CONTRACT_LEGAL_FIELDS = [...fieldsInGroups(Contract, ['legal']), 'risk_level', 'liability_cap']; + +/** + * The counterparty fields legal assesses, or the screening connector (§08) + * stamps: never the person who adds the party (#133, ruling A). `clm_requester` + * may create a counterparty, and it holds these read-only, so a new party is + * born unflagged and unscreened whatever the caller sends. The ruling's + * `verified_at` has been `screening_status` since the global-first revision. + * + * Every member holds `clm_requester`, and only the sets that DECLARE a field + * vote on it ({@link open}). So the sets that maintain these fields, `clm_legal` + * and `clm_admin`, open the same list, and `clm_finance` opens the banking + * pair. Without that, the requester's lock would bind them too: measured on + * 18.1.0, Legal Counsel 1 was refused `risk_flag` on her own party (403 + * "Field write denied") until legal declared it. + */ +export const PARTY_ASSESSED_FIELDS = ['risk_flag', 'risk_note', 'screening_status', 'is_active', 'bank_name'] as const; diff --git a/src/profiles/admin.profile.ts b/src/profiles/admin.profile.ts index 5ee44fd..4c97837 100644 --- a/src/profiles/admin.profile.ts +++ b/src/profiles/admin.profile.ts @@ -4,7 +4,7 @@ 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 } from '../sharing/_lifecycle.js'; -import { CONTRACT_STAMPED_FIELDS, inList, openAllExcept, open, readOnly } from './_grants.js'; +import { CONTRACT_STAMPED_FIELDS, PARTY_ASSESSED_FIELDS, inList, openAllExcept, open, readOnly } from './_grants.js'; /** * `clm_admin` — the CLM administrator (DESIGN.md §04): RCUD on every object, @@ -80,6 +80,8 @@ export const AdminSet = definePermissionSet({ ...openAllExcept(Signature, []), ...open(Review, ['internal_note']), ...open(Party, ['bank_account', 'contact_phone']), + // Read-only for clm_requester, which every member holds (#133). + ...open(Party, PARTY_ASSESSED_FIELDS), }, rowLevelSecurity: [ { diff --git a/src/profiles/finance.profile.ts b/src/profiles/finance.profile.ts index 0d7d645..1c09e5c 100644 --- a/src/profiles/finance.profile.ts +++ b/src/profiles/finance.profile.ts @@ -3,7 +3,7 @@ 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, CONTRACT_TERMINATION_REASON, editableOnly, hidden, inList, readOnly } from './_grants.js'; +import { CONTRACT_LEGAL_FIELDS, CONTRACT_STAMPED_FIELDS, CONTRACT_TERMINATION_REASON, editableOnly, hidden, inList, open, readOnly } from './_grants.js'; /** * `clm_finance` — the finance controller (DESIGN.md §04). @@ -48,6 +48,12 @@ export const FinanceSet = definePermissionSet({ ...readOnly(Contract, [...CONTRACT_LEGAL_FIELDS, 'status', CONTRACT_TERMINATION_REASON]), ...hidden(Review, ['internal_note']), ...editableOnly(Party, ['bank_name', 'bank_account']), + // editableOnly declares only the fields it locks, so the banking pair has + // to be opened here: clm_requester, which every member holds, hides + // `bank_account` and locks `bank_name`, and a set that says nothing does + // not out-vote it (`_grants.ts`, open). Without this line finance could + // neither read nor write the bank account (#133). + ...open(Party, ['bank_name', 'bank_account']), }, rowLevelSecurity: [ { diff --git a/src/profiles/legal.profile.ts b/src/profiles/legal.profile.ts index 42dc07b..531a083 100644 --- a/src/profiles/legal.profile.ts +++ b/src/profiles/legal.profile.ts @@ -4,7 +4,7 @@ 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, openAllExcept, open, readOnly } from './_grants.js'; +import { CONTRACT_STAMPED_FIELDS, PARTY_ASSESSED_FIELDS, inList, openAllExcept, open, readOnly } from './_grants.js'; /** * `clm_legal` — legal counsel and the head of legal (DESIGN.md §04). @@ -70,6 +70,8 @@ export const LegalSet = definePermissionSet({ ...openAllExcept(Signature, []), ...open(Review, ['internal_note']), ...open(Party, ['bank_account', 'contact_phone']), + // Read-only for clm_requester, which every member holds (#133). + ...open(Party, PARTY_ASSESSED_FIELDS), }, rowLevelSecurity: [ { diff --git a/src/profiles/requester.profile.ts b/src/profiles/requester.profile.ts index dcaa3d8..fdab8d4 100644 --- a/src/profiles/requester.profile.ts +++ b/src/profiles/requester.profile.ts @@ -3,7 +3,7 @@ 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, CONTRACT_TERMINATION_REASON, hidden, inList, readOnly } from './_grants.js'; +import { CONTRACT_STAMPED_FIELDS, CONTRACT_TERMINATION_REASON, PARTY_ASSESSED_FIELDS, hidden, inList, readOnly } from './_grants.js'; /** * `clm_requester` — every employee (DESIGN.md §04 "所有员工默认"). @@ -156,9 +156,9 @@ export const RequesterSet = definePermissionSet({ // Payment and contact details are the easiest to leak (§04 FLS table). ...hidden(Party, ['bank_account', 'contact_phone']), // Assessed by legal, or stamped by the screening connector (§08), never - // by the person who adds the party (#133, ruling A). The ruling's - // `verified_at` is `screening_status` since the global-first revision. - ...readOnly(Party, ['risk_flag', 'risk_note', 'screening_status', 'is_active', 'bank_name']), + // by the person who adds the party (#133, ruling A). Legal, admin and + // finance open what they maintain of this list in their own sets. + ...readOnly(Party, PARTY_ASSESSED_FIELDS), }, rowLevelSecurity: [ {