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
36 changes: 36 additions & 0 deletions .changeset/security-service-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-security": patch
"@objectstack/rest": patch
---

feat(spec): publish `ISecurityService` — the `security` service surface becomes an enforced contract

The `security` service registers seven cross-package methods (`getReadFilter`,
`getReadableFields`, `resolvePermissionSetNames`, `explain`, and the three
audience-binding suggestion calls) but had no contract in
`@objectstack/spec/contracts`. Consumers duck-typed it, and each one invented its
own fallback for a missing method or an "empty" answer — with more consumers
arriving, that is a drift surface.

`ISecurityService` now documents the surface, and both ends are typed against it
so it is **enforced rather than declared**: `plugin-security` assigns its
registration to `ISecurityService` (a renamed, dropped, or re-typed method fails
that build), and the REST layer resolves the service as a `Partial<ISecurityService>`
(so call sites must keep feature-detecting instead of assuming the full surface).

The contract makes explicit the one thing consumers cannot guess — that the
methods do **not** share a failure convention:

- `getReadFilter` fails **CLOSED**: a resolution failure yields a deny filter
matching zero rows, never `undefined`. `undefined` means "no row restriction",
and nothing else.
- `getReadableFields` fails **SOFT**: `undefined` means "no answer, use your own
projection", while `[]` is authoritative and means "no field is readable" —
opposite instructions that a consumer must not conflate.

Typing the producer immediately caught one real discrepancy, fixed here:
`getReadFilter` declared `Promise<Record<string, unknown> | null | undefined>`
while every return path yields a filter or `undefined` (`filter ?? undefined`
normalizes the null away). The dead `| null` is removed, so "no restriction" has
exactly one representation. Type-level only — no runtime behaviour changes.
1 change: 1 addition & 0 deletions content/docs/kernel/contracts/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Service contracts are **TypeScript interfaces** that define the boundaries betwe
| Contract | Interface | Description |
|:---|:---|:---|
| Auth Service | `IAuthService` | Authentication — login, verify, logout, session management |
| Security Service | `ISecurityService` | Query surface for access decisions — row-level read scope, readable-field projection, effective permission sets, access explanation |
| Sharing Service | `ISharingService` | Record sharing rules and access grants |

### Storage & Caching Contracts
Expand Down
1 change: 1 addition & 0 deletions content/docs/kernel/runtime-services/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ This chapter documents the runtime `services.*` APIs used in hook/action/flow/pl
Each page links the canonical TypeScript source used to derive signatures.

