diff --git a/.changeset/quotes-and-contracts-follow-the-account.md b/.changeset/quotes-and-contracts-follow-the-account.md new file mode 100644 index 000000000..dfc68d28b --- /dev/null +++ b/.changeset/quotes-and-contracts-follow-the-account.md @@ -0,0 +1,37 @@ +--- +'hotcrm': minor +--- + +**Quotes and contracts now follow the account** (#549). Both `crm_quote` and +`crm_contract` move from `sharingModel: 'private'` to `controlled_by_parent` +under `crm_account`, with the account lookup promoted to master-detail — the +same derivation contacts already use. A rep who receives an account through a +territory or team sharing rule now sees that account's *Quotes* and *Contracts* +related lists complete, renewals included, instead of the own-only keyhole this +issue reported. Access is computed per account, at every level: a quote under +an account you cannot see stays invisible, and so do its line items (measured +on `@objectstack/*` 17.4.0, which carries the two-level chain fix for +objectstack-ai/objectstack#11082 — the reason this change waited). + +FROM → TO, per persona: + +- **Sales Rep** — reads the quotes and contracts of every account they can see + (own, territory-shared, team-shared, manually shared); edits the quotes of + accounts they hold edit on (a territory share carries edit); still never + edits a contract (the object-level right is unchanged). The inert + `readScope: 'own'` on both objects is gone. +- **Sales Manager** — edits every contract, on every edition: the write gate + now asks for edit on the account, which the manager holds org-wide. The #880 + hierarchy write depth (`own_and_reports`, Enterprise-only) is no longer + declared because it is inert on a parent-derived object; the + `hierarchy-security` capability declaration stays. +- **Tasks, events, cases and opportunities** stay own-only, as ruled. + +An account delete would now cascade its quotes and contracts (master-detail), +so `account_protection` refuses to delete a customer account while an +*Activated* contract still references it — `Cannot delete customer account: 1 +activated contract still references it. Terminate or reassign it first.` — +alongside the existing open-opportunity refusal. An account carrying only +drafts or ended contracts is still refused by the platform's own referential +pass (the contact cascade stops on the contract's required Primary Contact), so +in practice an account with contracts cannot be deleted until they are gone. diff --git a/content/docs/administration/profiles.mdx b/content/docs/administration/profiles.mdx index 12997ef68..f8edab608 100644 --- a/content/docs/administration/profiles.mdx +++ b/content/docs/administration/profiles.mdx @@ -41,12 +41,13 @@ Permission sets are **explicit-allow only**: if an object is not listed in a set ### 💼 Sales Representative -- Create, read and edit their **own** leads, accounts, opportunities, quotes and tasks +- Create, read and edit their **own** leads, accounts, opportunities and tasks +- **Quotes**: the quotes on accounts the rep can see, and edit on the quotes of accounts they can edit — Quote is Controlled by Parent under the account since #549, the same derivation as Contact below - **Contacts**: the contacts on accounts the rep can see — their own, plus every account a territory rule or manual share put in reach. Contact is Controlled by Parent, and that derivation follows the account's sharing (see [Sharing & security](/docs/administration/sharing-and-security), *Controlled by Parent, in practice*) - **Opportunity and quote line items** — full control, which is what the "Products" related list and quote building need. Controlled by Parent again, so this reaches the lines of the deals and quotes the rep can see, and no others - Read the **product** catalog, **campaigns**, **campaign members** and **knowledge articles** - Read their **own forecast snapshots** — a rep never writes one. The nightly forecast job opens and refreshes them, and a manager or an admin may also enter one by hand; a hand-entered current-quarter row stands that nightly job down for the owner and period until the row is deleted (see [Forecasting](/docs/sales/forecasting)) -- Read their own **contracts** and **cases**; deleting is a manager privilege (their own tasks excepted) +- Read the **contracts** of every account they can see (Contract is Controlled by Parent under the account) and their own **cases**; deleting is a manager privilege (their own tasks excepted) ### 🎧 Service Agent diff --git a/content/docs/administration/profiles.zh-Hans.mdx b/content/docs/administration/profiles.zh-Hans.mdx index 830622ef4..c4b26a968 100644 --- a/content/docs/administration/profiles.zh-Hans.mdx +++ b/content/docs/administration/profiles.zh-Hans.mdx @@ -41,12 +41,13 @@ description: 六种内置权限集,以及每一种能查看、编辑和执行 ### 💼 Sales Representative -- 创建、读取和编辑 **自己的** 线索、客户、商机、报价和任务 +- 创建、读取和编辑 **自己的** 线索、客户、商机和任务 +- **报价**:代表能看到的那些客户下面的报价,并且能编辑自己有编辑权的客户下的报价 —— 自 #549 起 Quote 是客户之下的“由父级控制”,与下面的联系人是同一种派生 - **联系人**:代表能看到的那些客户下面的联系人 —— 自己的客户,加上区域规则或手工共享送到手上的每一个客户。Contact 是“由父级控制”,而这份派生跟随客户的共享(参见[共享与安全](/zh-Hans/docs/administration/sharing-and-security),*“由父级控制”在实践中*) - **商机行项和报价行项** —— 完整权限,这正是“Products”相关列表和报价编制所需要的。它们同样是“由父级控制”,因此这份权限达到的是代表能看到的那些交易和报价的行项,别的都到不了 - 读取 **产品** 目录、**营销活动**、**营销活动成员** 和 **知识文章** - 读取 **自己的预测快照** —— 销售代表自己从不写入。每晚的预测作业负责开出并刷新它们,而经理或管理员也可以手工录入一条;手工录入的当前季度那一行,会让这个每晚的作业对该负责人和周期停手,直到那一行被删除(参见[预测](/zh-Hans/docs/sales/forecasting)) -- 读取自己的 **合同** 和 **工单**;删除属于经理权限(自己的任务除外) +- 读取自己能看到的每一个客户的 **合同**(Contract 是客户之下的“由父级控制”)以及自己的 **工单**;删除属于经理权限(自己的任务除外) ### 🎧 Service Agent diff --git a/content/docs/administration/profiles.zh-Hant.mdx b/content/docs/administration/profiles.zh-Hant.mdx index 273ab15d1..467375977 100644 --- a/content/docs/administration/profiles.zh-Hant.mdx +++ b/content/docs/administration/profiles.zh-Hant.mdx @@ -43,12 +43,13 @@ description: 六種內建權限集,以及每一種能檢視、編輯和執行 ### 💼 Sales Representative -- 建立、讀取和編輯 **自己的** 線索、客戶、商機、報價和任務 +- 建立、讀取和編輯 **自己的** 線索、客戶、商機和任務 +- **報價**:代表能看到的那些客戶底下的報價,並且能編輯自己有編輯權的客戶底下的報價 —— 自 #549 起 Quote 是客戶之下的「由父層控制」,與下面的聯絡人是同一種衍生 - **聯絡人**:代表能看到的那些客戶底下的聯絡人 —— 自己的客戶,加上區域規則或手動共用送到手上的每一個客戶。Contact 是「由父層控制」,而這份衍生跟隨客戶的共用(參見[共用與安全](/zh-Hant/docs/administration/sharing-and-security),*「由父層控制」在實務中*) - **商機明細和報價明細** —— 完整權限,這正是「Products」相關清單和報價編製所需要的。它們同樣是「由父層控制」,因此這份權限達到的是代表能看到的那些交易和報價的明細,別的都到不了 - 讀取 **產品** 目錄、**行銷活動**、**行銷活動成員** 和 **知識文章** - 讀取 **自己的預測快照** —— 銷售代表自己從不寫入。每晚的預測作業負責開出並刷新它們,而經理或管理員也可以手動錄入一筆;手動錄入的當前季度那一筆,會讓這個每晚的作業對該負責人和週期停手,直到那一筆被刪除(參見[預測](/zh-Hant/docs/sales/forecasting)) -- 讀取自己的 **合約** 和 **工單**;刪除屬於經理權限(自己的任務除外) +- 讀取自己能看到的每一個客戶的 **合約**(Contract 是客戶之下的「由父層控制」)以及自己的 **工單**;刪除屬於經理權限(自己的任務除外) ### 🎧 Service Agent diff --git a/content/docs/administration/sharing-and-security.mdx b/content/docs/administration/sharing-and-security.mdx index 1fbb85afd..f09aec672 100644 --- a/content/docs/administration/sharing-and-security.mdx +++ b/content/docs/administration/sharing-and-security.mdx @@ -27,9 +27,9 @@ OWD is the *most restrictive* setting for each object. Everything else widens it | Contact | Controlled by Parent (Account) | A contact belongs to its account, and follows the account's sharing — see *Controlled by Parent, in practice* below | | Opportunity | Private | Sensitive deal data | | Opportunity Line Item | Controlled by Parent (Opportunity) | A product line is part of the deal | -| Quote | Private | Pricing is owner's business | +| Quote | Controlled by Parent (Account) | A quote is priced for an account and follows the account's sharing — see *Controlled by Parent, in practice* below | | Quote Line Item | Controlled by Parent (Quote) | A line is part of the quote | -| Contract | Private | Sensitive commercials | +| Contract | Controlled by Parent (Account) | A contract *is* account data and follows the account's sharing — see *Controlled by Parent, in practice* below | | Case | Private | An agent's queue is their own; escalations are shared | | Task | Private | Personal activity items | | Event | Private | Personal activity items, like tasks | @@ -45,9 +45,9 @@ OWD is the *most restrictive* setting for each object. Everything else widens it ### Controlled by Parent, in practice -For the six parent-derived objects above, there is nothing to configure per user: access is *computed*. +For the eight parent-derived objects above, there is nothing to configure per user: access is *computed*. -- **Read** — you see the rows whose parent you can see, and "can see" means every route that would let you open the parent directly: you own it, a sharing rule granted it, or someone shared it with you by hand. Measured against the shipped stack (platform 17.0.0-rc.4): a rep who can read exactly one account reads **that** account's contacts and no others, and a rep who can read **no** quote reads no quote line items at all. The account reaching that rep only through a territory rule is the point — a parent held by a share counts as a parent you can see. +- **Read** — you see the rows whose parent you can see, and "can see" means every route that would let you open the parent directly: you own it, a sharing rule granted it, or someone shared it with you by hand. Measured against the shipped stack (platform 17.0.0-rc.4): a rep who can read exactly one account reads **that** account's contacts, quotes and contracts and no others, and reads the line items of exactly those quotes — the chain quote line item → quote → account is resolved level by level (platform 17.4.0, objectstack-ai/objectstack#11082). The account reaching that rep only through a territory rule is the point — a parent held by a share counts as a parent you can see. - **Write** — adding, changing or removing a row requires **edit** access to the parent, resolved the same way as reads. A row under a parent you cannot edit is refused, naming the parent that stopped it. > **This changed in platform 17.0.0-rc.4.** Through 17.0.0-rc.3 the computed answer was org-wide rather than parent-scoped: the derivation resolved accessible parents from the parent object's *row-level security policies* alone, and HotCRM authors almost none, so a Controlled by Parent object was readable — and writable — by every user whose profile granted the object at all. If you are running an earlier release, that is the behaviour you have, and quote and opportunity line pricing is visible org-wide. The repo test `test/parent-derived-reach.test.ts` pins the current reach in both directions (#549, #694; upstream fix objectstack-ai/objectstack#5386). @@ -135,21 +135,21 @@ Most sharing rules are standing openings: a rule that matches a record today mat ### A rule widens one object, not the records underneath it -Sharing rules are authored **per object**. Widening `Account` widens accounts — the records hanging off a shared account keep their own baseline, and Contact is the only one derived from it: +Sharing rules are authored **per object**. Widening `Account` widens accounts — the records hanging off a shared account keep their own baseline, except the three derived from it (Contact, Quote, Contract): | Related list on a shared account | What the recipient sees | | --- | --- | | Contacts | This account's contacts — Contact is Controlled by Parent, and the shared account counts as a parent they can see | | Opportunities | Their own deals only, plus open deals ≥ $100,000 if they hold `sales_director` or `executive` | -| Quotes | Their own only | -| Contracts | Their own only | +| Quotes | This account's quotes — Quote follows the account, and so do the quotes' line items | +| Contracts | This account's contracts — Contract follows the account; whether they can *edit* one is the object-level right on their profile (a Sales Rep cannot) | | Cases | Their own only, plus open critical cases if they hold `service_manager` or `service_director` | | Tasks | Their own only | | Events | Their own only | -So a territory rule hands over the account record; the deal, quote, contract, case, task and meeting history underneath stays with its owners, and those related lists can look empty or partial on an account the user can otherwise read in full. Contacts are the exception, and the intended one: they follow the account, so the Contacts list of a territory-shared account is complete. +So a territory rule hands over the account record with its contacts, quotes and contracts; the deal, case, task and meeting history underneath stays with its owners, and those related lists can look empty or partial on an account the user can otherwise read in full. -Making a child object follow the account is a deliberate widening, not a config detail. A sharing rule on the child object with criteria matching the account rules opens that object for **every** holder of it, not only for the territory recipients. Setting the child's OWD to Controlled by Parent is the narrower instrument: since platform 17.0.0-rc.4 it grants the child exactly to whoever can reach the parent (see *Controlled by Parent, in practice* above). HotCRM ships neither for Quote and Contract today; whether it should is an open decision (#549). +Making a child object follow the account is a deliberate widening, not a config detail. A sharing rule on the child object with criteria matching the account rules opens that object for **every** holder of it, not only for the territory recipients. Setting the child's OWD to Controlled by Parent is the narrower instrument: it grants the child exactly to whoever can reach the parent (see *Controlled by Parent, in practice* above). That is what HotCRM ships for Quote and Contract (#549): a rep who receives an account through a territory rule sees, and — holding edit on the account — may edit, its quotes; contracts they read but never edit. Deals, cases, tasks and events stay own-only on purpose — a task is a personal work item, and the pipeline question is deferred until a territory model exists. ## Layer 4 — Manual shares @@ -245,6 +245,6 @@ There is no "AI bypass". If you think you should be able to see a record and can't: 1. Check **who owns it**. -2. If you reached it from an account, check **which object it is**: quotes, contracts, tasks, cases and deals stay with their owner even when the account itself is shared with you. Contacts are the one related list that is never withheld — Contact is Controlled by Parent, so it follows the account and arrives with it (see *Controlled by Parent, in practice*). +2. If you reached it from an account, check **which object it is**: tasks, cases and deals stay with their owner even when the account itself is shared with you. Contacts, quotes and contracts are the related lists that are never withheld — all three are Controlled by Parent, so they follow the account and arrive with it (see *Controlled by Parent, in practice*). 3. Check whether the deal is flagged **Private**. 4. Ask your admin whether a **sharing rule** should cover your position. diff --git a/content/docs/administration/sharing-and-security.zh-Hans.mdx b/content/docs/administration/sharing-and-security.zh-Hans.mdx index b74ee5ee9..9a33e15ac 100644 --- a/content/docs/administration/sharing-and-security.zh-Hans.mdx +++ b/content/docs/administration/sharing-and-security.zh-Hans.mdx @@ -27,9 +27,9 @@ OWD 是每个对象的 *最严格* 设置,其他一切都在此之上放宽。 | 联系人 | 由父级控制(客户) | 联系人属于它的客户,并跟随客户的共享 —— 见下面的 *“由父级控制”在实践中* | | 商机 | 私有 | 敏感的交易数据 | | 商机行项 | 由父级控制(商机) | 产品行是交易的一部分 | -| 报价 | 私有 | 定价属于所有人 | +| 报价 | 由父级控制(客户) | 报价是为某个客户开出的,并跟随客户的共享 —— 见下面的 *“由父级控制”在实践中* | | 报价行项 | 由父级控制(报价) | 行项是报价的一部分 | -| 合同 | 私有 | 敏感的商务条款 | +| 合同 | 由父级控制(客户) | 合同本身就是客户数据,并跟随客户的共享 —— 见下面的 *“由父级控制”在实践中* | | 工单 | 私有 | 专员的队列是自己的;升级件走共享 | | 任务 | 私有 | 个人活动条目 | | 活动 | 私有 | 个人活动条目,与任务同类 | @@ -45,9 +45,9 @@ OWD 是每个对象的 *最严格* 设置,其他一切都在此之上放宽。 ### “由父级控制”在实践中 -对于上面六个由父级派生的对象,没有任何按用户配置的东西:访问权限是 *计算* 出来的。 +对于上面八个由父级派生的对象,没有任何按用户配置的东西:访问权限是 *计算* 出来的。 -- **读** —— 你看到的是那些父记录你能看到的行,而“能看到”指的是任何一条能让你直接打开该父记录的途径:你拥有它、某条共享规则授予了它,或者有人手工共享给了你。对随产品发布的 stack 实测(平台 17.0.0-rc.4):一个只读得到一个客户的代表,只读到 **那一个** 客户的联系人;一个一张报价都读不到的代表,一条报价行项也读不到。那个仅通过区域规则到达该代表的客户正是关键 —— 通过共享持有的父记录,同样算作“你能看到的父记录”。 +- **读** —— 你看到的是那些父记录你能看到的行,而“能看到”指的是任何一条能让你直接打开该父记录的途径:你拥有它、某条共享规则授予了它,或者有人手工共享给了你。对随产品发布的 stack 实测(平台 17.0.0-rc.4):一个只读得到一个客户的代表,只读到 **那一个** 客户的联系人、报价和合同,并且只读到这些报价的行项 —— 报价行项 → 报价 → 客户 这条链是逐级解析的(平台 17.4.0,objectstack-ai/objectstack#11082)。那个仅通过区域规则到达该代表的客户正是关键 —— 通过共享持有的父记录,同样算作“你能看到的父记录”。 - **写** —— 新增、修改或移除一行需要对父记录有 **编辑** 权限,其解析方式与读取相同。父记录你无权编辑的行会被拒绝,并在报错中点名是哪个父记录挡下了它。 > **这一行为在平台 17.0.0-rc.4 发生了改变。** 在 17.0.0-rc.3 及更早版本上,计算出来的结果是组织范围而不是按父记录收窄:派生只从父对象自身的 *行级安全策略* 去解析可访问的父记录,而 HotCRM 几乎没有写这类策略,因此只要权限档案授予了该对象,“由父级控制”对象对该用户就是可读、也可写的。如果你运行的是更早的版本,你拿到的就是那个行为,报价与商机的行项定价在组织范围内可见。本仓的测试 `test/parent-derived-reach.test.ts` 在两个方向上都钉住了当前的可达范围(#549、#694;上游修复 objectstack-ai/objectstack#5386)。 @@ -114,21 +114,21 @@ na_sales_team eu_sales_team (区域分组) ### 一条规则放开的是一个对象,而不是它下面的记录 -共享规则是 **按对象** 编写的。放开 `Account` 只放开客户本身 —— 挂在这个客户下的记录仍然各自守着自己的基线,其中只有联系人是从客户派生的: +共享规则是 **按对象** 编写的。放开 `Account` 只放开客户本身 —— 挂在这个客户下的记录仍然各自守着自己的基线,从客户派生的三个除外(联系人、报价、合同): | 已共享客户上的相关列表 | 接收者实际看到的 | | --- | --- | | 联系人 | 这个客户的联系人 —— Contact 是“由父级控制”,而共享过来的客户就算作他们能看到的父记录 | | 商机 | 只有自己的交易;若持有 `sales_director` 或 `executive`,另加金额 ≥ $100,000 的未结交易 | -| 报价 | 只有自己的 | -| 合同 | 只有自己的 | +| 报价 | 这个客户的报价 —— Quote 跟随客户,报价的行项也随之而来 | +| 合同 | 这个客户的合同 —— Contract 跟随客户;能不能*编辑*某一份,由 profile 上的对象级权限决定(Sales Rep 不能) | | 工单 | 只有自己的;若持有 `service_manager` 或 `service_director`,另加未关闭的严重工单 | | 任务 | 只有自己的 | | 活动 | 只有自己的 | -也就是说,区域规则交出的是客户记录;下面的交易、报价、合同、工单、任务和会议历史仍归各自的所有人,用户明明能完整读取这个客户,这些相关列表却可能是空的或残缺的。联系人是例外,而且是符合本意的那种例外:它跟随客户,所以区域共享过来的客户,其联系人列表是完整的。 +也就是说,区域规则交出的是客户记录,连同它的联系人、报价和合同;下面的交易、工单、任务和会议历史仍归各自的所有人,用户明明能完整读取这个客户,这些相关列表却可能是空的或残缺的。 -让子对象跟随客户是一次有意的放开,而不是一个配置细节。在子对象上按与客户规则匹配的条件另写一条共享规则,会对 **所有** 持有该对象的用户放开可见性,而不只是区域规则的接收者。把子对象的 OWD 改成“由父级控制”则是更精准的那把刀:自平台 17.0.0-rc.4 起,它恰好把子记录授予那些能够到达其父记录的人(见上面的 *“由父级控制”在实践中*)。对报价和合同,HotCRM 今天两者都不做;是否应该做是一项待定决策(#549)。 +让子对象跟随客户是一次有意的放开,而不是一个配置细节。在子对象上按与客户规则匹配的条件另写一条共享规则,会对 **所有** 持有该对象的用户放开可见性,而不只是区域规则的接收者。把子对象的 OWD 改成“由父级控制”则是更精准的那把刀:它恰好把子记录授予那些能够到达其父记录的人(见上面的 *“由父级控制”在实践中*)。这正是 HotCRM 对报价和合同的做法(#549):通过区域规则拿到客户的代表能看到该客户的报价,并且——因为持有该客户的编辑权——可以编辑它们;合同则只读、永不可改。交易、工单、任务和活动刻意保持只有自己的——任务是个人工作项,而管道的问题推迟到有区域模型之后再议。 ## 第 4 层 —— 手工共享 @@ -205,6 +205,6 @@ Copilot 代表用户行动 —— 它看到的与用户看到的完全一致, 如果你认为自己应该能看到某条记录却看不到: 1. 查一下 **谁拥有它**。 -2. 如果你是从客户点进去的,先看 **它是哪个对象**:即使客户本身已经共享给你,报价、合同、任务、工单和交易仍归各自的所有人。联系人是唯一从不会被扣下的相关列表 —— Contact 是“由父级控制”,它跟随客户,所以客户共享给你时联系人也一并到达(见 *“由父级控制”在实践中*)。 +2. 如果你是从客户点进去的,先看 **它是哪个对象**:即使客户本身已经共享给你,任务、工单和交易仍归各自的所有人。联系人、报价和合同是从不会被扣下的相关列表 —— 三者都是“由父级控制”,它们跟随客户,所以客户共享给你时也一并到达(见 *“由父级控制”在实践中*)。 3. 查一下该交易是否被标记为 **Private**。 4. 询问管理员是否应该有一条 **共享规则** 覆盖你的岗位。 diff --git a/content/docs/administration/sharing-and-security.zh-Hant.mdx b/content/docs/administration/sharing-and-security.zh-Hant.mdx index 5097552cb..55b5e391a 100644 --- a/content/docs/administration/sharing-and-security.zh-Hant.mdx +++ b/content/docs/administration/sharing-and-security.zh-Hant.mdx @@ -29,9 +29,9 @@ OWD 是每個物件的 *最嚴格* 設定,其他一切都在此之上放寬。 | 聯絡人 | 由父層控制(客戶) | 聯絡人屬於它的客戶,並跟隨客戶的共用 —— 見下面的 *「由父層控制」在實務中* | | 商機 | 私有 | 敏感的交易資料 | | 商機明細 | 由父層控制(商機) | 產品明細是交易的一部分 | -| 報價 | 私有 | 定價屬於所有人 | +| 報價 | 由父層控制(客戶) | 報價是為某個客戶開出的,並跟隨客戶的共用 —— 見下面的 *「由父層控制」在實務中* | | 報價明細 | 由父層控制(報價) | 明細是報價的一部分 | -| 合約 | 私有 | 敏感的商務條款 | +| 合約 | 由父層控制(客戶) | 合約本身就是客戶資料,並跟隨客戶的共用 —— 見下面的 *「由父層控制」在實務中* | | 工單 | 私有 | 專員的佇列是自己的;升級件走共用 | | 任務 | 私有 | 個人活動項目 | | 活動 | 私有 | 個人活動項目,與任務同類 | @@ -47,9 +47,9 @@ OWD 是每個物件的 *最嚴格* 設定,其他一切都在此之上放寬。 ### 「由父層控制」在實務中 -對於上面六個由父層衍生的物件,沒有任何按使用者設定的東西:存取權限是 *計算* 出來的。 +對於上面八個由父層衍生的物件,沒有任何按使用者設定的東西:存取權限是 *計算* 出來的。 -- **讀** —— 你看到的是那些父記錄你能看到的列,而「能看到」指的是任何一條能讓你直接開啟該父記錄的途徑:你擁有它、某條共用規則授予了它,或者有人手動共用給了你。對隨產品發布的 stack 實測(平台 17.0.0-rc.4):一個只讀得到一個客戶的代表,只讀到 **那一個** 客戶的聯絡人;一個一張報價都讀不到的代表,一列報價明細也讀不到。那個僅透過區域規則到達該代表的客戶正是關鍵 —— 透過共用持有的父記錄,同樣算作「你能看到的父記錄」。 +- **讀** —— 你看到的是那些父記錄你能看到的列,而「能看到」指的是任何一條能讓你直接開啟該父記錄的途徑:你擁有它、某條共用規則授予了它,或者有人手動共用給了你。對隨產品發布的 stack 實測(平台 17.0.0-rc.4):一個只讀得到一個客戶的代表,只讀到 **那一個** 客戶的聯絡人、報價和合約,並且只讀到這些報價的明細 —— 報價明細 → 報價 → 客戶 這條鏈是逐級解析的(平台 17.4.0,objectstack-ai/objectstack#11082)。那個僅透過區域規則到達該代表的客戶正是關鍵 —— 透過共用持有的父記錄,同樣算作「你能看到的父記錄」。 - **寫** —— 新增、修改或移除一列需要對父記錄有 **編輯** 權限,其解析方式與讀取相同。父記錄你無權編輯的列會被拒絕,並在錯誤訊息中點名是哪個父記錄擋下了它。 > **這一行為在平台 17.0.0-rc.4 發生了改變。** 在 17.0.0-rc.3 及更早版本上,計算出來的結果是組織範圍而不是按父記錄收窄:衍生只從父物件自身的 *列層級安全政策* 去解析可存取的父記錄,而 HotCRM 幾乎沒有寫這類政策,因此只要權限設定檔授予了該物件,「由父層控制」物件對該使用者就是可讀、也可寫的。如果你執行的是更早的版本,你拿到的就是那個行為,報價與商機的明細定價在組織範圍內可見。本倉的測試 `test/parent-derived-reach.test.ts` 在兩個方向上都釘住了目前的可達範圍(#549、#694;上游修復 objectstack-ai/objectstack#5386)。 @@ -116,21 +116,21 @@ na_sales_team eu_sales_team (區域分組) ### 一條規則放開的是一個物件,而不是它底下的記錄 -共用規則是 **按物件** 撰寫的。放開 `Account` 只放開客戶本身 —— 掛在這個客戶底下的記錄仍各自守著自己的基線,其中只有聯絡人是從客戶衍生的: +共用規則是 **按物件** 撰寫的。放開 `Account` 只放開客戶本身 —— 掛在這個客戶底下的記錄仍各自守著自己的基線,從客戶衍生的三個除外(聯絡人、報價、合約): | 已共用客戶上的相關清單 | 接收者實際看到的 | | --- | --- | | 聯絡人 | 這個客戶的聯絡人 —— Contact 是「由父層控制」,而共用過來的客戶就算作他們能看到的父記錄 | | 商機 | 只有自己的交易;若持有 `sales_director` 或 `executive`,另加金額 ≥ $100,000 的未結交易 | -| 報價 | 只有自己的 | -| 合約 | 只有自己的 | +| 報價 | 這個客戶的報價 —— Quote 跟隨客戶,報價的明細也隨之而來 | +| 合約 | 這個客戶的合約 —— Contract 跟隨客戶;能不能*編輯*某一份,由 profile 上的物件級權限決定(Sales Rep 不能) | | 工單 | 只有自己的;若持有 `service_manager` 或 `service_director`,另加未關閉的嚴重工單 | | 任務 | 只有自己的 | | 活動 | 只有自己的 | -也就是說,區域規則交出的是客戶記錄;底下的交易、報價、合約、工單、任務與會議歷史仍歸各自的所有人,使用者明明能完整讀取這個客戶,這些相關清單卻可能是空的或殘缺的。聯絡人是例外,而且是符合本意的那種例外:它跟隨客戶,所以區域共用過來的客戶,其聯絡人清單是完整的。 +也就是說,區域規則交出的是客戶記錄,連同它的聯絡人、報價和合約;底下的交易、工單、任務與會議歷史仍歸各自的所有人,使用者明明能完整讀取這個客戶,這些相關清單卻可能是空的或殘缺的。 -讓子物件跟隨客戶是一次刻意的放開,而不是一個設定細節。在子物件上以與客戶規則相符的條件另寫一條共用規則,會對 **所有** 持有該物件的使用者放開可見性,而不只是區域規則的接收者。把子物件的 OWD 改成「由父層控制」則是更精準的那把刀:自平台 17.0.0-rc.4 起,它恰好把子記錄授予那些能夠到達其父記錄的人(見上面的 *「由父層控制」在實務中*)。對報價與合約,HotCRM 今天兩者都不做;是否應該做是一項待定決策(#549)。 +讓子物件跟隨客戶是一次刻意的放開,而不是一個設定細節。在子物件上以與客戶規則相符的條件另寫一條共用規則,會對 **所有** 持有該物件的使用者放開可見性,而不只是區域規則的接收者。把子物件的 OWD 改成「由父層控制」則是更精準的那把刀:它恰好把子記錄授予那些能夠到達其父記錄的人(見上面的 *「由父層控制」在實務中*)。這正是 HotCRM 對報價與合約的做法(#549):透過區域規則拿到客戶的代表能看到該客戶的報價,並且——因為持有該客戶的編輯權——可以編輯它們;合約則唯讀、永不可改。交易、工單、任務和活動刻意保持只有自己的——任務是個人工作項,而管道的問題推遲到有區域模型之後再議。 ## 第 4 層 —— 手動共用 @@ -207,6 +207,6 @@ Copilot 代表使用者行動 —— 它看到的與使用者看到的完全一 如果你認為自己應該能看到某條記錄卻看不到: 1. 查一下 **誰擁有它**。 -2. 如果你是從客戶點進去的,先看 **它是哪個物件**:即使客戶本身已經共用給你,報價、合約、任務、工單與交易仍歸各自的所有人。聯絡人是唯一從不會被扣下的相關清單 —— Contact 是「由父層控制」,它跟隨客戶,所以客戶共用給你時聯絡人也一併到達(見 *「由父層控制」在實務中*)。 +2. 如果你是從客戶點進去的,先看 **它是哪個物件**:即使客戶本身已經共用給你,任務、工單與交易仍歸各自的所有人。聯絡人、報價和合約是從不會被扣下的相關清單 —— 三者都是「由父層控制」,它們跟隨客戶,所以客戶共用給你時也一併到達(見 *「由父層控制」在實務中*)。 3. 查一下該交易是否被標記為 **Private**。 4. 詢問管理員是否應該有一條 **共用規則** 涵蓋你的職位。 diff --git a/content/docs/revenue/contracts.mdx b/content/docs/revenue/contracts.mdx index 07eeb3e98..a21743568 100644 --- a/content/docs/revenue/contracts.mdx +++ b/content/docs/revenue/contracts.mdx @@ -123,7 +123,7 @@ Two bounds worth knowing: the sweep looks 120 days ahead at most, so a notice wi ### Manual termination Terminating a contract is a status change to *Terminated*, and two things commonly assumed about that change do not hold. -**Being the owner is not what qualifies you to make it.** Edit on Contract is an object-level right that ownership does not extend: **Sales Manager** and **System Administrator** hold it, **Sales Rep** does not. That gap lands on the most common case, because the contract an accepted quote drafts carries the quote's owner across with it — so the rep whose deal it was ends up owning a contract they can read and cannot touch. Terminating it is someone else's action to take, and **who that someone is depends on your edition** — see *Who can edit which contract* under Sharing below. On the open edition it is a System Administrator. +**Being the owner is not what qualifies you to make it.** Edit on Contract is an object-level right that ownership does not extend: **Sales Manager** and **System Administrator** hold it, **Sales Rep** does not. That gap lands on the most common case, because the contract an accepted quote drafts carries the quote's owner across with it — so the rep whose deal it was ends up owning a contract they can read and cannot touch. Terminating it is someone else's action to take — a Sales Manager's, on any edition, since a manager can edit every account and therefore every contract; see *Who can edit which contract* under Sharing below. **And nothing holds the door shut afterwards.** *Terminated* is declared a dead end, but on the same **warning**-severity status machine as the rest of the progression: an edit that moves a terminated contract back to *Activated* is written to the server log and **saved anyway** — see *The status progression is advice, not a gate* below. Termination is one-way by convention, which is what the status means and how everything downstream reads it, not by enforcement. If it has to be enforced, an admin has to author that. @@ -142,29 +142,26 @@ Two more things are often read as rules and are not: - **Contract Value cannot be negative** — that is the field's own minimum of 0 rather than a validation rule, so NDAs at $0 are fine. **Contract Term (Months)** is bounded the same way, at a minimum of 1. - **The status progression is advice, not a gate.** The declared route is *Draft* → *In Approval* → *Activated* → *Expired* or *Terminated*, with *In Approval* able to go back to *Draft*, *Terminated* reachable from all three live statuses, and *Expired* and *Terminated* both dead ends. But it is declared at **warning** severity: an illegal jump — *Draft* straight to *Activated*, or reviving an *Expired* contract — is written to the server log and **the save still goes through**. Nothing in the form stops it and nobody is notified. A contract *created* directly as *Activated* is not checked at all, which is the same import path that skips the activation writes described above. -And one rule people expect that is not there: **nothing stops an activated contract from being deleted.** Contract has no delete hook and no status check anywhere — who may delete a contract is decided by their profile alone, and the profile never looks at the status. A **System Administrator** can delete any contract, activated ones included; **Sales Manager** and **Sales Rep** can delete none, not even a draft. If signed agreements must be undeletable in your org, an admin has to author that — the app does not do it for you. +And one rule people expect that is only half there: **nothing stops an activated contract from being deleted directly.** Contract has no delete hook and no status check of its own — who may delete a contract is decided by their profile alone, and the profile never looks at the status. A **System Administrator** can delete any contract, activated ones included; **Sales Manager** and **Sales Rep** can delete none, not even a draft. What *is* refused is deleting the **account** out from under its contracts. Contracts belong to their account (master-detail, so an account delete would cascade them), and the account guard refuses to delete a customer account while an *Activated* contract still references it — *"Cannot delete customer account: 1 activated contract still references it. Terminate or reassign it first."* An account carrying only drafts or ended contracts is refused too, by the platform rather than the guard: the delete cascades the account's contacts first and stops on the contract's required **Primary Contact** (*"This Contact is still referenced by 1 Contract record(s) through Primary Contact, which is required and cannot be cleared"*). In practice an account with contracts cannot be deleted until its contracts are gone. If signed agreements must be undeletable outright in your org, an admin has to author that — the app does not do it for you. ## Sharing -Contracts are **private**, and no sharing rule widens them. Contract visibility does **not** follow the account: +Contracts are **Controlled by Parent** under the account: you see the contracts of every account you can see, by whichever route the account reached you — you own it, a territory or team sharing rule granted it, or someone shared it with you by hand. No sharing rule is authored on Contract itself, and none is needed; the account's rules carry its contracts (see [Sharing & Security](/docs/administration/sharing-and-security), *Controlled by Parent, in practice*). A rep who reached the account through a territory rule therefore sees its *Contracts* related list complete, including the renewals — the gap that #549 reported. -- **The contract owner** reads their own contracts — and reading is the whole of what ownership buys here. Whether you may edit one at all is settled a layer earlier, by the object-level rights on your profile: **Sales Rep** holds Contract read-only, so a rep reads the contract standing in their own name and cannot change it. Record-level sharing widens *which records* you reach, never *which operations* you hold, so no sharing rule and no record share turns that read into an edit. -- **Sales Manager** and **System Administrator** read every contract (*View All* on the object), and they are the only profiles holding edit on it at all. Holding edit is not the same as reaching a given record, though — see immediately below. +Reading is one door; editing is two: -### Who can edit which contract +- **Object level first.** Edit on Contract is a right on your profile that record visibility never extends: **Sales Manager** and **System Administrator** hold it, **Sales Rep** does not. A rep reads every contract on the accounts they can see — the one drafted from their own accepted quote included — and cannot change any of them. +- **Then the account.** Changing, adding or removing a contract requires **edit** access to its account, resolved the same way as reads. Ownership of the contract row itself is irrelevant. -Reading every contract and being able to *change* one are two different grants, because Contract is private and owned. The object-level edit right gets you through the first door; the second asks whether the record's owner is inside your **write depth**. The three profiles sit differently: +### Who can edit which contract | Profile | Can edit… | Why | | --- | --- | --- | -| **System Administrator** | any contract | *Modify All* — the super-user bypass, which skips ownership entirely | -| **Sales Manager** | their own contracts always; **their reports' contracts on the Enterprise edition** | the profile declares a write depth of *own and reports*, which needs the hierarchy resolver that ships with ObjectStack Enterprise | +| **System Administrator** | any contract | *Modify All* on Contract — the super-user bypass | +| **Sales Manager** | any contract | holds *Modify All* on **Account**, so every account — and every contract under it — is editable; deleting a contract stays refused (no delete right on Contract) | | **Sales Rep** | none | no object-level edit right at all — the first door is shut | -That middle row is the one to read carefully, because it is the common case: the contract an accepted quote drafts belongs to the **rep**, not the manager. HotCRM declares the depth that says "a manager reaches the people who report to them", and the **Enterprise** edition supplies the machinery that resolves it. What it resolves against is the **reporting relationship your deployment maintains** — not HotCRM's positions, which are flat by design and roll nothing up. On the **open** edition there is no resolver, so that depth safely narrows to owner-only and a manager cannot change a contract standing in a rep's name — on that edition, those changes are a **System Administrator** job. Nothing is misconfigured when you see that; it is the edition boundary. -- **Everyone else** — including a rep who reached the account through a territory or team sharing rule — sees only the contracts they own, so an account's *Contracts* related list can look empty even when the account itself is fully readable. - -If your org wants contracts to follow the account, an admin has to author that, and neither available route actually delivers "follows the account" today: a sharing rule on Contract widens the records it matches for every holder of the object, and a Controlled-by-Parent OWD derives org-wide in this release — the parent link is not consulted per caller. Both are a broad widening, not an account-scoped one — see [Sharing & Security](/docs/administration/sharing-and-security), *Controlled by Parent, in practice*. +That middle row is the common case, and it holds on every edition: the contract an accepted quote drafts belongs to the **rep**, and the manager changes it because they can edit its account, not because of any reporting relationship. (Earlier releases declared a hierarchy write depth here that only the Enterprise edition could resolve, so on the open edition a manager reached only their own contracts; that boundary is gone with the parent-derived model.) ## Standard list views @@ -189,7 +186,7 @@ None of the four filters anything, so **every contract you can see appears in al - ✅ **You never fill a contract in — accepting the quote drafts it.** Contract is read-only for a Sales Rep: you cannot create one and you cannot edit one. What you do own is the quote, and marking it **Accepted** is what drafts the contract — a *Draft* contract carrying the quote's account, primary contact, related opportunity, owner and total, on a 12-month term starting today, typed *Subscription*. - ✅ **The opportunity link is copied off the quote, not pre-filled on a form you are typing into** — so there is nothing on it for you to keep or lose. Nor does the manual route pre-fill it: when a manager or an admin creates a contract by hand, the opportunity is picked by hand, and the only help the picker gives is that it lists the deals of the account already chosen on the contract. Nothing reads that link for reporting today — HotCRM ships no contract report or dashboard at all. -- ✅ **You read your own contracts and ask for the rest.** The contract your accepted quote drafted lands in your name, so you can read it; contracts owned by anyone else you cannot see. Changing a term, a date, the value or the status is somebody else's job, and so is an amendment — that is a new contract, which only they can create. **Who to ask depends on your edition**: on **Enterprise**, your Sales Manager can change the contract standing in your name without it being handed over first; on the **open** edition, a manager only reaches their own contracts, so it is a **System Administrator** who makes the change. If you are not sure which you are on, ask for the manager first — see *Who can edit which contract* above. +- ✅ **You read the contracts of your accounts and ask for the rest.** Contracts follow the account: every account you can see — your own, and the ones a territory or team rule shares with you — shows you its contracts, renewals included. Changing a term, a date, the value or the status is somebody else's job, and so is an amendment — that is a new contract, which only they can create. Ask your **Sales Manager**: a manager can change the contract standing in your name without it being handed over first, on any edition — see *Who can edit which contract* above. ## Tips for admins diff --git a/content/docs/revenue/contracts.zh-Hans.mdx b/content/docs/revenue/contracts.zh-Hans.mdx index 88b2d0839..551d6d270 100644 --- a/content/docs/revenue/contracts.zh-Hans.mdx +++ b/content/docs/revenue/contracts.zh-Hans.mdx @@ -123,7 +123,7 @@ description: 与客户签署的协议——条款、价值、续约,以及自 ### 手动终止 终止一份合同,就是把状态改成 *Terminated*。关于这个动作,有两件常被默认成立的事,其实都不成立。 -**能不能改,取决于 profile,不取决于所有权。** 合同的编辑权是对象级的权限,不会因为你是所有者就延伸过来:**Sales Manager** 与 **System Administrator** 持有它,**Sales Rep** 没有。而这道缺口恰好落在最常见的那种情形上——报价被接受后起草出来的那份合同,把报价的负责人一并抄了过来,所以做成这笔生意的销售代表,最后拿到的是一份自己读得到、却动不了的合同。终止它是别人的动作,而**这个「别人」是谁取决于你用的版本**——见下方「共享」一节的《谁能改哪一份合同》。在开源版上,那个人是 System Administrator。 +**能不能改,取决于 profile,不取决于所有权。** 合同的编辑权是对象级的权限,不会因为你是所有者就延伸过来:**Sales Manager** 与 **System Administrator** 持有它,**Sales Rep** 没有。而这道缺口恰好落在最常见的那种情形上——报价被接受后起草出来的那份合同,把报价的负责人一并抄了过来,所以做成这笔生意的销售代表,最后拿到的是一份自己读得到、却动不了的合同。终止它是别人的动作——在任何版本上都是 Sales Manager 的动作,因为经理能编辑每一个客户,也就能编辑每一份合同;见下方「共享」一节的《谁能改哪一份合同》。 **终止之后,也没有任何东西把门插上。** *Terminated* 确实被声明成终点,但它和整条状态推进一样是**警告**级:把一份已终止的合同改回 *Activated*,只会写进服务端日志,**保存照样通过**——见下文*状态推进是建议,不是闸门*。所以「一次性动作」是约定俗成——是这个状态的含义,也是下游所有人对它的读法——而不是强制。真要强制,得由管理员自己配置。 @@ -142,29 +142,26 @@ description: 与客户签署的协议——条款、价值、续约,以及自 - **合同金额不能为负**——那是字段自己的最小值 0,不是校验规则,所以 $0 的保密协议照样能存。**合同期限(月)**同理,最小值是 1。 - **状态推进是建议,不是闸门。** 声明的路线是「草稿」→「审批中」→「已激活」→「已过期」或「已终止」;「审批中」可以退回「草稿」,三种在途状态都能直接走到「已终止」,而「已过期」与「已终止」都是终点。但它是以**警告**级别声明的:非法的跳转——「草稿」直接跳「已激活」,或者把「已过期」的合同救活——只会写进服务端日志,**保存照样通过**。表单上没有任何东西拦住它,也不会通知任何人。而**新建**时就直接是「已激活」的合同根本不受检查,正是上文那条会跳过激活写入的导入路径。 -还有一条读者以为存在、其实没有的规则:**没有任何东西阻止删除一份已激活的合同。** 合同对象上既没有删除 hook,也没有任何按状态的检查——谁能删合同完全由他的 profile 决定,而 profile 从不看状态。**System Administrator** 能删任何一份合同,包括已激活的;**Sales Manager** 和 **Sales Rep** 一份都删不了,连草稿也删不了。如果你的组织要求已签署的协议不可删除,得由管理员自己配置——应用本身不替你做这件事。 +还有一条读者以为存在、其实只有一半的规则:**没有任何东西阻止直接删除一份已激活的合同。** 合同对象上既没有删除 hook,也没有任何按状态的检查——谁能删合同完全由他的 profile 决定,而 profile 从不看状态。**System Administrator** 能删任何一份合同,包括已激活的;**Sales Manager** 和 **Sales Rep** 一份都删不了,连草稿也删不了。*会*被拒绝的是把合同底下的 **客户** 删掉。合同属于它的客户(主从关系,删除客户本会级联删除它的合同),而客户守卫在仍有 *已激活* 合同引用某个客户类账户时拒绝删除——*“Cannot delete customer account: 1 activated contract still references it. Terminate or reassign it first.”* 只带着草稿或已结束合同的客户同样删不掉,只是拒绝的是平台而不是守卫:删除会先级联客户的联系人,并停在合同上必填的 **主要联系人** 字段(*“This Contact is still referenced by 1 Contract record(s) through Primary Contact, which is required and cannot be cleared”*)。实际效果是:带合同的客户,在合同清空之前无法删除。如果你的组织要求已签署的协议彻底不可删除,得由管理员自己配置——应用本身不替你做这件事。 ## 共享 -合同是 **私有** 的,也没有任何共享规则放开它。合同的可见性 **不** 跟随账户: +合同是客户之下的 **“由父级控制”**:你能看到的每一个客户,它的合同你都能看到,无论这个客户是通过哪条途径到达你的——你拥有它、某条区域或团队共享规则授予了它,或者有人手工共享给了你。Contract 本身没有任何共享规则,也不需要:客户的规则把它的合同一并带来(参见[共享与安全](/zh-Hans/docs/administration/sharing-and-security),*“由父级控制”在实践中*)。因此通过区域规则拿到客户的代表,看到的 *Contracts* 相关列表是完整的,续约也在其中——这正是 #549 报告的那道缺口。 -- **合同所有者** 读取自己的合同——而所有权买到的就只有「读」。能不能编辑,是更前面一层决定的,由你 profile 上的对象级权限说了算:**Sales Rep** 对合同只有只读权,所以挂在销售代表自己名下的合同,他读得到、却改不了。记录级共享放宽的是你能触及*哪些记录*,从来不是你握有*哪些操作*,所以任何共享规则、任何记录共享,都不会把这个「读」变成「编辑」。 -- **Sales Manager** 与 **System Administrator** 能读取全部合同(对该对象持有 *查看所有*),也是仅有的两个对合同持有编辑权的 profile。不过「持有编辑权」和「够得到某一份记录」是两回事——见紧接着的下一节。 +读是一道门;改是两道: -### 谁能改哪一份合同 +- **先看对象级。** 合同的编辑权是 profile 上的权限,记录可见性从不会把它延伸过来:**Sales Manager** 与 **System Administrator** 持有它,**Sales Rep** 没有。代表读得到自己能看到的客户下的每一份合同——包括从自己被接受的报价起草出来的那份——但一份都改不了。 +- **再看客户。** 修改、新增或移除一份合同,需要对它的客户有 **编辑** 权限,解析方式与读取相同。合同这条记录本身归谁无关紧要。 -「能读到每一份合同」和「能改动其中某一份」是两道不同的授权,因为合同是私有且有所有者的对象。对象级的编辑权让你过第一道门;第二道门问的是:这条记录的所有者,在不在你的**写深度**之内。三个 profile 的处境并不相同: +### 谁能改哪一份合同 | Profile | 能改的合同 | 原因 | | --- | --- | --- | -| **System Administrator** | 任何一份 | *修改所有*——这是超级用户旁路,直接跳过所有权 | -| **Sales Manager** | 自己名下的始终能改;**向其汇报的人名下的,在企业版上能改** | profile 声明的写深度是「本人及向其汇报的人」,它需要 ObjectStack 企业版随附的解析器 | +| **System Administrator** | 任何一份 | 对合同持有 *修改所有*——超级用户旁路 | +| **Sales Manager** | 任何一份 | 对 **客户** 持有 *修改所有*,因此每一个客户——连同它下面的每一份合同——都可编辑;删除合同仍被拒绝(对合同没有删除权) | | **Sales Rep** | 一份都不能 | 根本没有对象级编辑权——第一道门就是关着的 | -中间那一行要仔细读,因为它正是最常见的情形:报价被接受后起草出来的那份合同属于**销售代表**,而不是经理。HotCRM 声明的正是「经理够得到向其汇报的人」这个深度,而**企业版**提供把它对着你的汇报关系解析开来的那套机制。这里解析的是部署自己维护的**汇报关系**,不是 HotCRM 的岗位——岗位是扁平的,没有任何东西顺着它上卷(ADR-0090 D3)。在**开源版**上没有解析器,于是这个深度安全地收窄为「仅本人名下」,经理便改不了挂在代表名下的合同——在那个版本上,这类改动是 **System Administrator** 的事。看到这个现象并不意味着哪里配错了,这是版本边界。 -- **其他所有人** —— 包括通过区域或团队共享规则拿到该账户的销售代表 —— 只能看到自己拥有的合同。因此哪怕账户本身完全可读,账户上的 *Contracts* 相关列表也可能是空的。 - -如果你的组织希望合同跟随账户,需要管理员显式配置——而今天可走的两条路,没有一条真的交付了「跟随账户」:在 Contract 上新增共享规则,放开的是这条规则匹配到的那些记录,而且是对该对象的每一个持有者放开;把它的 OWD 改成“由父级控制”,在本版本中派生出来的是组织范围——父级链接并不会按调用者去查。两种做法都是一次宽口径的放开,而不是一次按账户收窄的放开——参见[共享与安全](/zh-Hans/docs/administration/sharing-and-security),*“由父级控制”在实践中*。 +中间那一行正是最常见的情形,而且在任何版本上都成立:报价被接受后起草出来的那份合同属于**销售代表**,经理能改它,是因为经理能编辑它的客户,而不是因为任何汇报关系。(早先的版本在这里声明过一个只有企业版才能解析的层级写深度,于是开源版上的经理只够得到自己名下的合同;改为由父级派生之后,这条版本边界已不存在。) ## 标准列表视图 @@ -189,7 +186,7 @@ description: 与客户签署的协议——条款、价值、续约,以及自 - ✅ **合同不是你填出来的——接受报价才是起草它的动作。** 对 Sales Rep 来说合同是只读的:你既建不了也改不了。你真正握在手里的是报价,把它标记为**已接受**,系统就会替你起草合同——一份「草稿」状态的合同,带着报价上的所属客户、主要联系人、关联商机、负责人和总价,期限 12 个月、从今天起算,合同类型为「订阅合同」。 - ✅ **商机链接是从报价上抄过来的,不是你在表单上被预填了一格**——所以没有什么"保留它"可言。人工那条路也不预填:当 Sales Manager 或管理员手工建合同时,商机是一个个挑出来的,选择器唯一帮上忙的地方是它只列出合同上已选定客户名下的商机。今天也没有任何东西为了报表去读这个链接——HotCRM 根本没有随附任何合同报表或仪表盘。 -- ✅ **自己的合同你读,别的事去找人。** 你的报价接受后起草出来的那份合同挂在你名下,所以你读得到;别人名下的合同你看不见。改期限、改日期、改金额、改状态都是别人的事,修订也一样——修订是一份新合同,只有他们能建。**该找谁取决于你用的版本**:在**企业版**上,你的 Sales Manager 不需要先把合同转移过去就能改挂在你名下的那一份;在**开源版**上,经理只够得到自己名下的合同,于是这件事由 **System Administrator** 来做。拿不准自己在哪个版本,就先找经理——见上文《谁能改哪一份合同》。 +- ✅ **你客户的合同你读,别的事去找人。** 合同跟随客户:你能看到的每一个客户——自己的,以及区域或团队规则共享给你的——都会把它的合同展示给你,续约也在其中。改期限、改日期、改金额、改状态都是别人的事,修订也一样——修订是一份新合同,只有他们能建。去找你的 **Sales Manager**:在任何版本上,经理都不需要先把合同转移过去就能改挂在你名下的那一份——见上文《谁能改哪一份合同》。 ## 给管理员的提示 diff --git a/content/docs/revenue/contracts.zh-Hant.mdx b/content/docs/revenue/contracts.zh-Hant.mdx index 989b2e04e..15401d6b0 100644 --- a/content/docs/revenue/contracts.zh-Hant.mdx +++ b/content/docs/revenue/contracts.zh-Hant.mdx @@ -125,7 +125,7 @@ description: 與客戶簽署的協議——條款、價值、續約,以及自 ### 手動終止 終止一份合約,就是把狀態改成 *Terminated*。關於這個動作,有兩件常被預設成立的事,其實都不成立。 -**能不能改,取決於 profile,不取決於所有權。** 合約的編輯權是物件級的權限,不會因為你是所有者就延伸過來:**Sales Manager** 與 **System Administrator** 持有它,**Sales Rep** 沒有。而這道缺口恰好落在最常見的那種情形上——報價被接受後起草出來的那份合約,把報價的負責人一併抄了過來,所以做成這筆生意的銷售代表,最後拿到的是一份自己讀得到、卻動不了的合約。終止它是別人的動作,而**這個「別人」是誰取決於你用的版本**——見下方「共享」一節的《誰能改哪一份合約》。在開源版上,那個人是 System Administrator。 +**能不能改,取決於 profile,不取決於所有權。** 合約的編輯權是物件級的權限,不會因為你是所有者就延伸過來:**Sales Manager** 與 **System Administrator** 持有它,**Sales Rep** 沒有。而這道缺口恰好落在最常見的那種情形上——報價被接受後起草出來的那份合約,把報價的負責人一併抄了過來,所以做成這筆生意的銷售代表,最後拿到的是一份自己讀得到、卻動不了的合約。終止它是別人的動作——在任何版本上都是 Sales Manager 的動作,因為經理能編輯每一個客戶,也就能編輯每一份合約;見下方「共享」一節的《誰能改哪一份合約》。 **終止之後,也沒有任何東西把門插上。** *Terminated* 確實被宣告成終點,但它和整條狀態推進一樣是**警告**級:把一份已終止的合約改回 *Activated*,只會寫進伺服器日誌,**儲存照樣通過**——見下文*狀態推進是建議,不是閘門*。所以「一次性動作」是約定俗成——是這個狀態的含義,也是下游所有人對它的讀法——而不是強制。真要強制,得由管理員自己設定。 @@ -144,29 +144,26 @@ description: 與客戶簽署的協議——條款、價值、續約,以及自 - **合約金額不能為負**——那是欄位自己的最小值 0,不是驗證規則,所以 $0 的保密協議照樣能存。**合約期限(月)**同理,最小值是 1。 - **狀態推進是建議,不是閘門。** 宣告的路線是「草稿」→「審批中」→「已啟用」→「已過期」或「已終止」;「審批中」可以退回「草稿」,三種在途狀態都能直接走到「已終止」,而「已過期」與「已終止」都是終點。但它是以**警告**級別宣告的:非法的跳轉——「草稿」直接跳「已啟用」,或者把「已過期」的合約救活——只會寫進伺服器日誌,**儲存照樣通過**。表單上沒有任何東西攔住它,也不會通知任何人。而**新建**時就直接是「已啟用」的合約根本不受檢查,正是上文那條會跳過啟用寫入的匯入路徑。 -還有一條讀者以為存在、其實沒有的規則:**沒有任何東西阻止刪除一份已啟用的合約。** 合約物件上既沒有刪除 hook,也沒有任何按狀態的檢查——誰能刪合約完全由他的 profile 決定,而 profile 從不看狀態。**System Administrator** 能刪任何一份合約,包括已啟用的;**Sales Manager** 和 **Sales Rep** 一份都刪不了,連草稿也刪不了。如果你的組織要求已簽署的協議不可刪除,得由管理員自己設定——應用本身不替你做這件事。 +還有一條讀者以為存在、其實只有一半的規則:**沒有任何東西阻止直接刪除一份已啟用的合約。** 合約物件上既沒有刪除 hook,也沒有任何按狀態的檢查——誰能刪合約完全由他的 profile 決定,而 profile 從不看狀態。**System Administrator** 能刪任何一份合約,包括已啟用的;**Sales Manager** 和 **Sales Rep** 一份都刪不了,連草稿也刪不了。*會*被拒絕的是把合約底下的 **客戶** 刪掉。合約屬於它的客戶(主從關係,刪除客戶本會連帶刪除它的合約),而客戶守衛在仍有 *已啟用* 合約引用某個客戶類帳戶時拒絕刪除——*「Cannot delete customer account: 1 activated contract still references it. Terminate or reassign it first.」* 只帶著草稿或已結束合約的客戶同樣刪不掉,只是拒絕的是平台而不是守衛:刪除會先連帶客戶的聯絡人,並停在合約上必填的 **主要聯絡人** 欄位(*「This Contact is still referenced by 1 Contract record(s) through Primary Contact, which is required and cannot be cleared」*)。實際效果是:帶合約的客戶,在合約清空之前無法刪除。如果你的組織要求已簽署的協議徹底不可刪除,得由管理員自己設定——應用本身不替你做這件事。 ## 共享 -合約是 **私有** 的,也沒有任何共用規則放開它。合約的可見性 **不** 跟隨帳戶: +合約是客戶之下的 **「由父層控制」**:你能看到的每一個客戶,它的合約你都能看到,無論這個客戶是透過哪條途徑到達你的——你擁有它、某條區域或團隊共用規則授予了它,或者有人手動共用給了你。Contract 本身沒有任何共用規則,也不需要:客戶的規則把它的合約一併帶來(參見[共用與安全](/zh-Hant/docs/administration/sharing-and-security),*「由父層控制」在實務中*)。因此透過區域規則拿到客戶的代表,看到的 *Contracts* 相關清單是完整的,續約也在其中——這正是 #549 回報的那道缺口。 -- **合約所有者** 讀取自己的合約——而所有權買到的就只有「讀」。能不能編輯,是更前面一層決定的,由你 profile 上的物件級權限說了算:**Sales Rep** 對合約只有唯讀權,所以掛在銷售代表自己名下的合約,他讀得到、卻改不了。記錄級共用放寬的是你能觸及*哪些記錄*,從來不是你握有*哪些操作*,所以任何共用規則、任何記錄共用,都不會把這個「讀」變成「編輯」。 -- **Sales Manager** 與 **System Administrator** 能讀取全部合約(對該物件持有 *檢視所有*),也是僅有的兩個對合約持有編輯權的 profile。不過「持有編輯權」和「構得到某一份記錄」是兩回事——見緊接著的下一節。 +讀是一道門;改是兩道: -### 誰能改哪一份合約 +- **先看物件級。** 合約的編輯權是 profile 上的權限,記錄可見性從不會把它延伸過來:**Sales Manager** 與 **System Administrator** 持有它,**Sales Rep** 沒有。代表讀得到自己能看到的客戶底下的每一份合約——包括從自己被接受的報價起草出來的那份——但一份都改不了。 +- **再看客戶。** 修改、新增或移除一份合約,需要對它的客戶有 **編輯** 權限,解析方式與讀取相同。合約這條記錄本身歸誰無關緊要。 -「能讀到每一份合約」和「能改動其中某一份」是兩道不同的授權,因為合約是私有且有所有者的物件。物件級的編輯權讓你過第一道門;第二道門問的是:這條記錄的所有者,在不在你的**寫深度**之內。三個 profile 的處境並不相同: +### 誰能改哪一份合約 | Profile | 能改的合約 | 原因 | | --- | --- | --- | -| **System Administrator** | 任何一份 | *修改所有*——這是超級使用者旁路,直接跳過所有權 | -| **Sales Manager** | 自己名下的始終能改;**向其匯報的人名下的,在企業版上能改** | profile 宣告的寫深度是「本人及向其匯報的人」,它需要 ObjectStack 企業版隨附的解析器 | +| **System Administrator** | 任何一份 | 對合約持有 *修改所有*——超級使用者旁路 | +| **Sales Manager** | 任何一份 | 對 **客戶** 持有 *修改所有*,因此每一個客戶——連同它底下的每一份合約——都可編輯;刪除合約仍被拒絕(對合約沒有刪除權) | | **Sales Rep** | 一份都不能 | 根本沒有物件級編輯權——第一道門就是關著的 | -中間那一行要仔細讀,因為它正是最常見的情形:報價被接受後起草出來的那份合約屬於**銷售代表**,而不是經理。HotCRM 宣告的正是「經理構得到向其匯報的人」這個深度,而**企業版**提供把它對著你的匯報關係解析開來的那套機制。這裡解析的是部署自己維護的**匯報關係**,不是 HotCRM 的職位——職位是扁平的,沒有任何東西順著它上捲(ADR-0090 D3)。在**開源版**上沒有解析器,於是這個深度安全地收窄為「僅本人名下」,經理便改不了掛在代表名下的合約——在那個版本上,這類改動是 **System Administrator** 的事。看到這個現象並不意味著哪裡設定錯了,這是版本邊界。 -- **其他所有人** —— 包括透過區域或團隊共用規則拿到該帳戶的銷售代表 —— 只能看到自己擁有的合約。因此即使帳戶本身完全可讀,帳戶上的 *Contracts* 相關清單也可能是空的。 - -如果你的組織希望合約跟隨帳戶,需要管理員明確設定——而今天可走的兩條路,沒有一條真的交付了「跟隨帳戶」:在 Contract 上新增共用規則,放開的是這條規則匹配到的那些記錄,而且是對該物件的每一個持有者放開;把它的 OWD 改成「由父層控制」,在本版本中衍生出來的是組織範圍——父層連結並不會按呼叫者去查。兩種做法都是一次寬口徑的放開,而不是一次按帳戶收窄的放開——參見[共用與安全](/zh-Hant/docs/administration/sharing-and-security),*「由父層控制」在實務中*。 +中間那一行正是最常見的情形,而且在任何版本上都成立:報價被接受後起草出來的那份合約屬於**銷售代表**,經理能改它,是因為經理能編輯它的客戶,而不是因為任何匯報關係。(早先的版本在這裡宣告過一個只有企業版才能解析的層級寫深度,於是開源版上的經理只構得到自己名下的合約;改為由父層衍生之後,這條版本邊界已不存在。) ## 標準列表檢視 @@ -191,7 +188,7 @@ description: 與客戶簽署的協議——條款、價值、續約,以及自 - ✅ **合約不是你填出來的——接受報價才是起草它的動作。** 對 Sales Rep 來說合約是唯讀的:你既建不了也改不了。你真正握在手裡的是報價,把它標記為**已接受**,系統就會替你起草合約——一份「草稿」狀態的合約,帶著報價上的所屬客戶、主要聯絡人、關聯商機、負責人和總價,期限 12 個月、從今天起算,合約類型為「訂閱合約」。 - ✅ **商機連結是從報價上抄過來的,不是你在表單上被預填了一格**——所以沒有什麼"保留它"可言。人工那條路也不預填:當 Sales Manager 或管理員手工建合約時,商機是一個個挑出來的,選擇器唯一幫上忙的地方是它只列出合約上已選定客戶名下的商機。今天也沒有任何東西為了報表去讀這個連結——HotCRM 根本沒有隨附任何合約報表或儀表板。 -- ✅ **自己的合約你讀,別的事去找人。** 你的報價接受後起草出來的那份合約掛在你名下,所以你讀得到;別人名下的合約你看不見。改期限、改日期、改金額、改狀態都是別人的事,修訂也一樣——修訂是一份新合約,只有他們能建。**該找誰取決於你用的版本**:在**企業版**上,你的 Sales Manager 不需要先把合約轉移過去就能改掛在你名下的那一份;在**開源版**上,經理只構得到自己名下的合約,於是這件事由 **System Administrator** 來做。拿不準自己在哪個版本,就先找經理——見上文《誰能改哪一份合約》。 +- ✅ **你客戶的合約你讀,別的事去找人。** 合約跟隨客戶:你能看到的每一個客戶——自己的,以及區域或團隊規則共用給你的——都會把它的合約展示給你,續約也在其中。改期限、改日期、改金額、改狀態都是別人的事,修訂也一樣——修訂是一份新合約,只有他們能建。去找你的 **Sales Manager**:在任何版本上,經理都不需要先把合約轉移過去就能改掛在你名下的那一份——見上文《誰能改哪一份合約》。 ## 給管理員的提示 diff --git a/objectstack.config.ts b/objectstack.config.ts index dc0e56712..0e531468e 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -133,13 +133,16 @@ export default defineStack({ // surface. To run AI locally, declare `@objectstack/service-ai` (cloud) in // package.json — its mere presence best-effort auto-loads it. // `hierarchy-security` is the ONE enterprise-edition capability this app - // declares (#880). `sales_manager` authors `writeScope: 'own_and_reports'` on - // `crm_contract`, an ADR-0057 HIERARCHY scope resolved by the - // `hierarchy-scope-resolver` service that ships only in - // `@objectstack/security-enterprise`. Declaring the capability is REQUIRED to - // author that scope at all — `defineStack` refuses the grant outright without - // it — and the pair is one declaration: move them together or not at all. + // declares. It was introduced for #880 — `sales_manager` authored + // `writeScope: 'own_and_reports'` on `crm_contract`, an ADR-0057 HIERARCHY + // scope resolved by the `hierarchy-scope-resolver` service that ships only + // in `@objectstack/security-enterprise`, and `defineStack` refuses such a + // grant unless the capability is declared. Since #549 `crm_contract` is + // `controlled_by_parent` under the account, that scope is inert and is no + // longer authored; the app currently authors NO hierarchy scope at all. // + // The declaration STAYS. Maintainer ruling 2026-08-31 (#1378, pinned by + // `test/hierarchy-read-depth.test.ts`): nothing is removed from `requires[]`. // Maintainer ruling, 2026-08-11, verbatim: 「本项目是元数据app,在企业版运行就 // 具备企业版相关的能力,不重复开发。」 The app states what it MEANS and the // edition supplies the capability, rather than approximating it with a broader @@ -154,12 +157,7 @@ export default defineStack({ // enterprise plugin in `plugins[]`. Only tier-gated tokens (ai / ai-studio / // i18n / ui / auth) have the dedicated hard-abort blocks the note above // describes. `objectstack validate` does print one informational line naming - // the package to install; that is expected output, asserted by - // `test/contract-write-depth.test.ts`, not a defect to silence. - // - // What an OPEN-edition boot gets: the resolver is absent, so the scope fails - // CLOSED to owner-only and a Sales Manager still cannot edit a rep's contract. - // That is an edition boundary, and the docs say so per edition. + // the package to install; that is expected output, not a defect to silence. requires: ['automation', 'triggers', 'analytics', 'auth', 'ui', 'approvals', 'sharing', 'hierarchy-security'], objects: allObjects, diff --git a/src/revenue/objects/contract.object.ts b/src/revenue/objects/contract.object.ts index 33edc7672..5bb56fc74 100644 --- a/src/revenue/objects/contract.object.ts +++ b/src/revenue/objects/contract.object.ts @@ -15,8 +15,10 @@ export const Contract = ObjectSchema.create({ icon: 'file-pen-line', description: 'Legal contracts and agreements', - // ADR-0090 D1/D7: OWD is an authored decision. Legal documents — owner only. - sharingModel: 'private', + // ADR-0090 D1/D7: OWD is an authored decision. Master-detail child of + // crm_account — a contract IS account data: reachable by whoever can reach + // the account, editable by whoever can edit it (#549, ruling 2026-08-31). + sharingModel: 'controlled_by_parent', // ADR-0079: render-only `titleFormat` retired in favor of `nameField`. // Original template was '{contract_number} - {crm_account.name}'. The // `crm_account.name` segment is a lookup DOT-WALK, which a formula field @@ -52,12 +54,15 @@ export const Contract = ObjectSchema.create({ format: 'CTR-{0000}', }), - // Relationships - crm_account: Field.lookup('crm_account', { + // Relationships — the master the OWD derives from. Authored as + // master-detail so the master is not decided by declaration order against + // the required `crm_contact` lookup below (objectstack#14747). + crm_account: Field.masterDetail('crm_account', { label: 'Account', group: 'parties', required: true, storage: { notNull: true }, + deleteBehavior: 'cascade', }), crm_contact: Field.lookup('crm_contact', { diff --git a/src/revenue/objects/quote.object.ts b/src/revenue/objects/quote.object.ts index 33b77d628..f031067e8 100644 --- a/src/revenue/objects/quote.object.ts +++ b/src/revenue/objects/quote.object.ts @@ -16,8 +16,10 @@ export const Quote = ObjectSchema.create({ icon: 'file-text', description: 'Price quotes for customers', - // ADR-0090 D1/D7: OWD is an authored decision. Owner only. - sharingModel: 'private', + // ADR-0090 D1/D7: OWD is an authored decision. Master-detail child of + // crm_account — a quote is reachable by whoever can reach its account, and + // its line items derive one level further (#549, ruling 2026-08-31). + sharingModel: 'controlled_by_parent', // ADR-0079: render-only `titleFormat` retired in favor of `nameField`, // which names a real field. The former template composed two local fields, so // a `display_title` formula field reproduces it for the record title. @@ -71,12 +73,13 @@ export const Quote = ObjectSchema.create({ expression: F`record.quote_number + " - " + record.name`, }), - // Relationships - crm_account: Field.lookup('crm_account', { + // Relationships — the master the OWD derives from (authored, not positional). + crm_account: Field.masterDetail('crm_account', { label: 'Account', group: 'basic', required: true, storage: { notNull: true }, + deleteBehavior: 'cascade', }), crm_contact: Field.lookup('crm_contact', { diff --git a/src/sales/objects/account.hook.ts b/src/sales/objects/account.hook.ts index a782d07d3..9f89ce7f3 100644 --- a/src/sales/objects/account.hook.ts +++ b/src/sales/objects/account.hook.ts @@ -12,7 +12,8 @@ import type { HookApi } from './_hook-api'; * rules filter on (#639). * - Folds `name` into the `name_normalized` column lead conversion matches * accounts on (#626). - * - Refuses to delete a `customer` account that still has open opportunities. + * - Refuses to delete a `customer` account that still has open opportunities + * or activated contracts — contracts cascade with the account since #549. */ const accountHook: Hook = { name: 'account_protection', @@ -20,7 +21,7 @@ const accountHook: Hook = { events: ['beforeInsert', 'beforeUpdate', 'beforeDelete'], priority: 200, description: - 'Validate account fields and protect customer accounts with open opportunities from deletion.', + 'Validate account fields and protect customer accounts with open opportunities or activated contracts from deletion.', handler: async (ctx: HookContext) => { // The refusal envelope (#1075). Mirrored from `./_refusal.ts` because a // lowered body has no module scope and `extractHookBody` THROWS on an @@ -222,6 +223,21 @@ const accountHook: Hook = { 409, ); } + // `crm_contract` is master-detail under the account (#549), so a delete + // would cascade the signed agreements away. Activated = live (the same + // definition `contact_integrity` uses); drafts and ended contracts go. + const activeContracts = await api.object('crm_contract').count({ + where: { crm_account: previous.id, status: 'activated' }, + }); + if (activeContracts > 0) { + throw refuse( + activeContracts === 1 + ? 'Cannot delete customer account: 1 activated contract still references it. Terminate or reassign it first.' + : `Cannot delete customer account: ${activeContracts} activated contracts still reference it. Terminate or reassign them first.`, + 'DELETE_RESTRICTED', + 409, + ); + } } }, }; diff --git a/src/sales/profiles/sales-manager.profile.ts b/src/sales/profiles/sales-manager.profile.ts index 137d6daca..e8c4ab419 100644 --- a/src/sales/profiles/sales-manager.profile.ts +++ b/src/sales/profiles/sales-manager.profile.ts @@ -21,62 +21,17 @@ export const SalesManagerProfile = { crm_contact: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true, allowTransfer: true, allowExport: true }, crm_opportunity: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true, allowTransfer: true, allowExport: true }, crm_quote: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true, allowTransfer: true }, - // `writeScope: 'own_and_reports'` — the app's FIRST authored write depth - // (#880). Paired with `requires: ['hierarchy-security']` in - // `objectstack.config.ts`; the two are one declaration and must move together. - // - // THE DEFECT. The object gate said "can edit" and every quote-drafted - // contract answered 403 anyway. `quote_on_accepted` copies the quote's - // `owner_id` onto the contract it drafts, so that contract hangs under a - // REP; `crm_contract` is `private` with an owner field, so - // `plugin-sharing`'s write gate needs the owner inside the caller's write - // DEPTH — and depth with no `writeScope` and `modifyAllRecords: false` is - // `own` (`getEffectiveScope`). A manager could edit only the contracts they - // had created themselves, which is why this never showed up in a smoke - // test: it bites only on the real path, where the rep closes the deal and - // the manager is asked to fix the terms. - // - // WHAT THIS DECLARES, AND WHERE IT TAKES EFFECT. Maintainer ruling, - // 2026-08-11, verbatim: 「本项目是元数据app,在企业版运行就具备企业版相关的 - // 能力,不重复开发。」 This app states the depth it MEANS and lets the edition - // supply the capability. `own_and_reports` is a HIERARCHY scope resolved by - // the `hierarchy-scope-resolver` service, which ships only in - // `@objectstack/security-enterprise`: - // - // - on the ENTERPRISE edition the resolver is present and a Sales Manager - // reaches their reports' contracts — the workflow this card is about; - // - on the OPEN edition there is no resolver, so - // `SharingService.resolveOwnerScopeIds` fails CLOSED to owner-only and a - // Sales Manager still gets 403 on a rep's contract. - // - // That 403 is an EDITION BOUNDARY, not the #880 defect returning. Do not - // "fix" it by substituting a broader open-edition value: an earlier revision - // of this PR used `writeScope: 'org'` for exactly that reason and the ruling - // rejected it as 重复开发 — approximating an enterprise capability in app - // metadata instead of declaring the real one. `test/contract-write-depth.test.ts` - // pins BOTH halves, so the open-edition refusal is asserted rather than - // merely tolerated, and an edition change becomes visible instead of silent. - // - // WHY `own_and_reports` AND NOT `unit_and_below`. The ruling's 「经理能改团队 - // 的合同」 means the manager's REPORTS, and the two scopes resolve through - // different data (ADR-0057 / ADR-0090 Addendum): `unit` / `unit_and_below` - // resolve through BUSINESS UNITS — the anchored - // `sys_user_position.business_unit_id`, falling back to - // `sys_business_unit_member` — while `own_and_reports` resolves through the - // MANAGER CHAIN (`ITeamGraphService.managerOf`). HotCRM's metadata mentions - // business units nowhere at all, so `unit_and_below` would tie this grant to - // an org topology the app never describes; the manager/rep split it DOES - // model (`src/sharing/positions.ts`) is a reporting relationship, which is - // what `own_and_reports` names. Neither scope's data is authored by this app - // — both are supplied by the deployment — so this is a choice of MEANING, - // and the reporting line is the meaning the ruling states. - // - // Deliberately NOT `modifyAllRecords: true`: depth widens which OWNERS the - // caller reaches and nothing else, while the Modify All Data bit is a - // super-user bypass that also skips row-level security, edits ownerless - // rows, widens DELETE past `checkDelete`, and opens `canManageShares`. This - // set holds `allowDelete: false` on contracts and must keep holding it. - crm_contract: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: true, modifyAllRecords: false, writeScope: 'own_and_reports' as const }, + // No writeScope: `crm_contract` is `controlled_by_parent` under + // `crm_account` (#549), so a scope here would be inert — the write gate + // asks for EDIT on the account instead, which this set holds org-wide + // (`modifyAllRecords` above). That retires the #880 gap on BOTH editions: + // the contract an accepted quote drafts hangs under the rep, and the + // manager edits it because they can edit its account, not because of a + // hierarchy depth. `own_and_reports` was the app's only authored hierarchy + // scope; `requires: ['hierarchy-security']` stays declared (#1378 ruling). + // `allowDelete: false` must keep holding; `modifyAllRecords: false` keeps + // this off the super-user bypass. `test/contract-write-depth.test.ts`. + crm_contract: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }, crm_product: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }, crm_campaign: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }, crm_case: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: true, modifyAllRecords: false, allowExport: true }, diff --git a/src/sales/profiles/sales-rep.profile.ts b/src/sales/profiles/sales-rep.profile.ts index 3a2389894..5cf459b0c 100644 --- a/src/sales/profiles/sales-rep.profile.ts +++ b/src/sales/profiles/sales-rep.profile.ts @@ -14,10 +14,10 @@ export const SalesRepProfile = { // account-team rules are authored on `crm_account` ONLY, and a sharing rule // widens the object it names — not the records hanging off it. So a rep who // receives an account through a territory rule reads that account, while the - // quotes, contracts and tasks on it stay own-only, and opportunities widen - // only through the >= $100k leadership rules. Whether those children should - // follow the account is an open business decision (#549), not a bug in these - // grants — do not widen them here to paper over it. + // tasks on it stay own-only and opportunities widen only through the + // >= $100k leadership rules. Quotes and contracts are the exception since + // #549 (ruling 2026-08-31): both are `controlled_by_parent` under the + // account, so they arrive with it — the same derivation as contacts. // // The `controlled_by_parent` grants below read as narrow, and as of // 17.0.0-rc.4 they ARE. MEASURED and pinned by @@ -50,8 +50,13 @@ export const SalesRepProfile = { // top of this file, #694). crm_contact: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: false, modifyAllRecords: false, allowExport: true }, crm_opportunity: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: false, modifyAllRecords: false, readScope: 'own' as const, allowExport: true }, - crm_quote: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: false, modifyAllRecords: false, readScope: 'own' as const }, - crm_contract: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: false, modifyAllRecords: false, readScope: 'own' as const }, + // NO readScope on quote/contract either: both are `controlled_by_parent` + // under `crm_account` (#549), so a scope here would be inert. A rep reads + // the quotes and contracts of every account they can reach, edits the + // quotes of accounts they can edit, and never edits a contract (object + // gate). `test/parent-derived-reach.test.ts` measures both levels. + crm_quote: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: false, modifyAllRecords: false }, + crm_contract: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: false, modifyAllRecords: false }, crm_product: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }, crm_campaign: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }, crm_case: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: false, modifyAllRecords: false, readScope: 'own' as const, allowExport: true }, diff --git a/test/cascade-guard-messages.test.ts b/test/cascade-guard-messages.test.ts index 87285fbab..e8bf803f5 100644 --- a/test/cascade-guard-messages.test.ts +++ b/test/cascade-guard-messages.test.ts @@ -312,3 +312,58 @@ describe('deleting a customer account with open opportunities', () => { expect(await rowsOf('crm_account', account.id)).toHaveLength(1); }); }); + +/** + * The same guard, for ACTIVATED contracts (#549). + * + * `crm_contract` is master-detail under the account since #549, so a + * `DELETE crm_account` now cascades its contracts. The guard refuses while an + * activated one exists and lets a draft go — measured end-to-end, because only + * the engine's own referential pass can show that the refusal reaches the + * caller BEFORE the cascade touches the contract. + */ +describe('deleting a customer account with activated contracts (#549)', () => { + const accountWithContract = async (status: string) => { + const n = uniq(); + const account = await insert('crm_account', { + name: `Signed Corp ${n}`, type: 'customer', industry: 'technology', + }); + const contact = await insert('crm_contact', { + first_name: 'Sig', last_name: `Nature ${n}`, email: `sig${n}@signed.test`, + crm_account: account.id, + }); + const contract = await insert('crm_contract', { + crm_account: account.id, crm_contact: contact.id, status, + contract_term_months: 12, start_date: '2026-01-01', end_date: '2026-12-31', + contract_value: 1000, + }); + return { account, contract }; + }; + + it('refuses while an activated contract exists, naming it, and keeps both rows', async () => { + const { account, contract } = await accountWithContract('activated'); + const message = await deleteAndCatch('crm_account', account.id); + expect(message).toContain( + 'Cannot delete customer account: 1 activated contract still references it. Terminate or reassign it first.', + ); + expect(await rowsOf('crm_account', account.id)).toHaveLength(1); + expect(await rowsOf('crm_contract', contract.id)).toHaveLength(1); + }); + + it('a draft contract is NOT what the guard refuses — the platform referential pass is', async () => { + // MEASURED, and the reason the docs say "an account carrying any contract + // cannot be deleted" rather than "drafts cascade": the guard stands down + // (no activated contract), then the engine's cascade reaches the CONTACTS + // first and stops on `crm_contract.crm_contact`, a required lookup that + // cannot be cleared. So the delete is still refused, by the platform and + // in its words, and every row stays. If this case starts passing with + // `null`, the engine reordered its cascade (contracts before contacts) and + // the docs' delete paragraph must be re-taken. + const { account, contract } = await accountWithContract('draft'); + const message = await deleteAndCatch('crm_account', account.id); + expect(message).not.toContain('activated contract'); + expect(message).toMatch(/still referenced by 1 Contract record/); + expect(await rowsOf('crm_account', account.id)).toHaveLength(1); + expect(await rowsOf('crm_contract', contract.id)).toHaveLength(1); + }); +}); diff --git a/test/contract-write-depth.test.ts b/test/contract-write-depth.test.ts index bd2070b0b..e509fc31c 100644 --- a/test/contract-write-depth.test.ts +++ b/test/contract-write-depth.test.ts @@ -16,62 +16,49 @@ import { defineStack, PLATFORM_CAPABILITY_PROVIDERS } from '@objectstack/spec'; import stack from '../objectstack.config'; /** - * A Sales Manager's WRITE DEPTH on `crm_contract` — the declaration, and what it - * resolves to on THIS edition (#880). + * A Sales Manager's WRITE reach on `crm_contract` — the declaration, and what it + * resolves to on THIS edition (#880, re-taken for #549). * - * ### What was broken + * ### What was broken (#880) * * `sales_manager` has held `allowEdit: true` on `crm_contract` since the app * shipped, and every contract that matters answered **403** anyway. The object - * gate is only the first of two doors: `crm_contract` is `sharingModel: - * 'private'` with an owner field, so `plugin-sharing`'s write gate then asks - * whether the record's owner falls inside the caller's write DEPTH. With - * `modifyAllRecords: false` and no `writeScope`, `getEffectiveScope('write')` - * returns `'own'` — so the manager could edit only contracts they had created - * themselves. + * gate is only the first of two doors: `crm_contract` was `sharingModel: + * 'private'` with an owner field, so `plugin-sharing`'s write gate then asked + * whether the record's owner fell inside the caller's write DEPTH — `own` by + * default — and the manager could edit only contracts they had created + * themselves. That is precisely the wrong half: `quote_on_accepted` + * (`src/revenue/objects/quote.hook.ts`) copies the accepted quote's `owner_id` + * onto the contract it drafts, so the most common contract in the app hangs + * under the **rep** who closed the deal. * - * That is precisely the wrong half. `quote_on_accepted` - * (`src/objects/quote.hook.ts`) copies the accepted quote's `owner_id` onto the - * contract it drafts, so the most common contract in the app hangs under the - * **rep** who closed the deal. The rep cannot edit it either (`allowEdit: - * false`), and nobody can hand it over (`allowTransfer` is - * `system_admin`-only). + * #880 answered with `writeScope: 'own_and_reports'`, an ADR-0057 HIERARCHY + * scope that only the enterprise `hierarchy-scope-resolver` resolves — so on + * the open edition (this suite) the manager was still refused, and this file + * pinned that 403 as an edition boundary. * - * ### What this file pins, and why it does NOT assert the manager can edit + * ### What this file pins now (#549) * - * Maintainer ruling, 2026-08-11, verbatim: 「本项目是元数据app,在企业版运行就具备 - * 企业版相关的能力,不重复开发。」 The app declares the depth it MEANS — - * `writeScope: 'own_and_reports'`, an ADR-0057 HIERARCHY scope — and the edition - * supplies the capability: - * - * - **enterprise**: `@objectstack/security-enterprise` registers the - * `hierarchy-scope-resolver` service, the scope resolves through the manager - * chain, and a Sales Manager reaches their reports' contracts. That is the - * workflow #880 is about, and it is NOT exercisable here — the resolver is - * not installed in this repo, by design. - * - **open** (what this test suite runs on): no resolver, so - * `SharingService.resolveOwnerScopeIds` fails CLOSED to owner-only and the - * manager still gets 403 on a rep's contract. - * - * So the 403 asserted below is an **edition boundary, not the #880 defect - * returning**. It is asserted rather than merely tolerated so that the day this - * repo gains the enterprise resolver, this file goes red and says so instead of - * silently changing meaning. An earlier revision of this PR made the manager's - * edit succeed by substituting `writeScope: 'org'`; the ruling rejected that as - * 重复开发 — approximating an enterprise capability in app metadata — so a test - * asserting that success would now be pinning the wrong thing. + * Since #549 `crm_contract` is `controlled_by_parent` under `crm_account` + * (ruling 2026-08-31). The second door is no longer "is the owner inside your + * write depth" but "can you EDIT the account" — and `sales_manager` holds + * `modifyAllRecords: true` on `crm_account`, so a manager edits every + * contract, on every edition, and the #880 workflow works without a hierarchy + * resolver. The `own_and_reports` scope is therefore INERT on this object + * (`test/authorization-coverage.test.ts` refuses an authored scope on a + * parent-derived object) and is no longer declared. `requires: + * ['hierarchy-security']` stays declared — the 2026-08-31 #1378 refusal to + * remove it stands, pinned by `test/hierarchy-read-depth.test.ts`. * * ### How to read a failure here * - * - The DECLARATION cases going red means the grant or its capability - * declaration was dropped, and `defineStack` would refuse the app outright. - * They are coupled on purpose: `writeScope: 'own_and_reports'` is illegal - * without `requires: ['hierarchy-security']`. - * - The open-edition 403 going red usually means the resolver arrived (good — - * update this file for the enterprise reality), but could also mean the depth - * silently widened, which is the thing to check first. - * - The controls going red means depth has been confused with the Modify All - * Data bypass, which is the one thing this grant must not become. + * - The DECLARATION cases going red means a hierarchy scope crept back onto + * the grant (it would be inert) or the object-level bits moved. + * - The manager's edit going red means the parent-derived write gate stopped + * resolving the account through `modifyAllRecords`, or the OWD moved back to + * `private` — either way the #880 defect is back; check the OWD first. + * - The controls going red means the derivation has been confused with the + * Modify All Data bypass on the CONTRACT, which the grant must not become. */ type AnyRec = Record; @@ -106,11 +93,6 @@ const attempt = async (object: string, doc: AnyRec, ctx: AnyRec) => { message: String(e?.message ?? ''), code: e?.code, status: e?.status ?? e?.statusCode, - // @objectstack/spec 17.0.0 split the refusal in two: `message` is now a - // localized END-USER sentence rendered from `BUILTIN_OPERATION_MESSAGES` - // (`errors.permission_denied`, four locales), while the operator-facing - // sentence naming the operation, object and positions moved to - // `developerMessage` and the machine-readable facts to `details`. details: e?.details, developerMessage: e?.developerMessage, }; @@ -122,24 +104,36 @@ const attempt = async (object: string, doc: AnyRec, ctx: AnyRec) => { // declaration is reported as itself rather than as a downstream permission // oddity. -describe('the declaration (#880)', () => { +describe('the declaration (#880 → #549)', () => { const salesManager = ((stack as AnyRec).permissions as AnyRec[]) .find((p) => p.name === 'sales_manager'); + const contract = ((stack as AnyRec).objects as AnyRec[]).find((o) => o.name === 'crm_contract'); + + it('crm_contract is controlled_by_parent under crm_account, authored as master-detail', () => { + expect(contract?.sharingModel).toBe('controlled_by_parent'); + // Authored, not positional: `crm_contact` is a required lookup too, and a + // master decided by declaration order is refused at author time + // (`security-controlled-by-parent-ambiguous-relation`, objectstack#14747). + expect(contract?.fields?.crm_account?.type).toBe('master_detail'); + expect(contract?.fields?.crm_account?.required).toBe(true); + }); - it('sales_manager declares own_and_reports write depth on crm_contract', () => { - expect(salesManager?.objects?.crm_contract?.writeScope).toBe('own_and_reports'); - // Depth, not the super-user bypass — the two are different grants and the - // bypass would also skip RLS, reach ownerless rows and widen DELETE. + it('sales_manager authors NO scope on crm_contract — it would be inert', () => { + expect(salesManager?.objects?.crm_contract?.writeScope).toBeUndefined(); + expect(salesManager?.objects?.crm_contract?.readScope).toBeUndefined(); + expect(salesManager?.objects?.crm_contract?.allowEdit).toBe(true); + // Not the super-user bypass — the bypass would also skip RLS, reach + // ownerless rows and widen DELETE. expect(salesManager?.objects?.crm_contract?.modifyAllRecords).toBe(false); expect(salesManager?.objects?.crm_contract?.allowDelete).toBe(false); + // The door the write now goes through. + expect(salesManager?.objects?.crm_account?.modifyAllRecords).toBe(true); }); - it('every hierarchy scope the app authors is backed by the capability', () => { - // The coupling rule, checked over the WHOLE permission surface rather than - // this one grant: `defineStack` refuses any hierarchy scope unless - // `requires` carries the token, so authoring one anywhere without it makes - // the app unloadable. Written as a sweep so a second such grant added later - // is covered without editing this test. + it('the app authors no hierarchy scope anywhere, and still declares the capability', () => { + // `own_and_reports` on crm_contract was the app's only hierarchy scope. The + // sweep is kept so a second such grant added later is covered; the + // `requires` line stays by the #1378 ruling (see hierarchy-read-depth). const authored: string[] = []; for (const ps of ((stack as AnyRec).permissions ?? []) as AnyRec[]) { for (const [objName, grant] of Object.entries((ps.objects ?? {}) as Record)) { @@ -148,15 +142,11 @@ describe('the declaration (#880)', () => { } } } - expect(authored, 'the #880 grant must be among the authored hierarchy scopes') - .toContain('sales_manager.crm_contract.writeScope'); + expect(authored).toEqual([]); expect((stack as AnyRec).requires).toContain('hierarchy-security'); }); it('hierarchy-security is an ENTERPRISE capability in the platform vocabulary', () => { - // The edition boundary, read off the platform's own registry rather than - // asserted from this app's beliefs. If ObjectStack ever moves the resolver - // into the open edition, this is the case that notices. const provider = (PLATFORM_CAPABILITY_PROVIDERS as AnyRec)['hierarchy-security']; expect(provider?.edition).toBe('enterprise'); expect(provider?.package).toBe('@objectstack/security-enterprise'); @@ -165,8 +155,8 @@ describe('the declaration (#880)', () => { describe('the spec gate accepts the pair and refuses the half (#880)', () => { // `defineStack` THROWS on a hierarchy scope with no capability declared. Both - // directions are asserted here, so the `requires` line cannot be deleted as - // "unused" without a red test naming exactly why it exists. + // directions are still asserted, on a probe stack: this is what the + // `requires` line protects the day a hierarchy scope is authored again. const minimal = (requires: string[]) => ({ manifest: { id: 'app.objectstack.hierarchy-gate-probe', @@ -208,8 +198,6 @@ describe('the spec gate accepts the pair and refuses the half (#880)', () => { expect(thrown?.message).toContain('hierarchy-scope capability validation failed'); expect(thrown?.message).toContain("writeScope='own_and_reports'"); expect(thrown?.message).toContain('@objectstack/security-enterprise'); - // The half of the diagnostic that states the edition behaviour this file - // then measures for real, one describe block down. expect(thrown?.message).toContain('fail closed to owner-only'); }); }); @@ -231,11 +219,7 @@ beforeAll(async () => { ); // NB: no `hierarchy-scope-resolver` is registered — that service ships in // `@objectstack/security-enterprise`, which this repo does not depend on. - // That absence is the whole subject of the block below. - // 17.2.0: declared sharing rules are only seeded once this stack states its - // tenancy posture — see `test/helpers/tenancy-probe.ts` for the measurement. - // Mounted BEFORE SharingServicePlugin, which reads the posture during its own - // boot. + // The manager's edit below must succeed WITHOUT it. await kernel.use(tenancyProbe('single') as never); await kernel.use(new SharingServicePlugin()); await kernel.bootstrap(); @@ -262,9 +246,13 @@ beforeAll(async () => { await bind(id.mgr, 'sales_manager'); await bind(id.rep, 'sales_rep'); + // The account is owned by the REP and shared to nobody: a JP prospect, so + // neither territory rule nor `account_team_sharing` (active CUSTOMER + // accounts → sales_manager) materialises a share. The manager reaches it + // through `modifyAllRecords` on crm_account and nothing else. id.account = await insert('crm_account', { - name: 'Depth Co', type: 'customer', is_active: true, owner_id: id.rep, - billing_address: { country: 'US' }, + name: 'Depth Co', type: 'prospect', is_active: true, owner_id: id.rep, + billing_address: { country: 'JP' }, }); id.contact = await insert('crm_contact', { first_name: 'Dana', last_name: 'Depth', email: 'dana@contract-depth.test', @@ -276,12 +264,9 @@ beforeAll(async () => { contract_term_months: 12, start_date: '2026-01-01', end_date: '2026-12-31', contract_value: 1000, }); - // THE record this card is about: the shape `quote_on_accepted` drafts — - // owned by the rep who closed the deal, not by the manager. + // THE record #880 is about: the shape `quote_on_accepted` drafts — owned by + // the rep who closed the deal, not by the manager. id.repContract = await insert('crm_contract', contractOwnedBy(id.rep)); - // Positive control: a contract the manager owns outright. Owner-only depth - // still covers this, so a suite that had merely broken permissions everywhere - // could not pass this case. id.mgrContract = await insert('crm_contract', contractOwnedBy(id.mgr)); // A case: the manager holds `allowEdit: false` there. @@ -318,9 +303,8 @@ describe('the harness enforces (negative controls)', () => { }); it('no hierarchy-scope resolver is registered — this IS the open edition', () => { - // The premise of every assertion below. Stated explicitly so a failure here - // reads as "the edition changed" rather than surfacing as a puzzling - // permission result three cases later. + // The premise of the block below: the manager's edit must not depend on + // the enterprise resolver. let resolver: unknown = null; try { resolver = kernel.getService('hierarchy-scope-resolver'); @@ -329,59 +313,61 @@ describe('the harness enforces (negative controls)', () => { } expect(resolver ?? null).toBeNull(); }); + + it('the account is shared to nobody — the manager reaches it by modifyAllRecords alone', async () => { + const shares = await ql.find( + 'sys_record_share', { where: { object_name: 'crm_account', record_id: id.account } }, { context: SYS }, + ); + expect(shares).toEqual([]); + }); }); -describe('open edition: the hierarchy scope fails closed to owner-only (#880)', () => { - it('a Sales Manager still cannot edit a REP-owned contract here', async () => { - // ⚠️ EDITION BOUNDARY, NOT THE #880 DEFECT RETURNING. The declaration says - // `own_and_reports`; resolving it needs the enterprise - // `hierarchy-scope-resolver`, absent here, so `resolveOwnerScopeIds` - // returns the caller alone and the owner-match misses. On the enterprise - // edition this same metadata lets the manager through. +describe('open edition: a Sales Manager edits a REP-owned contract through the account (#880 closed by #549)', () => { + it('the manager changes the value of a contract standing in a rep’s name', async () => { + // Before #549 this was refused (`own_and_reports` with no resolver fails + // closed to owner-only). Now the parent-derived write gate asks whether + // the manager can edit the ACCOUNT — `modifyAllRecords: true` — and the + // owner of the contract row is irrelevant. const r = await attempt('crm_contract', { id: id.repContract, contract_value: 2500 }, mgrCtx); - expect(r.ok, 'without the enterprise resolver this must still be refused').toBe(false); - // MEASURED refusal shape — the record-level write gate answers with a bare - // `Error` carrying `code`/`status`, NOT an ADR-0112 envelope. Pinned as - // measured rather than as expected. - // - // ⚠️ RE-MEASURED on the 17.2.0 -> 17.3.0 upgrade. `code` and `status` are - // unchanged; the MESSAGE was rewritten from the internal-sounding - // "insufficient privileges to update crm_contract" into prose addressed to - // the person who hit it ("You do not have access to change or delete this - // record. Contact the person who owns it, or your administrator…"). The - // code/status pair is the contract a caller switches on, so that is what is - // pinned tightly; the message is asserted only for the fact that it names a - // record-level access refusal, which is what keeps this case from passing - // against some other 403. - expect(r.code).toBe('FORBIDDEN'); - expect(r.status).toBe(403); - expect(r.message).toMatch(/do not have access to change or delete this record|insufficient privileges to update crm_contract/); - + expect(r, `the manager was refused on a rep-owned contract: ${JSON.stringify(r)}`) + .toEqual({ ok: true }); const rows = (await ql.find( 'crm_contract', { where: { id: id.repContract }, fields: ['id', 'contract_value'] }, { context: SYS }, )) as AnyRec[]; - expect(Number(rows[0]?.contract_value), 'the refused update must not have landed').toBe(1000); + expect(Number(rows[0]?.contract_value)).toBe(2500); }); - it('the same is true of terminating it — the act contracts.mdx describes', async () => { + it('and terminates it — the act contracts.mdx describes', async () => { const r = await attempt('crm_contract', { id: id.repContract, status: 'terminated' }, mgrCtx); - expect(r.ok).toBe(false); - expect(r.code).toBe('FORBIDDEN'); + expect(r).toEqual({ ok: true }); }); - it('but the manager still edits their OWN contract (positive control)', async () => { - // Owner-only depth covers this, which is what makes the two cases above a - // statement about DEPTH rather than about permissions being broken. + it('and still edits their OWN contract (positive control)', async () => { const r = await attempt('crm_contract', { id: id.mgrContract, contract_value: 1500 }, mgrCtx); - expect(r, `the manager was refused on their own contract: ${JSON.stringify(r)}`) - .toEqual({ ok: true }); + expect(r).toEqual({ ok: true }); + }); + + it('the rep still cannot edit the contract they own — the object gate, unchanged', async () => { + // `allowEdit: false` on crm_contract for sales_rep. The rep can EDIT the + // account (they own it), so this refusal is the object gate and nothing + // else — the parent-derived write gate never gets a say. + const r = await attempt('crm_contract', { id: id.repContract, contract_value: 9999 }, repCtx); + expect(r.ok, 'a rep edited a contract').toBe(false); + // The ENVELOPE is the contract (ADR-0112): code + status, then the + // structured facts naming WHICH verb was refused on WHICH object. + expect(r.code).toBe('PERMISSION_DENIED'); + expect(r.status).toBe(403); + expect(r.details).toMatchObject({ operation: 'update', object: 'crm_contract' }); + expect(String(r.developerMessage)).toContain( + "operation 'update' on object 'crm_contract' is not permitted", + ); }); }); -describe('what the grant deliberately does NOT convey, on any edition', () => { - it('DELETE stays refused — depth is not the Modify All Data bypass', async () => { - // `allowDelete: false` is the object gate; `writeScope` widens which owners - // the caller reaches, never which verbs they hold. +describe('what the grant deliberately does NOT convey', () => { + it('DELETE stays refused — the account door is not the Modify All Data bypass', async () => { + // `allowDelete: false` is the object gate; deriving from the account widens + // which ROWS the caller reaches, never which verbs they hold. let refused = false; try { await ql.delete('crm_contract', id.mgrContract, { context: mgrCtx }); @@ -399,30 +385,4 @@ describe('what the grant deliberately does NOT convey, on any edition', () => { const r = await attempt('crm_case', { id: id.case, subject: 'Rewritten' }, mgrCtx); expect(r.ok, `the manager edited a case: ${JSON.stringify(r)}`).toBe(false); }); - - it('a Sales Rep still cannot edit the contract standing in their own name', async () => { - // Unchanged by this card, and the reason the manager needed the reach at - // all: the OBJECT gate refuses the rep before ownership is ever consulted, - // which is a different producer and a different shape from the depth - // refusal above — `PermissionDeniedError` / `PERMISSION_DENIED`, with a - // `statusCode` rather than a `status`. - const r = await attempt('crm_contract', { id: id.repContract, contract_value: 9999 }, repCtx); - expect(r.ok).toBe(false); - // The ENVELOPE is the contract (ADR-0112): code + status, then the - // structured facts. This used to read the refusal out of `message`, which - // stopped naming the object at @objectstack/spec 17.0.0 — `message` is now - // the localized end-user sentence and says nothing about `crm_contract`. - // Asserting `details` instead is strictly stronger than the old substring: - // it pins WHICH verb was refused on WHICH object as data, so a refusal - // arriving for the wrong object can no longer pass by wording alone. - expect(r.code).toBe('PERMISSION_DENIED'); - expect(r.status).toBe(403); - expect(r.details).toMatchObject({ operation: 'update', object: 'crm_contract' }); - // The operator-facing sentence survives, one field over — kept in the - // assertion so a future release that drops `developerMessage` is loud here - // rather than quietly leaving the logs with nothing to grep. - expect(String(r.developerMessage)).toContain( - "operation 'update' on object 'crm_contract' is not permitted", - ); - }); }); diff --git a/test/hierarchy-read-depth.test.ts b/test/hierarchy-read-depth.test.ts index 21b14c206..ee41bd922 100644 --- a/test/hierarchy-read-depth.test.ts +++ b/test/hierarchy-read-depth.test.ts @@ -175,10 +175,11 @@ describe('the hierarchy READ capability in the platform vocabulary (#1378)', () } } expect(authoredReads, 'no profile may declare a hierarchy readScope (#1378 ruling)').toEqual([]); - // The write half is declared, and `contract-write-depth.test.ts` pins it. - // Named here so that dropping it does not quietly leave this file as the - // app's only statement about hierarchy scopes. - expect(authoredWrites).toEqual(['sales_manager.crm_contract']); + // The write half was `sales_manager.crm_contract` (#880) until #549 made + // `crm_contract` controlled_by_parent, where a scope is inert; the app now + // authors NO hierarchy scope and `contract-write-depth.test.ts` pins that. + // `requires` keeps the token regardless (the #1378 refusal above). + expect(authoredWrites).toEqual([]); }); it('a hierarchy readScope beside viewAllRecords: true would never be consulted', () => { diff --git a/test/hooks-runtime-sales.test.ts b/test/hooks-runtime-sales.test.ts index 78d2fb4ba..7e296c0e5 100644 --- a/test/hooks-runtime-sales.test.ts +++ b/test/hooks-runtime-sales.test.ts @@ -539,6 +539,63 @@ describe('account_protection', () => { ).resolves.toBeUndefined(); }); + // ─── Activated contracts (#549) ────────────────────────────────────── + // + // `crm_contract` is master-detail under the account since #549, so deleting + // an account cascades its contracts. The guard refuses while an ACTIVATED + // contract exists — the same "live" definition `contact_integrity` uses — + // and lets drafts, expired and terminated contracts go with the account. + const refuseDeleteForContracts = (statuses: string[]) => { + const h = makeHarness({ + crm_opportunity: [{ id: 'closed', crm_account: 'acc1', stage: 'closed_won' }], + crm_contract: [ + ...statuses.map((status, i) => ({ id: `c${i + 1}`, crm_account: 'acc1', status })), + { id: 'elsewhere', crm_account: 'other', status: 'activated' }, // other account + ], + }); + return hook.handler(makeCtx({ + event: 'beforeDelete', previous: { id: 'acc1', type: 'customer' }, user: USER, api: h.api, + })); + }; + + it('refuses to delete a customer account with ONE activated contract (singular agreement)', async () => { + await expect(refuseDeleteForContracts(['activated', 'draft'])).rejects.toThrow( + 'Cannot delete customer account: 1 activated contract still references it. Terminate or reassign it first.', + ); + }); + + it('refuses to delete a customer account with SEVERAL activated contracts (plural agreement)', async () => { + await expect(refuseDeleteForContracts(['activated', 'activated'])).rejects.toThrow( + 'Cannot delete customer account: 2 activated contracts still reference it. Terminate or reassign them first.', + ); + }); + + it('the contract refusal carries the same envelope as the opportunity one', async () => { + const err = await refuseDeleteForContracts(['activated']).catch((e: Error) => e) as Error & Rec; + expect(err.code).toBe('DELETE_RESTRICTED'); + expect(err.status).toBe(409); + }); + + it('lets drafts, expired and terminated contracts cascade with the account', async () => { + await expect( + refuseDeleteForContracts(['draft', 'in_approval', 'expired', 'terminated']), + ).resolves.toBeUndefined(); + }); + + it('open opportunities are reported before activated contracts', async () => { + // One refusal at a time, opportunities first — the sentence a user reads + // must not change depending on which count the harness answers first. + const h = makeHarness({ + crm_opportunity: [{ id: 'o1', crm_account: 'acc1', stage: 'proposal' }], + crm_contract: [{ id: 'c1', crm_account: 'acc1', status: 'activated' }], + }); + await expect( + hook.handler(makeCtx({ + event: 'beforeDelete', previous: { id: 'acc1', type: 'customer' }, user: USER, api: h.api, + })), + ).rejects.toThrow(/1 open opportunity still references it/); + }); + // ─── billing_country / territory derivation (#621, #639) ───────────── // // The territory sharing rules filter on `territory`, and this hook is the diff --git a/test/parent-derived-reach.test.ts b/test/parent-derived-reach.test.ts index 65e23097c..14b887fd3 100644 --- a/test/parent-derived-reach.test.ts +++ b/test/parent-derived-reach.test.ts @@ -20,17 +20,18 @@ import stack from '../objectstack.config'; * * ### Why this file exists * - * #549 asks whether `crm_quote` / `crm_contract` should become + * #549 asked whether `crm_quote` / `crm_contract` should become * `controlled_by_parent` under `crm_account`, so that a rep who receives an - * account through a territory rule also sees its quotes and contracts. Every - * other test in this repo reads METADATA; none of them can answer what the - * engine does with it. So this one boots the shipped stack — ObjectQL + - * `plugin-security` + `plugin-sharing`, the same plugins `objectstack serve` - * mounts — over the app's own `objectstack.config.ts`, materialises the - * territory sharing rule, and asks the engine, as a real `sales_rep`, what it - * returns. + * account through a territory rule also sees its quotes and contracts. The + * 2026-08-31 ruling converted both. Every other test in this repo reads + * METADATA; none of them can answer what the engine does with it. So this one + * boots the shipped stack — ObjectQL + `plugin-security` + `plugin-sharing`, + * the same plugins `objectstack serve` mounts — over the app's own + * `objectstack.config.ts`, materialises the territory sharing rule, and asks + * the engine, as a real `sales_rep`, what it returns. * - * ### The measurement (17.0.0-rc.4): the derivation follows the parent's SHARING + * ### The measurement (17.4.0): the derivation follows the parent's SHARING — + * ### and composes across a two-level chain * * A rep who holds ONE account (acct_US, via `north_america_territory`), owns one * quote of their own (quote_own) and owns nothing else sees: @@ -38,18 +39,20 @@ import stack from '../objectstack.config'; * | object | OWD | rep sees | * | -------------------------- | -------------------- | --------------------- | * | `crm_account` | private | acct_US only | - * | `crm_quote` | private + own | quote_own only — NOT the territory account's quote (the #549 keyhole) | - * | `crm_contract` | private + own | nothing | * | `crm_contact` | controlled_by_parent | contact_US only — the readable account's | - * | `crm_quote_line_item` | controlled_by_parent | line_own only — the readable quote's | + * | `crm_quote` | controlled_by_parent | quote_US + quote_own — the readable account's, and the owned one | + * | `crm_contract` | controlled_by_parent | contract_US only — the readable account's | + * | `crm_quote_line_item` | controlled_by_parent | line_US + line_own — the readable quotes' (two levels: line → quote → account) | + * | `crm_opportunity_line_item`| controlled_by_parent | oli_own only — its master `crm_opportunity` is still private + own (the live control) | * - * The last two rows are the point: a parent-derived child IS filtered to parents - * the caller can read, and "can read" includes the paths a direct read of the - * master would take — owner scope (quote_own, owned outright) AND - * `sys_record_share` grants (acct_US, held only through the territory rule). - * Folding the share path in is precisely what objectstack#5386 fixed; a - * derivation that consulted RLS policies alone would show contact_US as - * unreachable, because HotCRM authors no RLS policy on any master. + * "Can read" includes every path a direct read of the master would take — + * owner scope (quote_own, owned outright) AND `sys_record_share` grants + * (acct_US, held only through the territory rule). Folding the share path in is + * what objectstack#5386 fixed; composing the master's OWN derivation in as a + * third half — so a line item under a quote under an unreadable account stays + * unreadable and unwritable — is what objectstack#11082 fixed (PR #11183, first + * published in 17.4.0). On 17.1.0–17.2.0 the second level was org-wide, which + * is why the conversion waited for the pin to carry that fix (AGENTS.md §2). * * The write side derives the same way: `crm_contact` under the territory-shared * account is editable (the share carries `edit`), while a child of a master the @@ -71,21 +74,15 @@ import stack from '../objectstack.config'; * not see. That harness was written to go red the day the engine narrowed, which * it did on rc.4 — the assertions below are the flip, not a deletion. * - * Consequence for #549: the premise its 2026-08-02 Option 2 decision was - * rejected on has expired. Converting `crm_quote` / `crm_contract` to - * `controlled_by_parent` no longer leaks them org-wide; it now means what the - * decision assumed — "reachable by whoever can read the account". #549 is - * flagged for re-evaluation on this measurement; the conversion itself is the - * maintainer's call and is NOT made here. - * * ### How to read a failure here * * These assertions pin measured behaviour. If a case starts failing because the - * derivation widened again, that is a REGRESSION of objectstack#5386 and the - * exposure #694 described is back — per-line pricing and contact PII readable by - * every holder of object-level read. Report it upstream rather than relaxing the - * assertion. Every narrowed case below carries a positive control (a row that - * MUST still be visible), so none of them can pass by returning nothing. + * derivation widened again, that is a REGRESSION of objectstack#5386 (level one) + * or objectstack#11082 (level two) and the exposure #694 described is back — + * per-line pricing and contact PII readable by every holder of object-level + * read. Report it upstream rather than relaxing the assertion. Every narrowed + * case below carries a positive control (a row that MUST still be visible), so + * none of them can pass by returning nothing. */ type AnyRec = Record; @@ -222,6 +219,23 @@ beforeAll(async () => { id.contract_US = await insert('crm_contract', contractOn(id.acct_US, id.contact_US)); id.contract_JP = await insert('crm_contract', contractOn(id.acct_JP, id.contact_JP)); + // The live control: `crm_opportunity` stays private + own, and its line + // items derive from it. oli_own is the positive half (the rep owns opp_own), + // oli_JP the negative half (opp_JP is owned by someone else, on the + // unreachable account). + const oppOn = (account: string, owner: string, name: string) => ({ + name, crm_account: account, owner_id: owner, stage: 'prospecting', amount: 100, + close_date: '2026-06-01', + }); + id.opp_JP = await insert('crm_opportunity', oppOn(id.acct_JP, id.owner, 'JP Opp')); + id.opp_own = await insert('crm_opportunity', oppOn(id.acct_US, id.rep, 'Own Opp')); + id.oli_JP = await insert('crm_opportunity_line_item', { + crm_opportunity: id.opp_JP, crm_product: product, quantity: 1, unit_price: 10, + }); + id.oli_own = await insert('crm_opportunity_line_item', { + crm_opportunity: id.opp_own, crm_product: product, quantity: 1, unit_price: 10, + }); + // Materialise the declared rules against the population just inserted (the // boot backfill ran on an empty database). const rules: AnyRec = kernel.getService('sharingRules'); @@ -268,21 +282,30 @@ describe('the harness enforces (negative controls)', () => { }); }); +/** Rows of `object` with this id visible to the rep — 0 or 1. */ +const repReadsById = async (object: string, rowId: string): Promise => { + const rows = await ql.find(object, { where: { id: rowId } }, { context: repCtx }); + return Array.isArray(rows) ? rows.length : 0; +}; + describe('#549: what a territory-shared account carries into its related lists', () => { - it('quotes and contracts stay own-only — the keyhole this issue reports', async () => { - // The rep owns quote_own and nothing else. quote_US hangs off the account - // the territory rule shared to them, and still does not come back: a - // sharing rule widens the object it NAMES, not the records hanging off it. - // That is the #549 keyhole, unchanged by the rc.4 derivation fix — the fix - // narrowed `controlled_by_parent`, and `crm_quote` is `private`. + it('quotes and contracts follow the account — the keyhole this issue reported is closed', async () => { + // quote_US and contract_US hang off the account the territory rule shared + // to the rep, and both come back; quote_JP / contract_JP hang off the + // account they cannot reach, and neither does. `crm_quote` is no longer + // own-scoped: quote_own comes back because it hangs off acct_US too, not + // because the rep owns it. The negative half is asserted by id so the list + // assertion cannot pass by accident. expect( await repSees('crm_quote'), - 'crm_quote is private + readScope own with no rule of its own', - ).toEqual(['quote_own']); + 'crm_quote is controlled_by_parent under crm_account', + ).toEqual(['quote_US', 'quote_own']); expect( await repSees('crm_contract'), - 'crm_contract is private + readScope own with no rule of its own', - ).toEqual([]); + 'crm_contract is controlled_by_parent under crm_account', + ).toEqual(['contract_US']); + expect(await repReadsById('crm_quote', id.quote_JP)).toBe(0); + expect(await repReadsById('crm_contract', id.contract_JP)).toBe(0); }); it('a controlled_by_parent child IS filtered to parents the caller can read', async () => { @@ -299,16 +322,31 @@ describe('#549: what a territory-shared account carries into its related lists', ).toEqual(['contact_US']); }); - it('the second level of the chain narrows the same way (quote_line_item → quote)', async () => { - // The two-level chain #549 asks about is `quote_line_item → quote → - // account`. Level one restricts on the quote's own reachability: the rep - // reads quote_own and neither of the other two, so exactly its line comes - // back. line_own is the positive control — without it this case would read - // `[]` and pass even if line items were denied outright. + it('the second level of the chain narrows the same way (quote_line_item → quote → account)', async () => { + // The two-level chain: `crm_quote_line_item` derives from `crm_quote`, + // which itself derives from `crm_account`. The rep reads quote_US and + // quote_own (both under acct_US) and not quote_JP, so exactly those two + // quotes' lines come back. This is the objectstack#11082 pass condition in + // the ruling: quote_JP unreadable ⇒ line_JP unreadable (and unwritable, + // asserted in the write block below). line_US is the positive control for + // the chain — a derivation that denied instead of narrowed would drop it. expect( await repSees('crm_quote_line_item'), - 'line items no longer track the readable quote — objectstack#5386 regressed', - ).toEqual(['line_own']); + 'line items no longer track the readable quotes — objectstack#5386 / #11082 regressed', + ).toEqual(['line_US', 'line_own']); + expect( + await repReadsById('crm_quote_line_item', id.line_JP), + 'a line under a quote under an unreadable account is readable — objectstack#11082 regressed', + ).toBe(0); + }); + + it('live control: a child of a master that is still private does not widen', async () => { + // `crm_opportunity` stays private + own; its line items derive from it. If + // the conversion (or a platform change) had widened derivation itself + // rather than followed the account, oli_JP would appear here. + expect(await repSees('crm_opportunity')).toEqual(['opp_own']); + expect(await repSees('crm_opportunity_line_item')).toEqual(['oli_own']); + expect(await repReadsById('crm_opportunity_line_item', id.oli_JP)).toBe(0); }); }); @@ -333,20 +371,44 @@ describe('#694: the parent-write gate derives from the master the same way', () 'the write gate stopped honouring the edit share on the master — objectstack#5386 regressed', ).toBe('allowed'); expect(await repWrites('crm_quote_line_item', id.line_own, { quantity: 2 })).toBe('allowed'); + // The territory share carries `edit` on acct_US, so the rep edits its quote + // (sales_rep holds allowEdit on crm_quote — the cost the 2026-08-02 ruling + // accepted) and, two levels down, its lines. + expect(await repWrites('crm_quote', id.quote_US, { name: 'US Quote v2' })).toBe('allowed'); + expect(await repWrites('crm_quote_line_item', id.line_US, { quantity: 2 })).toBe('allowed'); }); - it('a child of an unreachable master is refused', async () => { - // Before rc.4 all three of these succeeded: the gate checked the master - // against the same empty RLS filter, so `allowEdit` on the child was enough - // to write children of masters the caller cannot even see (#694). + it('a child of an unreachable master is refused, at both levels', async () => { + // Before rc.4 all of these succeeded: the gate checked the master against + // the same empty RLS filter, so `allowEdit` on the child was enough to + // write children of masters the caller cannot even see (#694). Before + // 17.4.0 the line_JP write succeeded too: the master's own derivation was + // not composed in, so a master that was itself controlled_by_parent + // resolved to "no restriction" (objectstack#11082). expect( await repWrites('crm_contact', id.contact_JP, { title: 'Head of Ops' }), 'a contact under an unreadable account became writable again — objectstack#5386 regressed', ).toBe('denied: PermissionDeniedError'); - expect(await repWrites('crm_quote_line_item', id.line_US, { quantity: 2 })).toBe( + expect(await repWrites('crm_quote', id.quote_JP, { name: 'x' })).toBe( + 'denied: PermissionDeniedError', + ); + expect( + await repWrites('crm_quote_line_item', id.line_JP, { quantity: 2 }), + 'a line under a quote under an unreadable account became writable — objectstack#11082 regressed', + ).toBe('denied: PermissionDeniedError'); + expect(await repWrites('crm_opportunity_line_item', id.oli_JP, { quantity: 2 })).toBe( + 'denied: PermissionDeniedError', + ); + }); + + it('a contract is never writable by a rep — the object gate, not the derivation', async () => { + // sales_rep holds allowEdit: false on crm_contract, so even the contract + // under the account they can EDIT (acct_US, `edit` share) is refused. The + // object gate refuses before the parent-write gate is consulted. + expect(await repWrites('crm_contract', id.contract_US, { contract_value: 2 })).toBe( 'denied: PermissionDeniedError', ); - expect(await repWrites('crm_quote_line_item', id.line_JP, { quantity: 2 })).toBe( + expect(await repWrites('crm_contract', id.contract_JP, { contract_value: 2 })).toBe( 'denied: PermissionDeniedError', ); }); diff --git a/test/refusal-envelope.test.ts b/test/refusal-envelope.test.ts index b4e4c2773..2f93b85da 100644 --- a/test/refusal-envelope.test.ts +++ b/test/refusal-envelope.test.ts @@ -136,10 +136,11 @@ describe('every refusal names a code the platform will echo (#1075)', () => { it('found every swept call site', () => { // 18 until REQ-0003 added the account capability gate in - // `opportunity.hook.ts`. The number is hand-maintained on purpose: a new - // refusal has to be a deliberate edit here, so a guard that quietly stopped - // being swept cannot hide behind a count that follows it. - expect(sites).toHaveLength(19); + // `opportunity.hook.ts`; 19 until #549 added the activated-contract + // refusal to `account_protection`. The number is hand-maintained on + // purpose: a new refusal has to be a deliberate edit here, so a guard that + // quietly stopped being swept cannot hide behind a count that follows it. + expect(sites).toHaveLength(20); }); it('uses only members of the platform ErrorCode enum', () => { diff --git a/test/sharing-coverage.test.ts b/test/sharing-coverage.test.ts index 5bba32bcf..7e1aebc9a 100644 --- a/test/sharing-coverage.test.ts +++ b/test/sharing-coverage.test.ts @@ -121,8 +121,11 @@ const ACCOUNT_CHILD_COVERAGE: Record crm_contact: 'derived', crm_opportunity: 'partial', crm_case: 'partial', - crm_quote: 'own_only', - crm_contract: 'own_only', + // #549 (ruling 2026-08-31): both derive from the account, the same way + // `crm_contact` does — measured two levels deep (quote_line_item → quote → + // account) in `test/parent-derived-reach.test.ts`. + crm_quote: 'derived', + crm_contract: 'derived', crm_task: 'own_only', // #592. `crm_event` is `private` with no sharing rule of its own, exactly // like `crm_task` — the two are the same kind of record (a rep's personal @@ -632,7 +635,7 @@ describe('the OWD table lists every registered object, in every locale', () => { }; it('the row ledger answers exactly the objects this app registers', () => { - // Anti-vacuum, both halves. 17 objects ship today, 5 of them parent-derived; + // Anti-vacuum, both halves. 19 objects ship today, 8 of them parent-derived; // a ledger compared against an empty derived set would pass by checking // nothing at all, and so would every rule below. expect(