Skip to content

Commit 647ec8b

Browse files
baozhoutaoclaude
andauthored
fix(sharing,driver-sql): a rule authored in Setup actually applies — and switching it off actually withdraws access (#3850)
* fix(sharing,driver-sql): a rule authored in Setup actually applies — and switching it off actually withdraws access (#3821) **Enabling a rule did nothing to the records already there.** Writing a `sys_sharing_rule` rebound the per-record hooks, which only makes the rule reach records written FROM THEN ON. An admin who created a rule and switched it on saw the recipient's list stay empty until somebody happened to touch each record. **Switching a rule off did not withdraw access.** Deactivating — or deleting — left every grant it had already issued in place, and boot backfill only reconciles ACTIVE rules, so those grants outlived restarts while the UI showed the rule as disabled. The reconcile existed but was reachable only through `POST /sharing/rules/:id/evaluate`, which the Console never calls and exposes no button for. Each non-system write to `sys_sharing_rule` now also reconciles that rule's grants, chained behind the existing rebind: insert/update run the same diff-based `evaluateRule` the REST endpoint runs (it purges when the rule is inactive), and delete purges directly via the new `SharingRuleService.revokeRuleGrants` — `evaluateRule` can't help there because the row is gone (`RULE_NOT_FOUND`), which is also why a rule deleted through the plain data API used to orphan its grants. Seeding and package bootstrap write with `isSystem` and are skipped; `kernel:bootstrapped` already backfills those. Reconciliation is best-effort and never fails the authoring write. **An unsortable query lost its rows, not just its order.** `SqlDriver.find()` already recovered from a SELECT projection naming a missing column; the same failure one clause over — an unknown ORDER BY column — fell through to `return []`. Because `count()` is a separate statement, the endpoint answered 200 with `records: []` and `total: 3`: the rows are there, none are shown, nothing is logged. Same family as the `$`-param footgun closed by #2926. It surfaced through the sharing-rule recipient picker, whose client mangled `'name asc'` into a list of one-character column names (fixed in objectui). Rows now outrank their order: the retry ladder drops the projection first, then the sort, then gives up — an unsortable query returns rows UNORDERED instead of empty. Non-column errors still propagate. Also refreshes the `sys_sharing_rule` help text in the zh-CN / ja-JP / es-ES bundles, which still described `recipient_type` in terms of `department` (the enum value is `business_unit`) and told admins to enter a queue name for `recipient_id` (`queue` was removed in ADR-0078). The es-ES option labels for `position` / `unit_and_subordinates` were translated as "rol". Browser-verified against the showcase Console: enabling a rule immediately gave the recipient every matching record, and switching it off took them all away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(sharing): write the sharing-rule help text for the admin who reads it (#3821) The field `description`s on `sys_sharing_rule` render as help text under each input in Setup. They were written as notes for the next engineer: 接收访问权限的主体类型——求值时会展开为用户授权。`business_unit` 会沿 parent_business_unit_id 树展开;`team` 为扁平结构(better-auth);`position` 展开为该岗位的任职人(岗位为扁平结构,ADR-0090 D3)…(ADR-0057 D5)。 ADR numbers, table and column names, a third-party library, and enum machine values the dropdown never displays — it shows 用户 / 团队 / 业务单元 / 岗位. Several were also stale, some of them because of this very issue's UI work: `recipient_id` still said "fill in a business-unit id / team id / position name" after it became a record picker; `object_name` still said "short object name (e.g. opportunity, account)" after it became an object picker; `criteria_json` still described hand-written `FilterCondition` JSON after it became a visual builder — and carried "(FilterCondition JSON)" in its LABEL. All of it rewritten for the reader who actually sees it, in en / zh-CN / ja-JP / es-ES. The engine detail was already in the object's doc comment, which is where it stays; a comment on the first field now says so, so the next author doesn't put it back. `active` can finally state what it does — turning it off withdraws the access — which only became true with the reconcile fix in this branch. The list page's subtitle and the `managed_by` / `customized` help got the same treatment. Browser-verified in the showcase Console: no ADR, table, column, enum or library name survives anywhere in the create dialog, the detail page or the list header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a6c3f38 commit 647ec8b

11 files changed

Lines changed: 390 additions & 63 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
"@objectstack/plugin-sharing": patch
4+
---
5+
6+
fix(driver-sql,sharing): an unsortable query loses its ORDER BY, not its rows (#3821)
7+
8+
`SqlDriver.find()` already recovered from a SELECT projection naming a column
9+
the table lacks (retry with `select('*')`, the unknown field is simply absent
10+
from each row). The identical failure one clause over — an **ORDER BY** column
11+
the table lacks — fell through to `return []`. Because `count()` is a separate
12+
statement, the list endpoint answered `HTTP 200` with `records: []` and
13+
`total: 3`: the rows are there, none are shown, nothing is logged. Same family
14+
as the `$`-param footgun closed by #2926.
15+
16+
It surfaced through the Console's sharing-rule **recipient picker**, which
17+
never listed a single candidate. The client mangled `'name asc'` into
18+
`0 n,1 a,2 m,…` (fixed separately in objectui) and the driver turned that into
19+
"no users exist", so no sharing rule could be authored from the UI at all.
20+
21+
Rows now outrank their order: the retry ladder drops the projection first (the
22+
likelier culprit and the cheaper thing to lose), then the sort, then gives up.
23+
A query that cannot be sorted comes back **unordered instead of empty**. Errors
24+
that are not about an unknown column still propagate untouched.
25+
26+
**A rule authored in Setup now actually applies — and switching it off actually
27+
withdraws access.** Writing a `sys_sharing_rule` rebound the per-record hooks,
28+
which only makes the rule reach records written FROM THEN ON. So an admin who
29+
created a rule and enabled it saw nothing happen: the recipient's list stayed
30+
empty until somebody happened to touch each record. The reverse was worse —
31+
switching a rule OFF, or deleting it, left every grant it had already issued in
32+
place, and boot backfill only reconciles ACTIVE rules, so those grants outlived
33+
restarts while the UI displayed the rule as disabled. The reconcile was reachable
34+
only through `POST /sharing/rules/:id/evaluate`, which the Console never calls.
35+
36+
Each non-system write to `sys_sharing_rule` now also reconciles that rule's
37+
grants, chained behind the existing rebind: insert/update run the same
38+
diff-based `evaluateRule` the REST endpoint runs (it purges when the rule is
39+
inactive), and delete purges directly via the new
40+
`SharingRuleService.revokeRuleGrants``evaluateRule` can't help there because
41+
the row is already gone (`RULE_NOT_FOUND`), which is also why a rule deleted
42+
through the plain data API used to orphan its grants. Seeding and package
43+
bootstrap write with `isSystem` and are skipped; `kernel:bootstrapped` already
44+
backfills those. Reconciliation is best-effort and never fails the write.
45+
46+
**The dialog's help text was engineering notes, shown to tenant admins.** The
47+
field descriptions on `sys_sharing_rule` render under each input in Setup, and
48+
they cited ADR numbers, table and column names (`parent_business_unit_id`,
49+
`sys_business_unit`), enum machine values the dropdown never shows
50+
(`business_unit`, `team`), a third-party library (better-auth), and engine
51+
vocabulary ("evaluation", "lifecycle"). Several were also stale: they still told
52+
admins to type an id or hand-write a `FilterCondition` after those inputs became
53+
a record picker and a visual builder. Rewritten for the reader who actually sees
54+
them — the implementation detail was already in the object's doc comment, which
55+
is where it stays. `criteria_json`'s LABEL loses its "(FilterCondition JSON)"
56+
suffix for the same reason, and `active` can finally say what it now does:
57+
turning it off withdraws the access.
58+
59+
Also refreshes the `sys_sharing_rule` help text in the zh-CN / ja-JP / es-ES
60+
translation bundles, which still described `recipient_type` in terms of
61+
`department` (the enum value is `business_unit`) and told admins to enter a
62+
queue name for `recipient_id` (`queue` was removed in ADR-0078). The es-ES
63+
option labels for `position` / `unit_and_subordinates` were translated as
64+
"rol" — corrected to "Puesto" / "Unidad de negocio y subordinados".
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#3821 — `find()` must not turn an unknown column into "no rows".
5+
*
6+
* A SELECT projection naming a column the table lacks already had a retry
7+
* (select `*` and let the missing field simply be absent). An unknown ORDER BY
8+
* column did not: it fell through to `return []`, so the page came back empty
9+
* while `count()` — a separate statement — still reported the true total. The
10+
* UI reads that as "the records vanished". It surfaced through the sharing-rule
11+
* recipient picker, whose client mangled `'name asc'` into a list of
12+
* one-character column names.
13+
*
14+
* Rows matter more than their order, so an unsortable query returns the rows
15+
* unordered. These tests pin that, and that a genuine SQL error still throws.
16+
*/
17+
18+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
19+
import { SqlDriver } from '../src/index.js';
20+
21+
describe('SqlDriver find() recovers from unknown columns (objectstack#3821)', () => {
22+
let driver: SqlDriver;
23+
24+
beforeEach(async () => {
25+
driver = new SqlDriver({
26+
client: 'better-sqlite3',
27+
connection: { filename: ':memory:' },
28+
useNullAsDefault: true,
29+
});
30+
31+
await driver.initObjects([
32+
{
33+
name: 'member',
34+
fields: {
35+
name: { type: 'string' },
36+
rank: { type: 'integer' },
37+
},
38+
},
39+
]);
40+
41+
await driver.create('member', { id: 'm1', name: 'Carol', rank: 3 }, { bypassTenantAudit: true });
42+
await driver.create('member', { id: 'm2', name: 'Alice', rank: 1 }, { bypassTenantAudit: true });
43+
await driver.create('member', { id: 'm3', name: 'Bob', rank: 2 }, { bypassTenantAudit: true });
44+
});
45+
46+
afterEach(async () => {
47+
await driver.disconnect();
48+
});
49+
50+
it('returns the rows unordered when ORDER BY names a column the table lacks', async () => {
51+
const rows = await driver.find('member', {
52+
orderBy: [{ field: 'nosuchfield', order: 'asc' }],
53+
} as any);
54+
55+
expect(rows.map((r: any) => r.id).sort()).toEqual(['m1', 'm2', 'm3']);
56+
});
57+
58+
it('still sorts when every ORDER BY column exists', async () => {
59+
const rows = await driver.find('member', {
60+
orderBy: [{ field: 'rank', order: 'asc' }],
61+
} as any);
62+
63+
expect(rows.map((r: any) => r.name)).toEqual(['Alice', 'Bob', 'Carol']);
64+
});
65+
66+
it('drops only the unknown clause when a known and an unknown sort are mixed', async () => {
67+
// knex emits both columns in one ORDER BY, so the whole clause has to go;
68+
// what must NOT happen is losing the rows.
69+
const rows = await driver.find('member', {
70+
orderBy: [{ field: 'rank', order: 'asc' }, { field: 'nosuchfield', order: 'desc' }],
71+
} as any);
72+
73+
expect(rows).toHaveLength(3);
74+
});
75+
76+
it('recovers from an unknown SELECT projection and an unknown sort together', async () => {
77+
const rows = await driver.find('member', {
78+
fields: ['name', 'nosuchfield'],
79+
orderBy: [{ field: 'alsomissing', order: 'asc' }],
80+
} as any);
81+
82+
expect(rows).toHaveLength(3);
83+
expect(rows.map((r: any) => r.name).sort()).toEqual(['Alice', 'Bob', 'Carol']);
84+
});
85+
86+
it('keeps honouring the WHERE clause while recovering', async () => {
87+
const rows = await driver.find('member', {
88+
where: { rank: { $gte: 2 } },
89+
orderBy: [{ field: 'nosuchfield', order: 'asc' }],
90+
} as any);
91+
92+
expect(rows.map((r: any) => r.id).sort()).toEqual(['m1', 'm3']);
93+
});
94+
95+
it('propagates errors that are not about an unknown column', async () => {
96+
await expect(driver.find('no_such_table', {} as any)).rejects.toThrow();
97+
});
98+
});

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -811,7 +811,8 @@ export class SqlDriver implements IDataDriver {
811811
async find(object: string, query: QueryAST, options?: DriverOptions): Promise<any[]> {
812812
// Build everything EXCEPT the SELECT list, so the unknown-column retry
813813
// below can rebuild without re-deriving where/order/pagination.
814-
const buildBase = () => {
814+
const buildBase = (opts?: { withOrderBy?: boolean }) => {
815+
const withOrderBy = opts?.withOrderBy !== false;
815816
const b = this.getBuilder(object, options);
816817
this.applyTenantScope(b, object, options);
817818

@@ -821,7 +822,7 @@ export class SqlDriver implements IDataDriver {
821822
}
822823

823824
// ORDER BY
824-
if (query.orderBy && Array.isArray(query.orderBy)) {
825+
if (withOrderBy && query.orderBy && Array.isArray(query.orderBy)) {
825826
for (const item of query.orderBy) {
826827
if (item.field) {
827828
b.orderBy(this.remoteColumn(object, item.field, this.mapSortField(item.field)), item.order || 'asc');
@@ -866,15 +867,31 @@ export class SqlDriver implements IDataDriver {
866867
// only fires when the object's schema is populated in the registry —
867868
// this driver backstop holds even when it isn't (notably the cloud
868869
// multi-tenant runtime, where the projection otherwise zeroes the list).
869-
if (query.fields) {
870+
//
871+
// An unknown ORDER BY column is the same footgun one clause over
872+
// (objectstack#3821): a client that sorts by a field the table lacks
873+
// used to get an empty page with a non-zero `total` — "the rows are
874+
// there but none are shown". Rows matter more than their order, so
875+
// drop the sort and return them unordered rather than nothing. Ladder:
876+
// projection first (it is the likelier culprit and the cheaper thing
877+
// to lose), then the sort, then give up.
878+
const retries: Array<() => any> = [];
879+
if (query.fields) retries.push(() => buildBase().select('*'));
880+
if (query.orderBy && Array.isArray(query.orderBy) && query.orderBy.length > 0) {
881+
retries.push(() => buildBase({ withOrderBy: false }).select('*'));
882+
}
883+
results = [];
884+
let recovered = false;
885+
for (const retry of retries) {
870886
try {
871-
results = await buildBase().select('*');
887+
results = await retry();
888+
recovered = true;
889+
break;
872890
} catch {
873-
return [];
891+
// Try the next, broader fallback.
874892
}
875-
} else {
876-
return [];
877893
}
894+
if (!recovered) return [];
878895
} else {
879896
throw error;
880897
}

packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const SysSharingRule = ObjectSchema.create({
3737
// We still recommend `defineSharingRule({...})` for repo-controlled
3838
// baselines, but admins can safely create/edit/delete from the UI.
3939
userActions: { create: true, edit: true, delete: true, import: false },
40-
description: 'Declarative sharing rule that auto-materialises sys_record_share grants. Authored via defineSharingRule() in code or the Studio criteria builder.',
40+
description: 'Grants a group of people access to the records that match a condition.',
4141
displayNameField: 'name',
4242
nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
4343
titleFormat: '{label}',
@@ -99,7 +99,13 @@ export const SysSharingRule = ObjectSchema.create({
9999
label: 'Name',
100100
required: true,
101101
maxLength: 100,
102-
description: 'Unique snake_case rule name',
102+
// Field `description`s on this object are ADMIN-FACING help text rendered
103+
// under each input in Setup — not notes for the next engineer. They must
104+
// not name tables, columns, enum values, ADRs or third-party libraries:
105+
// the reader is a tenant admin who sees only the labels in the dropdown
106+
// (objectstack#3821). Implementation detail belongs in the doc comments
107+
// above, where it already is.
108+
description: 'Identifies the rule. Lowercase letters, digits and underscores only.',
103109
group: 'Identity',
104110
}),
105111

@@ -124,20 +130,20 @@ export const SysSharingRule = ObjectSchema.create({
124130
// instead of a free-text machine-name input. Falls back to a text input
125131
// when the `field:object-ref` widget is unavailable.
126132
widget: 'object-ref',
127-
description: 'Short object name (e.g. opportunity, account)',
133+
description: 'The object whose records this rule shares.',
128134
group: 'Target',
129135
}),
130136

131137
criteria_json: Field.textarea({
132-
label: 'Criteria (FilterCondition JSON)',
138+
label: 'Criteria',
133139
required: false,
134140
// Rendered as a visual criteria builder scoped to the selected object's
135141
// fields (dependsOn: object_name), storing the same JSON FilterCondition.
136142
// An "Edit as JSON" fallback keeps hand-authored / advanced filters
137143
// editable. Falls back to a textarea when the widget is unavailable.
138144
widget: 'filter-condition',
139145
dependsOn: ['object_name'],
140-
description: 'JSON FilterCondition matched against records of object_name. Empty = match all.',
146+
description: 'Which records to share. Leave empty to share every record of the object.',
141147
group: 'Target',
142148
}),
143149

@@ -150,7 +156,9 @@ export const SysSharingRule = ObjectSchema.create({
150156
label: 'Recipient Type',
151157
required: true,
152158
defaultValue: 'business_unit',
153-
description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `business_unit` walks the parent_business_unit_id tree; `team` is flat (better-auth); `position` expands the position\'s holders (positions are flat, ADR-0090 D3); `unit_and_subordinates` expands the named business unit PLUS every descendant unit\'s members via the sys_business_unit tree (ADR-0057 D5).',
159+
// The engine detail this used to spell out (which tree is walked, which
160+
// expansion is flat, the ADRs behind each) lives in the class doc above.
161+
description: 'Who receives access. Picking a team, business unit or position gives access to everyone in it. "Business unit and subordinates" also covers every unit below the one you pick.',
154162
group: 'Recipient',
155163
},
156164
),
@@ -166,7 +174,7 @@ export const SysSharingRule = ObjectSchema.create({
166174
// back to a text input when the widget is unavailable.
167175
widget: 'recipient-picker',
168176
dependsOn: ['recipient_type'],
169-
description: 'business-unit id / team id / position name / user id depending on recipient_type',
177+
description: 'The specific user, team, business unit or position that receives access.',
170178
group: 'Recipient',
171179
}),
172180

@@ -184,7 +192,7 @@ export const SysSharingRule = ObjectSchema.create({
184192
label: 'Active',
185193
required: false,
186194
defaultValue: true,
187-
description: 'Only active rules participate in lifecycle evaluation',
195+
description: 'Turn off to withdraw the access this rule granted, without deleting the rule.',
188196
group: 'Lifecycle',
189197
}),
190198

@@ -203,8 +211,7 @@ export const SysSharingRule = ObjectSchema.create({
203211
readonly: true,
204212
defaultValue: 'admin',
205213
description:
206-
'Record provenance (unified tri-state, A4 #2920): platform = framework built-in / ' +
207-
'package = app/package-declared (boot-seeded) / admin = tenant-created in Setup.',
214+
'Where this rule came from: built into the platform, installed with an app, or created here in Setup.',
208215
options: [
209216
{ value: 'platform', label: 'Platform' },
210217
{ value: 'package', label: 'Package' },
@@ -219,8 +226,7 @@ export const SysSharingRule = ObjectSchema.create({
219226
readonly: true,
220227
defaultValue: false,
221228
description:
222-
'Set when an admin edits a package-declared rule; boot seeding will no longer ' +
223-
'overwrite the row (deactivations survive redeploys). Meaningless on admin rows.',
229+
'Set once you edit a rule that came with an app, so your changes are kept when the app is updated.',
224230
group: 'System',
225231
}),
226232

0 commit comments

Comments
 (0)