- Data: `packages/client/src/index.ts`
- Security: `packages/spec/src/contracts/security-service.ts`
- Sharing: `packages/spec/src/contracts/sharing-service.ts`
- Queue: `packages/spec/src/contracts/queue-service.ts`
- Email: `packages/spec/src/contracts/email-service.ts`
Expand Down
19 changes: 14 additions & 5 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
RLS_MEMBERSHIP_RESOLVER_SERVICE,
RESERVED_RLS_MEMBERSHIP_KEYS,
type IRlsMembershipResolver,
type ISecurityService,
} from '@objectstack/spec/contracts';
import { matchesFilterCondition } from '@objectstack/formula';
import { FieldMasker } from './field-masker.js';
Expand Down Expand Up @@ -611,7 +612,11 @@ export class SecurityPlugin implements Plugin {
resolveSets: (context: any) => this.resolvePermissionSetsForContext(context),
logger: ctx.logger,
};
ctx.registerService('security', {
// Typed against the published contract so the registered surface cannot
// drift from what cross-package consumers are promised: a renamed method,
// a dropped one, or a changed return type fails THIS build rather than
// silently degrading a consumer's feature detection at runtime.
const securityService: ISecurityService = {
getReadFilter: (object: string, context?: any) => this.getReadFilter(object, context),
// [#3547] Readable-field projection for a context — the authoritative
// column set for a read-derived export (`export ⊆ list`, #3391).
Expand All @@ -632,8 +637,11 @@ export class SecurityPlugin implements Plugin {
// [ADR-0090 D6] First-class access explanation. Same code paths as
// the middleware (resolution/evaluator/RLS compiler) — explained by
// construction. Explaining ANOTHER user requires `manage_users`.
explain: (request: { object: string; operation: string; userId?: string }, callerContext?: any) =>
this.explainAccessForCaller(request, callerContext),
explain: (request, callerContext?: any) =>
this.explainAccessForCaller(
{ ...request, operation: String(request.operation) },
callerContext,
),
// [ADR-0090 D5/D9] Install-time suggestion surface: packages suggest
// audience-anchor bindings; a tenant admin confirms (the binding is
// written under the anchor + delegated-admin gates) or dismisses.
Expand All @@ -643,7 +651,8 @@ export class SecurityPlugin implements Plugin {
confirmAudienceBindingSuggestion(suggestionDeps, callerContext, id),
dismissAudienceBindingSuggestion: (callerContext: any, id: string) =>
dismissAudienceBindingSuggestion(suggestionDeps, callerContext, id),
});
};
ctx.registerService('security', securityService);
ctx.logger.info('[security] registered "security" service (getReadFilter, getReadableFields, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / #3547');
} catch (e) {
ctx.logger.warn?.('[security] failed to register "security" service', {
Expand Down Expand Up @@ -2093,7 +2102,7 @@ export class SecurityPlugin implements Plugin {
async getReadFilter(
object: string,
context?: any,
): Promise<Record<string, unknown> | null | undefined> {
): Promise<Record<string, unknown> | undefined> {
// System operations bypass scoping (mirrors the middleware's isSystem skip).
if (context?.isSystem) return undefined;
const positions = context?.positions ?? [];
Expand Down
10 changes: 9 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpoints
import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api';
import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security';
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
import type { ISecurityService } from '@objectstack/spec/contracts';
import {
resolveEffectiveApiMethods,
isApiOperationAllowed,
Expand Down Expand Up @@ -4542,8 +4543,15 @@ export class RestServer {
* Mirrors the resolver in registerSecurityExplainEndpoints. Returns
* `undefined` when no security service is reachable (no plugin-security /
* single-kernel without a provider), so callers degrade gracefully.
*
* Typed as a PARTIAL {@link ISecurityService}: an implementation may omit a
* method it cannot honour, so every call site must keep feature-detecting
* (`typeof svc.x === 'function'`) rather than assume the full surface.
*/
private async resolveSecurityService(environmentId?: string, req?: any): Promise<any | undefined> {
private async resolveSecurityService(
environmentId?: string,
req?: any,
): Promise<Partial<ISecurityService> | undefined> {
try {
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
if (envId && envId !== 'platform' && this.kernelManager) {
Expand Down
6 changes: 6 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3425,6 +3425,9 @@
"ApprovalSendBackResult (interface)",
"ApprovalStatus (type)",
"AssistantModelMessage (type)",
"AudienceBindingSuggestion (type)",
"AudienceBindingSuggestionFilter (interface)",
"AudienceBindingSuggestionSync (interface)",
"AuthResult (interface)",
"AuthSession (interface)",
"AuthUser (interface)",
Expand Down Expand Up @@ -3452,6 +3455,7 @@
"EmailAttachment (interface)",
"EmailDeliveryStatus (type)",
"ExecuteUpgradeInput (interface)",
"ExplainAccessRequest (interface)",
"ExportJobDownload (interface)",
"ExportJobListResult (interface)",
"FinishReason (type)",
Expand Down Expand Up @@ -3508,6 +3512,7 @@
"ISchemaDiffService (interface)",
"ISchemaDriver (interface)",
"ISearchService (interface)",
"ISecurityService (interface)",
"ISeedLoaderService (interface)",
"IServiceRegistry (interface)",
"IShareLinkService (interface)",
Expand Down Expand Up @@ -3611,6 +3616,7 @@
"SearchHit (interface)",
"SearchOptions (interface)",
"SearchResult (interface)",
"SecurityContext (type)",
"SendEmailInput (interface)",
"SendEmailResult (interface)",
"SendSmsInput (interface)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export * from './workflow-service.js';
export * from './export-service.js';
export * from './email-service.js';
export * from './sms-service.js';
export * from './security-service.js';
export * from './sharing-service.js';
export * from './rls-membership-resolver.js';
export * from './share-link-service.js';
Expand Down
98 changes: 98 additions & 0 deletions packages/spec/src/contracts/security-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import type { ISecurityService } from './security-service';

/**
* These tests pin the two things a consumer of the `security` service reasons
* about and that a refactor could silently change: the SHAPE of the surface
* (compile-time) and the MEANING of each "empty" answer (runtime). The second
* matters more than it looks — `undefined` and `[]` from getReadableFields are
* opposite instructions, and a consumer that conflates them either leaks a
* column set or blanks one out.
*/

/** Minimal stub implementing the full surface. */
function makeService(overrides: Partial<ISecurityService> = {}): ISecurityService {
return {
getReadFilter: async () => undefined,
getReadableFields: async () => [],
resolvePermissionSetNames: async () => [],
explain: async () => ({}) as any,
listAudienceBindingSuggestions: async () => ({
suggestions: [],
synced: { created: 0, confirmedObserved: 0, pruned: 0 },
}),
confirmAudienceBindingSuggestion: async () => ({ suggestion: {}, bindingCreated: true }),
dismissAudienceBindingSuggestion: async () => ({ suggestion: {} }),
...overrides,
};
}

describe('Security Service Contract', () => {
it('a full implementation satisfies the surface', () => {
const service = makeService();
for (const m of [
'getReadFilter',
'getReadableFields',
'resolvePermissionSetNames',
'explain',
'listAudienceBindingSuggestions',
'confirmAudienceBindingSuggestion',
'dismissAudienceBindingSuggestion',
] as const) {
expect(typeof service[m]).toBe('function');
}
});

it('getReadFilter: undefined means NO row restriction — the only thing it may mean', async () => {
// A deny is expressed as a filter that matches nothing, never as `undefined`,
// so a consumer can safely read `undefined` as "apply no filter".
const open = makeService({ getReadFilter: async () => undefined });
await expect(open.getReadFilter('deal', { userId: 'u1' })).resolves.toBeUndefined();

const denied = makeService({ getReadFilter: async () => ({ id: { $eq: null } }) as any });
await expect(denied.getReadFilter('deal', { userId: 'u1' })).resolves.toBeDefined();
});

it('getReadableFields: undefined (no answer) and [] (nothing readable) are opposite answers', async () => {
const noAnswer = makeService({ getReadableFields: async () => undefined });
// `undefined` → the caller must fall back to its own projection…
await expect(noAnswer.getReadableFields('deal', { userId: 'u1' })).resolves.toBeUndefined();

const nothingReadable = makeService({ getReadableFields: async () => [] });
// …whereas `[]` is authoritative: expose no columns at all.
await expect(nothingReadable.getReadableFields('deal', { userId: 'u1' })).resolves.toEqual([]);
});

it('a system context is a full field-level bypass', async () => {
const service = makeService({
getReadableFields: async (_object, context) =>
context?.isSystem ? ['id', 'name', 'secret'] : ['id', 'name'],
});
await expect(service.getReadableFields('deal', { isSystem: true }))
.resolves.toEqual(['id', 'name', 'secret']);
await expect(service.getReadableFields('deal', { userId: 'u1' }))
.resolves.toEqual(['id', 'name']);
});

it('a partial implementation is feature-detectable rather than wrong', () => {
// Consumers probe (`typeof svc.getReadableFields === 'function'`) so an
// implementation may omit a method it cannot honour and still be usable.
const partial: Partial<ISecurityService> = { getReadFilter: async () => undefined };
expect(typeof partial.getReadFilter).toBe('function');
expect(typeof partial.getReadableFields).toBe('undefined');
});

it('explain accepts a record-scoped request and an explicit target user', async () => {
const seen: unknown[] = [];
const service = makeService({
explain: async (request) => { seen.push(request); return {} as any; },
});
await service.explain(
{ object: 'deal', operation: 'update', userId: 'u2', recordId: 'r1' },
{ userId: 'admin' },
);
expect(seen[0]).toEqual({ object: 'deal', operation: 'update', userId: 'u2', recordId: 'r1' });
});
});
Loading
Loading