Skip to content

Commit e5c7d07

Browse files
feat(rest): GET /data/:object/export?template=true answers an xlsx import template (#18386) (#20683)
Part of #18386. Acceptance 6 (dropdowns in Excel, WPS, Numbers and Google Sheets) needs a person to open the file, so the card stays open for that check after this lands. The objectui half is objectstack-ai/objectui#9600. Clause-②: yes (widening) `GET /api/v1/data/:object/export?template=true` answers an xlsx import template with no data rows. Its columns leave out `system`, `readonly`, `formula`, `summary` and `autonumber` fields, a writable `hidden` field is kept, and field-level security narrows the rest to what the caller may edit. Required columns with no default carry `*`, select, radio and boolean columns get dropdowns, and an instructions sheet states the spellings the import reader accepts. With no `template` parameter the export is unchanged, byte for byte. `ISecurityService` gains an optional `getWritableFields`, which `plugin-security` implements from the write gate's own field mask. A security service without it gets the read projection instead, and the response says so in the `X-Export-Template-Projection` header and on the instructions sheet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7afdc5c commit e5c7d07

13 files changed

Lines changed: 2231 additions & 38 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
feat(rest): `GET /api/v1/data/:object/export?template=true` answers an xlsx import template for the object (#18386)
6+
7+
Clause-②: yes (widening)
8+
9+
The export door takes one more query parameter, `template`. `template=true`
10+
answers an `.xlsx` workbook with no data rows; `template=false` answers the export.
11+
Without a `template` parameter the export is exactly as before, byte for byte.
12+
13+
- **Columns.** Every field of the object except
14+
those marked `system` or `readonly`, and `formula`, `summary` and
15+
`autonumber` fields, in the order the object declares them. A `hidden` field
16+
that can be written is a column. The seven columns the platform adds to every
17+
object (`organization_id`, `created_at`, `created_by`, `updated_at`,
18+
`updated_by`, `owner_id`, `owning_business_unit_id`) are never template
19+
columns. A field the caller's field-level security does not let them edit is
20+
left out. If the security service cannot say which fields the caller can edit,
21+
the columns are narrowed by the fields the caller can read instead. The
22+
instructions sheet then says so, and the `X-Export-Template-Projection`
23+
response header reads `readable` instead of `writable` (`none` when no
24+
field-level security applies). An explicit `?fields=` list is used as sent.
25+
- **First sheet.** The header row, with ` *` after each field that is required
26+
and has no default value, and one example row to replace or delete. Select,
27+
radio and boolean columns carry a dropdown.
28+
- **Second sheet.** One row per column: the field's API name, its type, whether
29+
it is required, and the values the import accepts for it.
30+
- **Language.** The sheets are in Chinese for a `zh` request locale
31+
(`?locale=` or `Accept-Language`) and in English otherwise.
32+
33+
The same two permission checks as the export apply: an object that does not
34+
expose export answers `405`, and a caller without the export permission answers
35+
`403`. `template` with a value other than `true` or `false`, a `format` other
36+
than `xlsx`, or any of `limit`, `page`, `filter`, `search`, `searchFields`,
37+
`orderby` or `header` beside `template=true`, answers `400 VALIDATION_ERROR`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
The `security` service implements `getWritableFields(object, context)` (#18386). It uses the same permission sets, field grants, `requiredPermissions` check and on-behalf-of delegator intersection as the write gate. A field is in the answer exactly when a write naming it passes the field-level-security check. `getReadableFields` now shares that derivation, and its answers are unchanged.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`ISecurityService` (`@objectstack/spec/contracts`) gains an optional `getWritableFields(object, context)`: the field names field-level security lets the caller write on the object, the write-side twin of `getReadableFields` (#18386).
6+
7+
Clause-②: yes (widening)
8+
9+
- It is the exact complement of the fields the write path's field-level-security gate refuses when a payload names them. Neither the object permission nor a field's own rules (`readonly`, `system`, a `formula`, `summary` or `autonumber` type) are part of the answer.
10+
- It fails soft like `getReadableFields`: `undefined` means no answer, `[]` means no field is writable. A system context gets every field.
11+
- It is optional. A consumer checks `typeof svc.getWritableFields === 'function'`. When the method is missing, the consumer may narrow by `getReadableFields` instead, and must say in its response that it did.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ that silently does not happen.
9999
| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: every gate under it in this lane at once, down to and including the write-bypass row that widens the effective write scope to `org` (row 7) — this is the single largest behaviour on the page | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
100100
| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside `packages/plugins/plugin-security/src/security-plugin.ts#start` |
101101
| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter` |
102-
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields` |
102+
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#resolveProjectionFieldMask` |
103103
| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` |
104104
| 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` |
105105
| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` |
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18386] `getWritableFields` — the write-side twin of `getReadableFields`.
5+
*
6+
* The first block is an EQUIVALENCE, not a table of expected lists: for every
7+
* field of the object it drives the REAL registered middleware with a write
8+
* whose payload names only that field, and requires "the middleware admitted
9+
* it" to equal "the field is in `getWritableFields`". The two answers come from
10+
* one derivation; this is what keeps them one.
11+
*
12+
* Harness mirrors `can-write-object-admission.test.ts`.
13+
*/
14+
15+
import { describe, it, expect, vi } from 'vitest';
16+
import type { PermissionSet } from '@objectstack/spec/security';
17+
import { SecurityPlugin } from './security-plugin.js';
18+
19+
const CRUD = { allowRead: true, allowCreate: true, allowEdit: true };
20+
21+
/** The baseline every authenticated caller resolves: reads `account`, may not edit it; `secret` neither. */
22+
const LOCKED_SET = {
23+
name: 'member_default',
24+
label: 'Writer, FLS-locked',
25+
objects: { invoice: CRUD },
26+
fields: {
27+
'invoice.account': { readable: true, editable: false },
28+
'invoice.secret': { readable: false, editable: false },
29+
},
30+
} as unknown as PermissionSet;
31+
32+
/** The same grant with no field rules — every field passes step 2.5. */
33+
const OPEN_SET = { name: 'member_default', label: 'Writer', objects: { invoice: CRUD } } as unknown as PermissionSet;
34+
35+
/** …and with the capability `margin` requires (ADR-0066 D3). */
36+
const CAPABLE_SET = {
37+
name: 'member_default',
38+
label: 'Writer with the margin capability',
39+
objects: { invoice: CRUD },
40+
systemPermissions: ['view_margin'],
41+
} as unknown as PermissionSet;
42+
43+
/** An agent's own set, which may edit `account`: the D10 case turns on the intersection alone. */
44+
const AGENT_SET = {
45+
name: 'agent_writer',
46+
label: 'Agent',
47+
objects: { invoice: CRUD },
48+
fields: { 'invoice.account': { readable: true, editable: true } },
49+
} as unknown as PermissionSet;
50+
51+
const SCHEMAS: Record<string, unknown> = {
52+
invoice: {
53+
name: 'invoice',
54+
fields: {
55+
title: { type: 'text', label: 'Title' },
56+
account: { type: 'lookup', label: 'Account', reference: 'crm_account' },
57+
secret: { type: 'text', label: 'Secret' },
58+
margin: { type: 'number', label: 'Margin', requiredPermissions: ['view_margin'] },
59+
},
60+
},
61+
};
62+
/** The field universe the plugin resolves: the schema's fields plus `id`. */
63+
const FIELDS = ['id', 'title', 'account', 'secret', 'margin'];
64+
const PAYLOAD_VALUE: Record<string, unknown> = { id: 'inv_1', title: 'x', account: 'acc_1', secret: 's', margin: 1 };
65+
66+
const WRITER_CTX = { userId: 'u_writer', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' };
67+
const LIVE_DELEGATOR = 'u_boss';
68+
const AGENT_CTX = { userId: 'u_agent', tenantId: 'org-1', positions: [], permissions: ['agent_writer'], posture: 'MEMBER' };
69+
const DELEGATED_AGENT_CTX = { ...AGENT_CTX, onBehalfOf: { userId: LIVE_DELEGATOR } };
70+
71+
async function boot(sets: PermissionSet[], opts: { noBaseline?: boolean } = {}) {
72+
const middlewares: Array<(opCtx: any, next: () => Promise<void>) => Promise<void>> = [];
73+
const services: Record<string, unknown> = {
74+
manifest: { register: vi.fn() },
75+
objectql: {
76+
registerMiddleware: (mw: any) => middlewares.push(mw),
77+
getSchema: (name: string) => SCHEMAS[name],
78+
findOne: vi.fn(async (_object: string, query: any) =>
79+
(query?.where?.id === LIVE_DELEGATOR ? { id: LIVE_DELEGATOR, email: 'boss@example.test' } : null)),
80+
},
81+
metadata: {
82+
get: async (_type: string, name: string) => SCHEMAS[name],
83+
list: async () => sets,
84+
},
85+
};
86+
const registerService = vi.fn();
87+
const ctx: Record<string, unknown> = {
88+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
89+
registerService,
90+
getService: (name: string) => {
91+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
92+
return services[name];
93+
},
94+
};
95+
const plugin = new SecurityPlugin(
96+
opts.noBaseline
97+
? { defaultPermissionSets: [], fallbackPermissionSet: null }
98+
: { fallbackPermissionSet: 'member_default' },
99+
);
100+
await plugin.init(ctx as any);
101+
await plugin.start(ctx as any);
102+
if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware');
103+
return { plugin, middleware: middlewares[0], registerService };
104+
}
105+
106+
async function middlewareAdmits(
107+
middleware: (opCtx: any, next: () => Promise<void>) => Promise<void>,
108+
operation: 'insert' | 'update',
109+
context: Record<string, unknown>,
110+
data: Record<string, unknown>,
111+
): Promise<boolean> {
112+
try {
113+
await middleware(
114+
{ object: 'invoice', operation, context: { ...context }, options: {}, ast: { where: {} }, data },
115+
async () => {},
116+
);
117+
return true;
118+
} catch {
119+
return false;
120+
}
121+
}
122+
123+
describe('getWritableFields agrees with the middleware\'s write gate, field for field', () => {
124+
const CASES: Array<{ label: string; sets: PermissionSet[]; context: Record<string, unknown>; writable: string[] }> = [
125+
{ label: 'a field read but not editable, and one neither', sets: [LOCKED_SET], context: WRITER_CTX, writable: ['id', 'title'] },
126+
{ label: 'no field rules', sets: [OPEN_SET], context: WRITER_CTX, writable: ['id', 'title', 'account', 'secret'] },
127+
{ label: 'the field capability held', sets: [CAPABLE_SET], context: WRITER_CTX, writable: FIELDS },
128+
{ label: 'a delegated agent whose delegator may not edit the field', sets: [AGENT_SET, LOCKED_SET], context: DELEGATED_AGENT_CTX, writable: ['id', 'title'] },
129+
{ label: 'the same agent acting for nobody', sets: [AGENT_SET, LOCKED_SET], context: AGENT_CTX, writable: ['id', 'title', 'account'] },
130+
];
131+
132+
for (const c of CASES) {
133+
it(c.label, async () => {
134+
const { plugin, middleware } = await boot(c.sets);
135+
const writable = await plugin.getWritableFields('invoice', c.context);
136+
// The expected list keeps each case honest about what it exercises; the
137+
// equivalence below is the pin.
138+
expect(writable).toEqual(c.writable);
139+
for (const operation of ['insert', 'update'] as const) {
140+
for (const field of FIELDS) {
141+
const admitted = await middlewareAdmits(middleware, operation, c.context, { [field]: PAYLOAD_VALUE[field] });
142+
expect(admitted, `${operation} naming ${field}`).toBe(writable!.includes(field));
143+
}
144+
}
145+
});
146+
}
147+
});
148+
149+
describe('getWritableFields — the answers the contract names', () => {
150+
it('a field the caller may read but not edit is readable and NOT writable', async () => {
151+
const { plugin } = await boot([LOCKED_SET]);
152+
expect(await plugin.getReadableFields('invoice', WRITER_CTX)).toContain('account');
153+
expect(await plugin.getWritableFields('invoice', WRITER_CTX)).not.toContain('account');
154+
});
155+
156+
it('a system context bypasses: the full field set', async () => {
157+
const { plugin } = await boot([LOCKED_SET]);
158+
expect(await plugin.getWritableFields('invoice', { isSystem: true })).toEqual(FIELDS);
159+
});
160+
161+
it('no permission sets resolved: the full field set, as the middleware skips its write gate', async () => {
162+
const { plugin } = await boot([], { noBaseline: true });
163+
expect(await plugin.getWritableFields('invoice', WRITER_CTX)).toEqual(FIELDS);
164+
});
165+
166+
it('an unresolvable object is no answer (undefined), not an empty one', async () => {
167+
const { plugin } = await boot([LOCKED_SET]);
168+
expect(await plugin.getWritableFields('no_such_object', WRITER_CTX)).toBeUndefined();
169+
});
170+
171+
it('a delegator that does not exist fails closed: []', async () => {
172+
const { plugin } = await boot([AGENT_SET, LOCKED_SET]);
173+
expect(await plugin.getWritableFields('invoice', { ...AGENT_CTX, onBehalfOf: { userId: 'u_ghost' } })).toEqual([]);
174+
});
175+
176+
it('is exposed on the registered "security" service', async () => {
177+
const { registerService } = await boot([LOCKED_SET]);
178+
const svc = registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1];
179+
expect(typeof svc?.getWritableFields).toBe('function');
180+
expect(await svc.getWritableFields('invoice', WRITER_CTX)).toEqual(['id', 'title']);
181+
});
182+
});

0 commit comments

Comments
 (0)