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
52 changes: 49 additions & 3 deletions objectstack.config.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<void> => {
registerClmPositionBindings(ctx as BindHostContext);
};
124 changes: 124 additions & 0 deletions src/profiles/_grants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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<string, FieldPermission>;

export interface ObjectLike {
name: string;
fields: Record<string, unknown>;
}

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

/**
* 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)
.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'];
68 changes: 68 additions & 0 deletions src/profiles/admin.profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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_STAMPED_FIELDS, openAllExcept, open, 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.
*
* 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,
* 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: {
...openAllExcept(Contract, CONTRACT_STAMPED_FIELDS),
...readOnly(Contract, CONTRACT_STAMPED_FIELDS),
...openAllExcept(Signature, []),
...open(Review, ['internal_note']),
...open(Party, ['bank_account', 'contact_phone']),
},
});
76 changes: 76 additions & 0 deletions src/profiles/finance.profile.ts
Original file line number Diff line number Diff line change
@@ -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"',
},
],
});
9 changes: 9 additions & 0 deletions src/profiles/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading