Skip to content

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

Description

@hotlong

Milestone: M1 · Card: docs/backlog/04-security.md
Blocked-by: #2
Blocked-by: #3

Scope

src/profiles/*.profile.ts (5 permission sets), src/sharing/positions.ts (7 positions),
src/sharing/*.sharing.ts (6 rules), FLS declarations, src/security/bind-position-sets.ts +
onEnable in objectstack.config.ts. Adds requires: ['sharing'].

Spec — DESIGN.md §04, verbatim

Positions, sets, the permission matrix, the six sharing rules and the FLS table are pinned there (global-first revision, 2026-09-07: no seal keeper; clm_records_manager covers execution and archive).
Capabilities granted via systemPermissions: clm_requester.access · clm_legal.access · clm_finance.access
· clm_records.access · clm_admin.access; action gates approve_contract ·
execute_contract · archive_contract · terminate_contract · manage_clauses · manage_approval_rules.
contract_manager_reports uses writeScope: 'own_and_reports' only if declaring it does not require the
hierarchy-security capability at validate time; if it does, declare the capability (it is safe on an
open-edition boot — see HotCRM's objectstack.config.ts note) and record the edition boundary in the PR.
Bindings: sys_position_permission_set rows cannot be seeds — bind on kernel:bootstrapped as ATS does.
Ruling Q1 (2026-09-07): finance gets edit on contracts from approved onward with FLS locking the legal fields and status — verify in this card that FLS can lock status; if it cannot, return needs_decision with the measured behavior instead of widening the grant.

Acceptance

  • Gates green. Two requester accounts cannot read each other's contracts through REST; a clm_legal
    account reads all; a clm_finance account reads no in_review contract.
  • clm_party.bank_account is absent from a requester's REST read.

Out of scope

Views/app gating (07).

Activity

  1. self-assigned this
    on Sep 7, 2026
  2. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    Claim: PM loop round 3
    Session: session_01KcrVDXSptwDukFsHPHPR1V
    Branch: claude/issue-4-security
    Worktree: hotclm-issue-4
    Domain: repo:hotclm (single-lane repo)
    File surface: src/profiles/, src/sharing/, src/security/, plus objectstack.config.ts (the sharing capability token, the permissions/positions keys and onEnable — declared in advance, since security metadata cannot register from anywhere else)
    Container & model: L, mode:subagent, model: fable (permission and sharing surfaces carry design judgment, and a wrong grant fails open)
    Clause-②: no
    Serial constraints cleared: Blocked-by: #2 and #3 are both satisfied — PR #5 merged as f3f2a4e, PR #9 as 1817f95. Base is main @ 1817f95 (11 objects, 169 fields, signable-in). No other in-flight claim in this repo. Decision cards #6 and #10 touch DESIGN.md §03 state machines and field lists, not §04 — they do not block this card.


    Generated by Claude Code

  3. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 4,
      "status": "done",
      "branch": "claude/issue-4-security",
      "pr": "https://github.com/objectstack-ai/hotclm/pull/12",
      "premise_still_valid": true,
      "summary": "DESIGN.md §04 landed as metadata on main @ 1817f95: seven positions (src/sharing/positions.ts), five permission sets with the permission matrix, action gates, FLS locks and row-level write windows (src/profiles/*.profile.ts + _grants.ts), five criteria sharing rules plus the own_and_reports depth on clm_requester (src/sharing/contract.sharing.ts, _lifecycle.ts reads every status list from the objects), and the kernel:bootstrapped binder writing 12 sys_position_permission_set rows (src/security/bind-position-sets.ts, onEnable named export). requires gains sharing and hierarchy-security. Two platform rules were measured and coded around: RLS policies OR-merge across held sets (so each editing set carries a permissive companion window) and FLS merges most permissively only among declaring sets (so legal/admin open explicitly what clm_requester hides). §04 was not edited; the one sentence the platform cannot satisfy — clm_requester as everyone's default while it grants clm_requester.access, refused by lint security-anchor-high-privilege and the runtime's everyone-anchor gate — is filed as decision card #11 (unlabelled; PM to apply needs-user-decision), with distribution meanwhile via every position binding clm_requester and Setup grants for positionless employees.",
      "tests": "Gates on final commit 8440537: pnpm validate exit 0 ('✓ Validation passed', 'Security: 7 Positions  5 Permissions', one informational line naming @objectstack/security-enterprise for hierarchy-security — expected); pnpm lint exit 0 ('✓ All checks passed'); pnpm typecheck exit 0. Zone 2 #2 measured first without the token: validate exit 1 'uses readScope=own_and_reports, a HIERARCHY scope. Declare requires: [hierarchy-security]' (and writeScope). Browser: pnpm dev --seed-admin on OS_PORT=3106, '✓ Server is ready', boot diagnostics = 1 pre-existing ADR-0087 warning (identical on base, filed as #8 by card 03), nothing degraded. REST pass (5 real sessions via /api/v1/auth/sign-in/email, 80 calls, 0 HTTP>=500) and Console pass (Playwright, each persona through the real /_console/ login form, refusals re-taken from inside the authenticated page, 4 screenshots). Refusals with positive controls: requesters A/B list only their own (total 1 each), A GET CB 404 RECORD_NOT_FOUND / B GET CA 404, own GET 200, B PATCH CA 403 PERMISSION_DENIED, A PATCH own draft 200; legal lists total 2 and reads both 200, moves CA submitted→in_review→in_approval→approved (200, stamps written); finance with CA in_review: query total 0, GET CA 404 — after approval query total 1, GET CA 200; requester GET party 200 with no bank_account and no contact_phone (contact_email present), legal GET same party has bank_account true; Zone 2 #1: finance PATCH {status:'signing'} on approved CA → 403 '[Security] Field write denied: not permitted to edit [status] on clm_contract', positive control PATCH {payment_terms:'net_30'} → 200 read back net_30, sibling {governing_law:'DE'} → 403; requester PATCH while in_review 403 (RLS window), requester PATCH risk_level 403 (FLS), review internal_note hidden from requester (has_comments true / has_internal_note false) and present for legal, finance legal-stage review 403 (row CHECK) vs finance-stage 201, finance payment_plan insert 201 vs requester 403. Bindings: sys_position_permission_set clm rows = 12; sys_sharing_rule = 6 active. Reverse evidence: run 1 (before the explicit FLS re-opens) had legal lose bank_account and get 403 on internal_note; run 2 after the fix: bank_account true, review 201 — the same calls flipped. Ablation with rebuild/on-disk proof: not applicable — metadata-only app with no dist-resolved test subject; the negative/positive control pairs above are the measurement.",
      "mcp_calls": "5 — search_issues ×2 (targeted dedup returned 0; control query hit #6 on the same channel; REST answered 403 at the start of the run so search switched to MCP), issue_write ×1 (#11), create_pull_request ×1 (#12), add_issue_comment ×1 (this report)",
      "open_questions": [
        {
          "question": "clm_requester is 'every employee's default' (§04) but a set carrying systemPermissions cannot bind to the everyone anchor (lint error security-anchor-high-privilege, runtime refusal). How is the default distributed? Filed as #11.",
          "options": ["A keep the tokens; §04 states the distribution (positions bind clm_requester too, positionless employees granted in Setup); report the platform gap upstream so isDefault can return", "B drop clm_requester.access, mark the set isDefault, gate the 我的合同 group on authentication (§04/§05 change)", "C add an eighth position clm_employee (§04 change)"],
          "recommendation": "A, because it changes no token semantics, is fully reversible once the platform distinguishes app capability tokens from platform system permissions, and keeps the five-token navigation scheme intact"
        },
        {
          "question": "§04 lists six action gates but assigns them to no set; the PR derives the placement (manage_approval_rules→admin; manage_clauses→legal,admin; execute/archive→records,admin; terminate→legal,admin; approve_contract→requester,legal,finance,admin because §06 F5's first rung is the direct manager who holds only clm_requester). Accept the derivation?",
          "options": ["A accept the table as authored in the PR", "B maintainer reassigns one or more tokens (one-line edits in src/profiles)"],
          "recommendation": "A, because every row is forced by the matrix or by F5's rung-1 manager, and approve_contract on the requester set is the only placement under which that rung can act at all"
        }
      ],
      "out_of_scope_findings": [
        "filed as #11: clm_requester default distribution vs the everyone-anchor high-privilege rule — decision card, left unlabelled for PM triage",
        "noted, not filed: no non-admin set can read sys_file — every requester/legal contract read logs 'find on sys_file is not permitted' then 'file fields keep their raw ids'; clm_contract_version.file is required, so the non-admin upload path may need a sys_file grant or a system-context upload route; not measured here",
        "noted, not filed: §05 says the records 台账 grid is exportable but allowExport is not granted yet — grant it in card 07 together with the list view's exportOptions (HotCRM discipline)",
        "noted, not filed: the eleven systemPermissions tokens are seeded untitled; defineCapability entries under capabilities: would give Setup labels and scope org — card 07's natural place",
        "noted, not filed: dual-hat composition rule documented in _grants.ts/legal.profile.ts/requester.profile.ts — any future set that locks or narrows must be mirrored by explicit open/permissive entries in every co-holdable set",
        "noted, not filed: clm_obligation is controlled_by_parent, so an obligation assigned to someone who cannot read the contract is invisible to them — a §03/§04 consequence for cards 07/09",
        "noted, not filed: hook refusals (422 INVALID_STATE) are logged by the platform as ERROR [BodyRunner] sandboxed hook threw although the client receives a clean 422 — card 02's design, platform log level",
        "noted, not filed: the binder's '[clm] position bindings ensured' line is info-level and invisible at the dev server's default log level; the evidence is the sys_position_permission_set count"
      ]
    }

    Generated by Claude Code

  4. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    ACCEPT — PM review, round 3 (M1 complete)

    Reviewed against GitHub. PR #12, head 8440537.

    Item Verdict
    PR shape draft, base main, Fixes #4 ✓
    CI Validate green on 8440537 ✓
    File surface 14 files across src/profiles/, src/sharing/, src/security/ plus objectstack.config.ts — exactly the surface the claim declared in advance, no breach ✓
    Gates validate ✓ (7 Positions, 5 Permissions — matches §04's rosters) · lint ✓ · typecheck ✓
    Capabilities requires: ['ui', 'auth', 'sharing', 'hierarchy-security'] — both new tokens named and justified ✓

    Both Zone 2 assumptions resolved by measurement, and the first one matters beyond this card.

    1. FLS can lock status — so ruling Q1 is implementable as written. Finance PATCH {status: 'signing'} on an approved contract → 403 [Security] Field write denied: not permitted to edit [status] on clm_contract, with the positive control {payment_terms: 'net_30'} → 200 and the value read back. That was the open risk when I answered Q1 on card 02; it is now closed with evidence rather than assumption.
    2. hierarchy-security is genuinely required. Measured first without the token: validate exit 1, uses readScope=own_and_reports, a HIERARCHY scope. Declare requires: ['hierarchy-security']. Declaring it second is the right order — the requirement was established, not assumed.

    Two platform behaviours discovered and designed around. These are the substance of the card and neither is in DESIGN.md:

    • RLS policies OR-merge across held sets, so an editing set needs a permissive companion window or the narrower set widens the broader one.
    • FLS merges most permissively only among declaring sets, so a lock in one set is silently void unless every co-holdable set also declares the field. Legal and admin therefore re-open explicitly what clm_requester hides.

    The second is the kind of rule that fails open, and the run caught it the honest way: run 1, before the explicit re-opens, had legal lose bank_account and take a 403 on internal_note; run 2 flipped the same calls. That is ablation-quality evidence for a security surface — the control was observed failing before it was observed passing.

    Every refusal carries its positive control, which is what I asked for and what makes the negatives mean anything:

    Refusal observed Its control
    Requester A GET B's contract → 404 A GET own → 200; each lists total 1
    B PATCH A's contract → 403 A PATCH own draft → 200
    Finance GET an in_review contract → 404, query total 0 Same contract after approval → 200, total 1
    Requester GET party → no bank_account, no contact_phone Legal GET same party → bank_account present
    Finance PATCH status → 403 PATCH payment_terms → 200, read back
    Requester PATCH during in_review → 403 (RLS window) — with the FLS refusal on risk_level as a separate axis

    Bindings verified as data, not as intent: 12 sys_position_permission_set rows, 6 active sharing rules. Both the REST pass (5 real sessions, 80 calls, zero 5xx) and a Console pass through the real login form.

    §04 was not edited. The one sentence the platform cannot satisfy — clm_requester as everyone's default while it carries a capability token — was raised as decision card #11 instead of resolved in a diff. That is the third card in a row to hit a governed-surface gap and route it correctly rather than widen the text.

    Open questions. Both are on #11, now labelled and with the second one added by me:

    1. How clm_requester reaches employees without a position (recommendation A: keep the tokens, state the distribution, report the platform gap upstream).
    2. Which sets hold the six action gates — §04 lists them and assigns them to nobody. The derivation is forced by the matrix and by F5's rung-1 manager, and approve_contract on the requester set is the only placement under which that rung can act at all. This is a permission boundary, so it is the maintainer's, not mine.

    Neither blocks M1: both are one-line reversible edits in src/profiles/, and the acceptance run staffed its personas through the Setup path.

    Merging: CI green, ACCEPT recorded, no governed surface touched.

    M1 is complete with this card — 11 objects, the state machines, the roll-ups, and the full security model, each verified against a running app.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions