Skip to content

Positions, permission sets, sharing rules, FLS, onEnable bindings (card 04, M1) - #12

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-4-security
Sep 7, 2026
Merged

hotlong merged 2 commits into
mainfrom
claude/issue-4-security

Conversation

@hotlong

@hotlong hotlong commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #4

DESIGN.md §04 as metadata: the seven flat positions, the five permission sets carrying the permission matrix, the six-row sharing table, the field-level-security table, and the kernel:bootstrapped binder that writes the sys_position_permission_set rows a seed cannot. requires gains sharing and hierarchy-security. §04 was not edited; the one place it cannot be satisfied as written on this platform version is filed as #11 (decision card, not labelled — PM triage).

What changed

File Content
src/sharing/positions.ts The seven positions via definePosition, plus CLM_POSITION name constants the rules and the binder spell from one place.
src/sharing/contract.sharing.ts Five criteria sharing rules (contract_legal_all is two rows, one per legal position — a rule has one recipient), all edit/read levels and recipients per §04.
src/sharing/_lifecycle.ts The status vocabularies, READ FROM THE OBJECTS: CONTRACT_STATUSES, POST_APPROVAL_STATUSES, EXECUTION_STATUSES, REQUESTER_EDITABLE_STATUSES, REVIEW_STAGES, OBLIGATION_STATUSES. A subset that names a value the object no longer declares throws at load, which validate reports.
src/profiles/*.profile.ts clm_requester · clm_legal · clm_finance · clm_records · clm_admin via definePermissionSet: the matrix, the systemPermissions, the FLS entries and the row-level write windows.
src/profiles/_grants.ts FLS helpers derived from the objects (readOnly / hidden / open / editableOnly / openAllExcept / fieldsInGroups), the RLS inList helper, and the two shared lock lists (CONTRACT_STAMPED_FIELDS, CONTRACT_LEGAL_FIELDS). Every helper refuses a field the object does not declare.
src/security/bind-position-sets.ts The ATS-shaped binder: 12 sys_position_permission_set rows on kernel:bootstrapped, idempotent, organization-aware.
objectstack.config.ts requires: ['ui', 'auth', 'sharing', 'hierarchy-security'], positions / permissions / sharingRules, and the named onEnable export.

The permission matrix, as authored

Every cell of §04 is an object grant; the parentheticals are row scope (sharing rules + row-level policies) and field locks (FLS). Legal, finance and records carry readScope: 'own' on clm_contract explicitly — their rows arrive through the sharing rules; the admin set carries viewAllRecords / modifyAllRecords (+ allowTransfer, authored so it is readable) and is the only set with delete.

The six-row sharing table

Rows 1–5 are sharingRules. Row 6, contract_manager_reports, is exactly what §04's own table says it is: readScope / writeScope: 'own_and_reports' on the clm_requester grant (both axes — §13 Q2 names visibility, the table names writes).

Two platform shapes, neither a change of meaning:

  • "全部" cannot be an empty criteria. plugin-sharing refuses a match-all rule at seed and by defineRule, and a match-all row that slipped in would match NOTHING (isMatchAllCriteria, ADR-0049). Legal's "every contract" is spelled record.status in [every status], the list read from clm_contract.status.options, so a new status cannot fall out of legal's reach silently.
  • One recipient per rule. contract_legal_all names two positions, so it is contract_legal_all_counsel + contract_legal_all_head.

The action gates — where each token lives, and why

§04 lists six gates but assigns them to no set; the matrix and §06 decide each one:

Token Sets Derivation
manage_approval_rules admin clm_approval_rule is RCUD for admin only.
manage_clauses legal, admin Clause RCU is legal's (and admin's) alone.
execute_contract, archive_contract records, admin "clm_records_manager covers execution and archive"; records is RU from signing onward.
terminate_contract legal, admin The only two sets that may write status on an active contract: finance's status is locked by §13 Q1, records' edit is limited to execution/archive fields.
approve_contract requester, legal, finance, admin §06 F5: rung 1 is the requester's direct manager (type: 'manager'), who holds no position and therefore only clm_requester; the executive and general-manager rungs likewise hold only that set. WHICH approval is theirs is the flow's assignment, not the gate's.

Field-level security

§04's five FLS rows plus the matrix parentheticals, per set:

Set Locked (readable, not editable) Hidden (absent from reads)
all five clm_contract route_*, approval_status, the 8 stage timestamps, the 4 ai_* (= the routing / lifecycle / ai field groups, derived) —
requester risk_level, liability_cap clm_review.internal_note; clm_party.bank_account, contact_phone
finance the legal field group + risk_level + liability_cap + status (§13 Q1); every clm_party field except bank_name / bank_account clm_review.internal_note
records every clm_contract field except status (execution IS the signing → active transition the execute_contract gate authorises) and archive_no (F14); every clm_signature field except status, formalities_done, executed_file, completed_at clm_party.bank_account, contact_phone
legal, admin the all-positions row only; every other contract / signature / review / party field opened explicitly (see the merge rule below) —

Two platform rules the first verification run taught, both now in the code

  1. Row-level policies OR-merge across every set a person holds, and a set that says nothing contributes nothing. The requester's draft/submitted write window would otherwise bind a legal user (who also holds clm_requester) and lock them out of a contract in review. Every set that edits an object another set narrows carries its own permissive policy on the same object and operation, each spelled as the field's whole vocabulary read from the object (contract_legal_edit_any_status, review_legal_*_any_stage, obligation_legal_update_any; finance and records restate their sharing-rule windows). Admin carries none: the VAMA bits bypass business RLS and a policy there would be inert metadata.
  2. Field permissions merge most permissively, but only among the sets that DECLARE a field. Measured on run 1: legal read the party without bank_account and was refused internal_note on a review insert (403 Field write denied: not permitted to edit [internal_note]) because the co-held requester set hides both and legal said nothing. Legal and admin now open every field they edit explicitly (openAllExcept / open) — HotCRM's #488 discipline. Run 2: has_bank_account: true for legal, review insert 201.

clm_requester — how "所有员工默认" is distributed (#11)

The platform's mechanism for "everyone's set" is isDefault: true (auto-bind to the everyone anchor). A set carrying ANY systemPermissions is high-privilege by describeHighPrivilegeBits, so: the runtime would refuse the binding at boot with a warning, and os lint refuses the declaration (security-anchor-high-privilege, error). clm_requester must grant clm_requester.access (§04), so it is not isDefault. Instead every one of the seven positions binds clm_requester as well as its own set (12 rows), which is also the only way an executive holds an object-level read on clm_contract for the rows contract_executive_routed shares; an employee with no position receives the set in Setup. Options for the maintainer are in #11; nothing here pre-empts them.

Zone 2 — the four mechanical assumptions, measured

  1. FLS can lock status: TRUE. Finance (clm_finance_controller), on an approved contract it can read and edit: PATCH /api/v1/data/clm_contract/6HJqqeDmOxs-NoTR {status:'signing'} → 403 PERMISSION_DENIED — [Security] Field write denied: not permitted to edit [status] on 'clm_contract'. The lock is a refusal, not a silent strip (plugin-security detectForbiddenWrites throws; stripNonEditableFields has no caller). Positive control on the same row, same session, one call later: PATCH {payment_terms:'net_30'} → 200, read back by legal as payment_terms: "net_30", status: "approved". Sibling negative: PATCH {governing_law:'DE'} → 403 … not permitted to edit [governing_law]. The Q1 ruling is implementable as written.
  2. own_and_reports demands hierarchy-security at validate time: TRUE. Without the token, pnpm validate exits 1: permission set 'clm_requester' grant on 'clm_contract' uses readScope='own_and_reports', a HIERARCHY scope. Declare requires: ['hierarchy-security'] (provided by @objectstack/security-enterprise) — the open edition cannot enforce it and would fail closed to owner-only. (and the same line for writeScope). Declared, with the edition boundary recorded in the config note: on the open edition SharingService.resolveOwnerScopeIds has no resolver and returns owner-only, which is the edition the M1 measurement below was taken on — a requester reads exactly their own contracts. validate then prints one informational line naming the enterprise package; expected output, the same line HotCRM asserts.
  3. sharing is the only new token: FALSE, by one. hierarchy-security is the second, for the reason in (2) — the card anticipated exactly this case. Nothing else asked for a token: the sharing rules and the FLS declarations validate, seed and enforce under sharing alone.
  4. validate reports 7 positions and 5 permissions: TRUE — Security: 7 Positions 5 Permissions (tail below).

Gates

All three on the final commit, git rev-parse --short HEAD = 8440537.

$ pnpm validate
  ✓ Validation passed (221ms)
  Data: 11 Objects  169 Fields
  UI: 0 Apps
  Logic: 0 Flows
  Security: 7 Positions  5 Permissions
  Runtime: 0 plugins
  ⚠ Capability "hierarchy-security" is provided by @objectstack/security-enterprise (ADR-0057 hierarchy scopes ship in the enterprise edition). Run `pnpm add @objectstack/security-enterprise` and add it to `plugins[]`, or remove "hierarchy-security" from `requires`.
  ⚠ No apps or plugins defined — this stack may not do much
exit 0

$ pnpm lint
  ℹ Config: /home/user/hotclm-issue-4/objectstack.config.ts
  ✓ All checks passed (256ms)
exit 0

$ pnpm typecheck
> tsc --noEmit
exit 0

Browser evidence — every refusal observed, each with its positive control

pnpm dev --seed-admin on OS_PORT=3106, detached (setsid); Playwright 1.50 against /opt/pw-browsers/chromium-1194/chrome-linux/chrome. The Console has no rendered clm_* list or record surface yet (views arrive with cards 05/07 — card 03 recorded the same), so "the surface a person touches" is the authenticated data API. Two passes:

  • REST pass (verify-rest.mjs): five real sessions through POST /api/v1/auth/sign-in/email — the seeded admin, two requesters, a legal counsel, a finance controller — 80 calls, HTTP >= 500: 0.
  • Console pass (verify-ui.mjs): each persona signs in through the real /_console/ login form (#login-email, #login-password, Sign In → /_console/home), and the same refusals are re-taken with fetch from inside that authenticated page. Screenshots shot-01…04-*-home.png show the four signed-in homes ("No applications yet — apps your admin shares with you will show up here", the correct state before card 07).

The boot, read

✓ Server is ready, then ⚠ Boot diagnostics — 1 warning logged during startup — the ADR-0087 field-required-notnull-explicit conversion of 40 sites, first at objects[0].fields.name. Pre-existing and repo-wide: card 03's boot log carried the identical line on base, and it is filed as #8. No degraded capabilities, no no such table, no failed plugin. Nothing this card adds warns at boot: the everyone-anchor refusal that isDefault would have caused does not appear, because the set is not isDefault.

Staffing, measured

  • sys_position_permission_set (clm rows): 12 — clm_admin → clm_admin, clm_admin → clm_requester, clm_executive → clm_requester, clm_finance_controller → clm_finance, clm_finance_controller → clm_requester, clm_general_manager → clm_requester, clm_legal_counsel → clm_legal, clm_legal_counsel → clm_requester, clm_legal_head → clm_legal, clm_legal_head → clm_requester, clm_records_manager → clm_records, clm_records_manager → clm_requester. On the first boot of an empty database (the organization is created by the seed during boot), so onEnable on kernel:bootstrapped found the catalog rows; the restart re-ran it idempotently (still 12).
  • sys_sharing_rule: the six rows, all active, edit/read as declared.
  • Users via POST /api/v1/auth/admin/create-user; L given clm_legal_counsel and F clm_finance_controller via POST /api/v1/data/sys_user_position; A and B given clm_requester via POST /api/v1/data/sys_user_permission_set (no position — the plain-employee path). GET /api/v1/auth/me/permissions then reports A: sets [clm_requester, member_default], positions [org_member, everyone]; L: [clm_legal, clm_requester, member_default] / [org_member, clm_legal_counsel, everyone]; F: [clm_finance, clm_requester, member_default] / [org_member, clm_finance_controller, everyone].

Acceptance line 1 — two requesters cannot read each other's contracts

A creates CA (201), B creates CB (201).

Call Status
A POST clm_contract/query 200 total: 1, ids [CA] — A does not see CB
B POST clm_contract/query 200 total: 1, ids [CB] — B does not see CA
A GET clm_contract/CB 404 RECORD_NOT_FOUND negative
B GET clm_contract/CA 404 RECORD_NOT_FOUND negative
A GET clm_contract/CA 200 positive control
B GET clm_contract/CB 200 positive control
B PATCH clm_contract/CA {title} 403 PERMISSION_DENIED "You do not have access to this record" negative
A PATCH clm_contract/CA {title} (draft) 200 positive control

Repeated from the Console sessions of A and B with the same four outcomes (404/200/404/200).

Acceptance line 2 — a clm_legal account reads all

L POST clm_contract/query → 200, total: 2, titles ["B's contract", "A's contract (edited while draft)"]; GET CA → 200; GET CB → 200. L also moved CA submitted → in_review (200, review_started_at stamped) and later in_review → in_approval → approved (200, approved_at stamped) — the edit level of contract_legal_all holds, and the hook-stamped timestamps pass the FLS lock because the check runs on the caller's payload.

Acceptance line 3 — a clm_finance account reads no in_review contract

With CA in_review: F POST clm_contract/query → 200, total: 0; F GET clm_contract/CA → 404 RECORD_NOT_FOUND. Positive control, same session, after legal approved CA: F query → total: 1, ids [CA]; GET CA → 200, status: "approved". From the Console session: GET CB (draft) → 404, GET CA (approved) → 200.

Acceptance line 4 — clm_party.bank_account absent from a requester's read

A GET clm_party/ZaPt8gpD6xie6L1p → 200, keys: id … name, party_kind, country_code, registration_no, legal_representative, address, contact_name, contact_email, bank_name, risk_flag, … — no bank_account, no contact_phone, while contact_email and bank_name survive. Asking for it explicitly (query with fields: ['id','name','bank_account','contact_phone']) returns keys ["id","name"]. Positive control: L GET the same party → has_bank_account: true, value DE89 3704 0044 0532 0130 00, contact_phone present.

The rest of §04, while the sessions were open

  • Requester write window: A PATCH CA {title} while in_review → 403 (log: not permitted to update this 'clm_contract' record (row-level security)); the same call while draft → 200 (above). From the Console, on the approved CA → 403.
  • Requester FLS: A PATCH CA {risk_level:'high'} → 403 Field write denied: not permitted to edit [risk_level].
  • Review internal_note: L records a legal review with internal_note → 201; A GET it → has_comments: true, has_internal_note: false; L GET → has_internal_note: true.
  • Finance stage policy: on the approved CA, F POST clm_review {stage:'legal'} → 403 "You are not allowed to save this record with the values you entered" (the row-level CHECK); F POST clm_review {stage:'finance'} → 201.
  • Finance and the children: F POST clm_payment_plan on the approved CA → 201; A the same → 403 (no create grant). While CA was still in_review, F's child inserts were refused as "requires edit access to its master record … (row-level security)" — the parent derivation (ADR-0055) honours the finance window.
  • Hook stamps under FLS: A's submit → 200, submitted_at: "2026-09-07T13:56:32.794Z".

Request-time log lines seen, and what they are

All expected refusals log as WARN [Security] Access denied … (8 RLS, the FLS and CRUD denials above). Two ERROR [BodyRunner] sandboxed hook threw are the state machine's own 422 INVALID_STATE refusals from run 1 (card 02's hooks refuse by throwing; the platform logs a thrown hook at ERROR although the client received a clean 422). Also seen, none introduced here: sys_file lookup failed; file fields keep their raw ids after find on sys_file is not permitted for requester and legal sessions on contract reads (noted below), find on sys_activity is not permitted from the Console home's activity feed for non-admin users, and the pre-existing Console [AuthProvider] Failed to load organizations warning card 03 also reported.

验收备注

Out-of-scope observations, none ridden into this PR:

  • Filed as [Decision] DESIGN.md §04 makes clm_requester every employee's default, but a set that grants clm_requester.access cannot bind to everyone #11 — the clm_requester distribution decision above (§04 wording vs the platform's high-privilege rule). Left unlabelled for triage.
  • Noted, not filed — sys_file is not readable by any non-admin set. Every requester/legal read of a contract logged find on object 'sys_file' is not permitted followed by file fields keep their raw ids and will render as "no file". §04's matrix names no sys_* object, and clm_contract_version.file is required, so the version-upload path for a requester (and legal, who upload the negotiation rounds) may need a sys_file grant or an upload route that runs under a system context. Not measured here (no upload was attempted); the first card that drives an upload as a non-admin should probe it before assuming.
  • Noted, not filed — allowExport for the records ledger. §05 says the 台账 grid is exportable (可导出); the bulk-egress bit is granted on the set, but HotCRM's discipline ties it to a list view declaring exportOptions, which card 07 authors. Grant it there in the same change.
  • Noted, not filed — capability declarations. The eleven systemPermissions tokens are seeded implicitly (untitled) into sys_capability; defineCapability entries under capabilities: would give Setup labels and scope: 'org'. Additive; card 07 gates on them and is the natural place.
  • Noted, not filed — dual-hat composition. Both merge rules above are documented in _grants.ts, legal.profile.ts and requester.profile.ts: any future set that locks or narrows must be mirrored by an explicit open/permissive entry in every set a person can hold beside it. Records' editableOnly locks are countered in legal and admin; a finance + records dual hat (not a §04 shape) would inherit records' contract locks.
  • Noted, not filed — obligations assigned across the wall. clm_obligation is controlled_by_parent, so "我负责的履约" (§05) shows a requester only the obligations on contracts they can read; an obligation assigned to a colleague who cannot read the contract is invisible to them. A §03/§04 consequence for cards 07/09, not an implementation choice here.
  • Noted, not filed — the binder logs at info. [clm] position bindings ensured {created, existing, skipped, declared} is emitted, but the dev server prints WARN and above by default; the boot-time evidence is the sys_position_permission_set count, which the PR relies on.

Generated by Claude Code

…e position-set bindings

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Positions, permission sets, sharing rules, FLS, onEnable bindings (card 04, M1)

2 participants