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/20935-analytics-masked-field-not-queryable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/service-analytics': minor
---

fix(service-analytics)!: a field the caller is served masked is refused as a group key, an aggregate input, a filter or a sort key on every analytics face, whichever strategy serves the cube (#20935)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or stored shape is removed or renamed. The change refuses analytics queries that group, aggregate, filter or sort by a field the caller may only see masked, which the engine already refuses on the data API and on the ObjectQL strategy, so there is nothing for `objectstack migrate meta` to rewrite. The one public-surface addition is a new optional service hook. -->

**BREAKING for analytics queries on a SQL deployment that group, aggregate, filter or sort by a field the caller may only see masked.**

**What changed.** The field-level gate on `POST /api/v1/analytics/query`,
`POST /api/v1/analytics/sql` and `POST /api/v1/analytics/dataset/query` judged
each member by the caller's readable fields. A field whose `maskingRule` applies
to the caller is readable (its values are served masked), so the gate admitted
it, and the native-SQL strategy then grouped or filtered by the stored value.
The gate now also asks which fields the caller may query on, and refuses a
member naming a masked field with `403 PERMISSION_DENIED`, in the words the
engine uses for the same field. The ObjectQL strategy and the data API already
refused these queries.

**What is not affected.** A caller who holds the capability that lifts a
field's masking rule queries the field as before. A system context is
unaffected. A query that names no masked field answers as before.

**New hook.** `AnalyticsServiceConfig.getQueryableFields(object, context)`
supplies the answer. `AnalyticsServicePlugin` wires it to the `security`
service's `getQueryableFields`. When that service predates the method, or
answers "no answer", the plugin treats every field that declares a
`maskingRule` as not queryable, for every caller. A host that
constructs `AnalyticsService` itself with `getReadableFields` and without
`getQueryableFields` is warned once at construction.

**If a widget stopped answering for some users,** it groups or filters by a
field those users see masked. Give the users who need it the capability the
field's `requiredPermissions` names, or build the widget on fields they can query.
5 changes: 5 additions & 0 deletions .changeset/20935-plugin-security-queryable-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-security': minor
---

The `security` service implements `getQueryableFields(object, context)` (#20935). A field is in the answer exactly when a query naming it as a filter, a sort key, a group key or an aggregate input passes the engine's field guards: the answer is read from the one field map the predicate guard and the aggregate-input guard now share (permission sets, field grants, the `requiredPermissions` check, the on-behalf-of delegator intersection, and every field whose masking rule applies to the caller). The two guards refuse exactly what they refused before.
11 changes: 11 additions & 0 deletions .changeset/20935-security-service-queryable-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 `getQueryableFields(object, context)`: the field names field-level security lets the caller filter, sort, group or aggregate by on the object, the query-side twin of `getReadableFields` (#20935).

Clause-②: yes (widening)

- It is the exact complement of the fields the engine's field guards refuse when a query names them as a filter, a sort key, a group key or an aggregate input. It is a subset of `getReadableFields`, and the two differ by exactly the fields the caller is served masked: a field whose `maskingRule` applies to the caller is readable (served, its value replaced) and not queryable.
- It fails soft like `getReadableFields`: `undefined` means no answer, `[]` means no field is queryable. A system context gets every field.
- It is optional. A consumer checks `typeof svc.getQueryableFields === 'function'`. When the method is missing, or answers `undefined`, the consumer must treat every field that declares a `maskingRule` as not queryable, whoever the caller is. Falling back to `getReadableFields` alone would admit exactly the masked fields.
202 changes: 202 additions & 0 deletions packages/plugins/plugin-security/src/get-queryable-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20935] `getQueryableFields` — the query-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 query
* naming only that field — as a filter, as a sort key, as a group key and as an
* aggregate input — and requires "the middleware admitted it" to equal "the
* field is in `getQueryableFields`". The published answer and the engine's two
* query guards come from one derivation; this is what keeps them one.
*
* The second block pins the answers the contract names, above all the one that
* makes this method necessary: a field the caller is served MASKED is in the
* read projection and NOT in the query one.
*
* Harness mirrors `get-writable-fields.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 the object, and two
* fields not at all — one of them declares a masking rule, which never widens
* an explicit deny.
*/
const MEMBER_SET = {
name: 'member_default',
label: 'Reader',
objects: { ledger: CRUD },
fields: {
'ledger.secret': { readable: false, editable: false },
'ledger.denied_masked': { readable: false, editable: false },
},
} as unknown as PermissionSet;

/** The same grant plus the capability that lifts `gated_masked`'s rule. */
const UNMASK_SET = {
...(MEMBER_SET as object),
label: 'Reader who may see the gated field unmasked',
systemPermissions: ['view_gated'],
} as unknown as PermissionSet;

/** An agent's own set, holding the capability: the D10 case turns on the intersection alone. */
const AGENT_SET = {
name: 'agent_reader',
label: 'Agent',
objects: { ledger: CRUD },
systemPermissions: ['view_gated'],
} as unknown as PermissionSet;

const SCHEMAS: Record<string, unknown> = {
ledger: {
name: 'ledger',
fields: {
title: { type: 'text', label: 'Title' },
// Masked unless the caller holds `view_gated` (the rule's unmask gate).
gated_masked: { type: 'text', label: 'Gated', maskingRule: { keepHead: 1, keepTail: 1 }, requiredPermissions: ['view_gated'] },
// A rule with no gate: masked for every non-system caller.
always_masked: { type: 'text', label: 'Always', maskingRule: 'name' },
secret: { type: 'text', label: 'Secret' },
denied_masked: { type: 'text', label: 'Denied', maskingRule: 'name' },
},
},
};
/** The field universe the plugin resolves: the schema's fields plus `id`. */
const FIELDS = ['id', 'title', 'gated_masked', 'always_masked', 'secret', 'denied_masked'];

const MEMBER_CTX = { userId: 'u_member', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' };
const LIVE_DELEGATOR = 'u_boss';
const AGENT_CTX = { userId: 'u_agent', tenantId: 'org-1', positions: [], permissions: ['agent_reader'], 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 };
}

/** Each way a query can name one field: the four positions the engine's two query guards judge. */
const PROBES: ReadonlyArray<readonly [string, 'find' | 'aggregate', (field: string) => Record<string, unknown>]> = [
['a filter', 'find', (field) => ({ where: { [field]: 'v' } })],
['a sort key', 'find', (field) => ({ where: {}, orderBy: [{ field, order: 'asc' }] })],
['a group key', 'aggregate', (field) => ({ groupBy: [field], aggregations: [{ function: 'count', alias: 'n' }] })],
['an aggregate input', 'aggregate', (field) => ({ aggregations: [{ function: 'max', field, alias: 'm' }] })],
];

async function middlewareAdmits(
middleware: (opCtx: any, next: () => Promise<void>) => Promise<void>,
operation: 'find' | 'aggregate',
context: Record<string, unknown>,
ast: Record<string, unknown>,
): Promise<{ admitted: true } | { admitted: false; code?: unknown; status?: unknown }> {
try {
await middleware({ object: 'ledger', operation, context: { ...context }, options: {}, ast }, async () => {});
return { admitted: true };
} catch (e) {
const err = e as { code?: unknown; status?: unknown; statusCode?: unknown };
return { admitted: false, code: err.code, status: err.status ?? err.statusCode };
}
}

describe('[#20935] getQueryableFields agrees with the middleware\'s query guards, field for field', () => {
const CASES: Array<{ label: string; sets: PermissionSet[]; context: Record<string, unknown>; queryable: string[] }> = [
{ label: 'a member: two fields served masked, two hidden', sets: [MEMBER_SET], context: MEMBER_CTX, queryable: ['id', 'title'] },
{ label: 'a member holding the capability that lifts one rule', sets: [UNMASK_SET], context: MEMBER_CTX, queryable: ['id', 'title', 'gated_masked'] },
{ label: 'an agent holding that capability, acting for nobody', sets: [AGENT_SET, MEMBER_SET], context: AGENT_CTX, queryable: ['id', 'title', 'gated_masked'] },
{ label: 'the same agent acting for a delegator who does not hold it', sets: [AGENT_SET, MEMBER_SET], context: DELEGATED_AGENT_CTX, queryable: ['id', 'title'] },
];

for (const c of CASES) {
it(c.label, async () => {
const { plugin, middleware } = await boot(c.sets);
const queryable = await plugin.getQueryableFields('ledger', c.context);
// The expected list keeps each case honest about what it exercises; the
// equivalence below is the pin.
expect(queryable).toEqual(c.queryable);
for (const field of FIELDS) {
for (const [position, operation, ast] of PROBES) {
const verdict = await middlewareAdmits(middleware, operation, c.context, ast(field));
if (queryable!.includes(field)) {
expect(verdict, `${field} as ${position}`).toEqual({ admitted: true });
} else {
expect(verdict, `${field} as ${position}`).toMatchObject({ admitted: false, code: 'PERMISSION_DENIED', status: 403 });
}
}
}
});
}
});

describe('[#20935] getQueryableFields — the answers the contract names', () => {
it('a field the caller is served MASKED is readable and NOT queryable; the difference is exactly the masked fields', async () => {
const { plugin } = await boot([MEMBER_SET]);
const readable = await plugin.getReadableFields('ledger', MEMBER_CTX);
const queryable = await plugin.getQueryableFields('ledger', MEMBER_CTX);
expect(readable).toEqual(['id', 'title', 'gated_masked', 'always_masked']);
expect(queryable).toEqual(['id', 'title']);
expect(queryable!.every((f) => readable!.includes(f))).toBe(true);
expect(readable!.filter((f) => !queryable!.includes(f))).toEqual(['gated_masked', 'always_masked']);
});

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

it('no permission sets resolved: the full field set, as the middleware skips both guards', async () => {
const { plugin } = await boot([], { noBaseline: true });
expect(await plugin.getQueryableFields('ledger', MEMBER_CTX)).toEqual(FIELDS);
});

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

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

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