Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/18386-export-import-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/rest': minor
---

feat(rest): `GET /api/v1/data/:object/export?template=true` answers an xlsx import template for the object (#18386)

Clause-②: yes (widening)

The export door takes one more query parameter, `template`. `template=true`
answers an `.xlsx` workbook with no data rows; `template=false` answers the export.
Without a `template` parameter the export is exactly as before, byte for byte.

- **Columns.** Every field of the object except
those marked `system` or `readonly`, and `formula`, `summary` and
`autonumber` fields, in the order the object declares them. A `hidden` field
that can be written is a column. The seven columns the platform adds to every
object (`organization_id`, `created_at`, `created_by`, `updated_at`,
`updated_by`, `owner_id`, `owning_business_unit_id`) are never template
columns. A field the caller's field-level security does not let them edit is
left out. If the security service cannot say which fields the caller can edit,
the columns are narrowed by the fields the caller can read instead. The
instructions sheet then says so, and the `X-Export-Template-Projection`
response header reads `readable` instead of `writable` (`none` when no
field-level security applies). An explicit `?fields=` list is used as sent.
- **First sheet.** The header row, with ` *` after each field that is required
and has no default value, and one example row to replace or delete. Select,
radio and boolean columns carry a dropdown.
- **Second sheet.** One row per column: the field's API name, its type, whether
it is required, and the values the import accepts for it.
- **Language.** The sheets are in Chinese for a `zh` request locale
(`?locale=` or `Accept-Language`) and in English otherwise.

The same two permission checks as the export apply: an object that does not
expose export answers `405`, and a caller without the export permission answers
`403`. `template` with a value other than `true` or `false`, a `format` other
than `xlsx`, or any of `limit`, `page`, `filter`, `search`, `searchFields`,
`orderby` or `header` beside `template=true`, answers `400 VALIDATION_ERROR`.
5 changes: 5 additions & 0 deletions .changeset/18386-plugin-security-writable-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-security': minor
---

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.
11 changes: 11 additions & 0 deletions .changeset/18386-security-service-writable-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
---

`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).

Clause-②: yes (widening)

- 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.
- It fails soft like `getReadableFields`: `undefined` means no answer, `[]` means no field is writable. A system context gets every field.
- 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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ that silently does not happen.
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` |
| 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` |
| 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` |
Expand Down
182 changes: 182 additions & 0 deletions packages/plugins/plugin-security/src/get-writable-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#18386] `getWritableFields` — the write-side twin of `getReadableFields`.
*
* The first block is an EQUIVALENCE, not a table of expected lists: for every
* field of the object it drives the REAL registered middleware with a write
* whose payload names only that field, and requires "the middleware admitted
* it" to equal "the field is in `getWritableFields`". The two answers come from
* one derivation; this is what keeps them one.
*
* Harness mirrors `can-write-object-admission.test.ts`.
*/

import { describe, it, expect, vi } from 'vitest';
import type { PermissionSet } from '@objectstack/spec/security';
import { SecurityPlugin } from './security-plugin.js';

const CRUD = { allowRead: true, allowCreate: true, allowEdit: true };

/** The baseline every authenticated caller resolves: reads `account`, may not edit it; `secret` neither. */
const LOCKED_SET = {
name: 'member_default',
label: 'Writer, FLS-locked',
objects: { invoice: CRUD },
fields: {
'invoice.account': { readable: true, editable: false },
'invoice.secret': { readable: false, editable: false },
},
} as unknown as PermissionSet;

/** The same grant with no field rules — every field passes step 2.5. */
const OPEN_SET = { name: 'member_default', label: 'Writer', objects: { invoice: CRUD } } as unknown as PermissionSet;

/** …and with the capability `margin` requires (ADR-0066 D3). */
const CAPABLE_SET = {
name: 'member_default',
label: 'Writer with the margin capability',
objects: { invoice: CRUD },
systemPermissions: ['view_margin'],
} as unknown as PermissionSet;

/** An agent's own set, which may edit `account`: the D10 case turns on the intersection alone. */
const AGENT_SET = {
name: 'agent_writer',
label: 'Agent',
objects: { invoice: CRUD },
fields: { 'invoice.account': { readable: true, editable: true } },
} as unknown as PermissionSet;

const SCHEMAS: Record<string, unknown> = {
invoice: {
name: 'invoice',
fields: {
title: { type: 'text', label: 'Title' },
account: { type: 'lookup', label: 'Account', reference: 'crm_account' },
secret: { type: 'text', label: 'Secret' },
margin: { type: 'number', label: 'Margin', requiredPermissions: ['view_margin'] },
},
},
};
/** The field universe the plugin resolves: the schema's fields plus `id`. */
const FIELDS = ['id', 'title', 'account', 'secret', 'margin'];
const PAYLOAD_VALUE: Record<string, unknown> = { id: 'inv_1', title: 'x', account: 'acc_1', secret: 's', margin: 1 };

const WRITER_CTX = { userId: 'u_writer', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' };
const LIVE_DELEGATOR = 'u_boss';
const AGENT_CTX = { userId: 'u_agent', tenantId: 'org-1', positions: [], permissions: ['agent_writer'], posture: 'MEMBER' };
const DELEGATED_AGENT_CTX = { ...AGENT_CTX, onBehalfOf: { userId: LIVE_DELEGATOR } };

async function boot(sets: PermissionSet[], opts: { noBaseline?: boolean } = {}) {
const middlewares: Array<(opCtx: any, next: () => Promise<void>) => Promise<void>> = [];
const services: Record<string, unknown> = {
manifest: { register: vi.fn() },
objectql: {
registerMiddleware: (mw: any) => middlewares.push(mw),
getSchema: (name: string) => SCHEMAS[name],
findOne: vi.fn(async (_object: string, query: any) =>
(query?.where?.id === LIVE_DELEGATOR ? { id: LIVE_DELEGATOR, email: 'boss@example.test' } : null)),
},
metadata: {
get: async (_type: string, name: string) => SCHEMAS[name],
list: async () => sets,
},
};
const registerService = vi.fn();
const ctx: Record<string, unknown> = {
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerService,
getService: (name: string) => {
if (!(name in services)) throw new Error(`service not registered: ${name}`);
return services[name];
},
};
const plugin = new SecurityPlugin(
opts.noBaseline
? { defaultPermissionSets: [], fallbackPermissionSet: null }
: { fallbackPermissionSet: 'member_default' },
);
await plugin.init(ctx as any);
await plugin.start(ctx as any);
if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware');
return { plugin, middleware: middlewares[0], registerService };
}

async function middlewareAdmits(
middleware: (opCtx: any, next: () => Promise<void>) => Promise<void>,
operation: 'insert' | 'update',
context: Record<string, unknown>,
data: Record<string, unknown>,
): Promise<boolean> {
try {
await middleware(
{ object: 'invoice', operation, context: { ...context }, options: {}, ast: { where: {} }, data },
async () => {},
);
return true;
} catch {
return false;
}
}

describe('getWritableFields agrees with the middleware\'s write gate, field for field', () => {
const CASES: Array<{ label: string; sets: PermissionSet[]; context: Record<string, unknown>; writable: string[] }> = [
{ label: 'a field read but not editable, and one neither', sets: [LOCKED_SET], context: WRITER_CTX, writable: ['id', 'title'] },
{ label: 'no field rules', sets: [OPEN_SET], context: WRITER_CTX, writable: ['id', 'title', 'account', 'secret'] },
{ label: 'the field capability held', sets: [CAPABLE_SET], context: WRITER_CTX, writable: FIELDS },
{ label: 'a delegated agent whose delegator may not edit the field', sets: [AGENT_SET, LOCKED_SET], context: DELEGATED_AGENT_CTX, writable: ['id', 'title'] },
{ label: 'the same agent acting for nobody', sets: [AGENT_SET, LOCKED_SET], context: AGENT_CTX, writable: ['id', 'title', 'account'] },
];

for (const c of CASES) {
it(c.label, async () => {
const { plugin, middleware } = await boot(c.sets);
const writable = await plugin.getWritableFields('invoice', c.context);
// The expected list keeps each case honest about what it exercises; the
// equivalence below is the pin.
expect(writable).toEqual(c.writable);
for (const operation of ['insert', 'update'] as const) {
for (const field of FIELDS) {
const admitted = await middlewareAdmits(middleware, operation, c.context, { [field]: PAYLOAD_VALUE[field] });
expect(admitted, `${operation} naming ${field}`).toBe(writable!.includes(field));
}
}
});
}
});

describe('getWritableFields — the answers the contract names', () => {
it('a field the caller may read but not edit is readable and NOT writable', async () => {
const { plugin } = await boot([LOCKED_SET]);
expect(await plugin.getReadableFields('invoice', WRITER_CTX)).toContain('account');
expect(await plugin.getWritableFields('invoice', WRITER_CTX)).not.toContain('account');
});

it('a system context bypasses: the full field set', async () => {
const { plugin } = await boot([LOCKED_SET]);
expect(await plugin.getWritableFields('invoice', { isSystem: true })).toEqual(FIELDS);
});

it('no permission sets resolved: the full field set, as the middleware skips its write gate', async () => {
const { plugin } = await boot([], { noBaseline: true });
expect(await plugin.getWritableFields('invoice', WRITER_CTX)).toEqual(FIELDS);
});

it('an unresolvable object is no answer (undefined), not an empty one', async () => {
const { plugin } = await boot([LOCKED_SET]);
expect(await plugin.getWritableFields('no_such_object', WRITER_CTX)).toBeUndefined();
});

it('a delegator that does not exist fails closed: []', async () => {
const { plugin } = await boot([AGENT_SET, LOCKED_SET]);
expect(await plugin.getWritableFields('invoice', { ...AGENT_CTX, onBehalfOf: { userId: 'u_ghost' } })).toEqual([]);
});

it('is exposed on the registered "security" service', async () => {
const { registerService } = await boot([LOCKED_SET]);
const svc = registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1];
expect(typeof svc?.getWritableFields).toBe('function');
expect(await svc.getWritableFields('invoice', WRITER_CTX)).toEqual(['id', 'title']);
});
});
Loading
Loading