| @objectstack/spec | minor |
|---|---|
| @objectstack/plugin-security | minor |
| @objectstack/plugin-hono-server | patch |
| @objectstack/rest | minor |
feat(security): ENFORCE the user-level export axis on the server (#3544)
allowExport landed as a spec bit plus a /me/permissions annotation, which
hid the client's Export button — and nothing else. Because export ⊆ list, the
REST export route streams through findData and the engine middleware sees an
ordinary find gated by allowRead, so no code path ever read the bit: a caller
holding allowExport: false could still curl /api/v1/data/:object/export and drain the whole table. Declared, not enforced.
- plugin-security
PermissionEvaluator.checkObjectPermission('export', …)is now a real decision:export= read granted ∧ not explicitly denied.allowExportstays out ofOPERATION_TO_PERMISSIONon purpose — that map means "the bit must be truthy", which would have denied export to every permission set authored before the axis existed. The new exportedresolveUserExportAllowed()folds the tri-state across sets (truebeatsfalsebeats unset) exactly as the/me/permissionsmerge does. - spec
ISecurityServicegainscanExport(object, context)— the question a bulk-egress door outside the engine middleware has to ask before it reads. Fails CLOSED;isSystemand an empty set resolution bypass, mirroring the middleware. - rest
GET /data/:object/exportcalls it and answers 403EXPORT_NOT_PERMITTEDbefore the first chunk is fetched. Distinct from the object-level 405OBJECT_API_METHOD_NOT_ALLOWED, which still runs first: 405 says the object exposes no export, 403 says this caller may not use it. No security service (noplugin-security⇒ no permission sets) → allowed, the same fail-open posture as every other permission gate in that layer; service present but unable to answer → denied. - plugin-hono-server the
/me/permissionsannotation now falls back to the'*'entry's export bit when a per-object entry declares none, matching the evaluator's own wildcard fallback — so a set that denies export wholesale via'*'no longer offers a button the server refuses.
Backward-compatible: allowExport is still an opt-out with no default, so an
unset bit inherits read and existing permission sets behave exactly as before.
Only a permission set that explicitly sets allowExport: false changes — and it
now changes on the server, which is the point.
Implementers of ISecurityService outside this repo must add canExport; the
interface member is required, matching how getReadableFields was added.
Consumers still feature-detect (typeof svc.canExport === 'function'), so a
partial implementation degrades rather than throwing.