Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/profiles/_grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,36 @@ export function open(object: ObjectLike, fields: readonly string[]): FieldSecuri
return Object.fromEntries(fields.map((field) => [`${object.name}.${field}`, { readable: true, editable: true }]));
}

/**
* The same entries, voting only on the rows `condition` matches — the field
* permission's row condition (`when`, `@objectstack/spec` 18.2.0,
* objectstack-ai/objectstack#25866). On every other row the entry is ABSENT,
* not open: the most-permissive merge and the "only declaring sets vote" rule
* ({@link open}) are unchanged, so a co-held set that locks the same field —
* `clm_requester`'s stamps, `risk_level`, `liability_cap` — still locks it
* there, and a set that says nothing still lifts nothing.
*
* The write gate judges the condition on the STORED row before an update (a
* lock on `status in [...]` holds on an approved contract even when the write
* moves `status`) and on the incoming row of an insert. The condition reaches
* the EDIT vote only, so a {@link hidden} entry is refused here, as the spec
* refuses `readable: false` beside a `when`. Readers that hold no row — the
* record form's field projection, `/auth/me/permissions` — report a
* conditioned entry as its lock on every row.
*
* Use it for "this set locks these fields from a stage onward": the lock then
* stays off the draft a holder of the set launches as a requester.
*/
export function onRowsWhere(security: FieldSecurity, condition: string): FieldSecurity {
const unreadable = Object.entries(security).filter(([, perm]) => perm.readable === false).map(([key]) => key);
if (unreadable.length > 0) {
throw new Error(
`onRowsWhere: ${unreadable.join(', ')} ${unreadable.length === 1 ? 'is' : 'are'} not readable — a row condition reaches the edit vote only, so a read lock cannot carry one.`,
);
}
return Object.fromEntries(Object.entries(security).map(([key, perm]) => [key, { ...perm, when: condition }]));
}

/** Every authored field of the object except `except` is {@link open}. */
export function openAllExcept(object: ObjectLike, except: readonly string[]): FieldSecurity {
assertFields(object, except, 'openAllExcept');
Expand Down
22 changes: 17 additions & 5 deletions src/profiles/finance.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, open, readOnly } from './_grants.js';
import { CONTRACT_LEGAL_FIELDS, CONTRACT_STAMPED_FIELDS, CONTRACT_TERMINATION_REASON, editableOnly, hidden, inList, onRowsWhere, open, readOnly } from './_grants.js';

/**
* `clm_finance` — the finance controller (DESIGN.md §04).
Expand All @@ -13,9 +13,11 @@ import { CONTRACT_LEGAL_FIELDS, CONTRACT_STAMPED_FIELDS, CONTRACT_TERMINATION_RE
* 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.
* a finance user's write that names `status` — or any legal field — on an
* approved-or-later contract is refused with 403 (`Field write denied`). Card
* 04 measured exactly that on a running app; the PR carries the transcript.
* The lock carries the same window as a row condition (`onRowsWhere`), so it
* does not land on the draft a finance controller launches (#127).
*
* On `clm_review`, "RCU(stage=finance)": finance reads every review of a
* contract it can see (legal's `internal_note` withheld) and creates or
Expand Down Expand Up @@ -45,7 +47,17 @@ export const FinanceSet = definePermissionSet({
// §04 "FLS 锁法律字段" + §13 Q1 "与 status". The termination reason travels
// with `status` on the same write, so locking one without the other would
// leave finance able to author half of a transition it may not make.
...readOnly(Contract, [...CONTRACT_LEGAL_FIELDS, 'status', CONTRACT_TERMINATION_REASON]),
//
// From `approved` onward only — the rows finance edits as finance (#127,
// ruling A). Before that the entries are absent, so on the draft a finance
// controller launches as a requester, `clm_requester` alone decides: the
// legal fields and `status` are open there as they are to every launcher,
// and `risk_level`, `liability_cap` and the termination reason stay locked
// by the requester's own entries. The stamps above stay unconditional.
...onRowsWhere(
readOnly(Contract, [...CONTRACT_LEGAL_FIELDS, 'status', CONTRACT_TERMINATION_REASON]),
inList('status', POST_APPROVAL_STATUSES),
),
...hidden(Review, ['internal_note']),
...editableOnly(Party, ['bank_name', 'bank_account']),
// editableOnly declares only the fields it locks, so the banking pair has
Expand Down
12 changes: 9 additions & 3 deletions src/profiles/records.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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';
import { editableOnly, hidden, inList, onRowsWhere } from './_grants.js';

/**
* `clm_records` — the records manager (DESIGN.md §04, global-first revision:
Expand All @@ -21,7 +21,9 @@ import { editableOnly, hidden, inList } from './_grants.js';
* - `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`,
* position, is readable and locked (`editableOnly`) on the rows of that edit
* window — a row condition (`onRowsWhere`), so the lock does not land on the
* draft a records manager launches (#127). 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`
Expand All @@ -46,7 +48,11 @@ export const RecordsSet = definePermissionSet({
clm_approval_rule: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false },
},
fields: {
...editableOnly(Contract, ['status', 'archive_no']),
// From `signing` onward only — the rows records edits as records (#127,
// ruling A). Before that the entries are absent, so on the draft a records
// manager launches as a requester, `clm_requester` alone decides, stamps
// and assessed fields included.
...onRowsWhere(editableOnly(Contract, ['status', 'archive_no']), inList('status', EXECUTION_STATUSES)),
...editableOnly(Signature, ['status', 'formalities_done', 'executed_file', 'completed_at']),
...hidden(Party, ['bank_account', 'contact_phone']),
},
Expand Down
Loading