Skip to content

Commit e7e1ab9

Browse files
os-zhuangclaude
andcommitted
feat(spec): ADR-0066 D2/D3 — object access posture + requiredPermissions
- access: { default: 'public' | 'private' } — secure-by-default object posture (ObjectAccessConfigSchema), a data-model posture like tenancy. private opts the object out of wildcard '*' grants and (via the engine) out of wildcard RLS. - requiredPermissions: string[] on Object — capability contract (mirrors App.requiredPermissions), enforced by the engine as an AND-gate. - Both optional; absent ⇒ public / no gate (no migration for existing objects). - Tests + full-DTS build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0cb83e0 commit e7e1ab9

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

packages/spec/src/data/object.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, type ServiceObject } from './object.zod';
2+
import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, type ServiceObject } from './object.zod';
33

44
describe('ObjectCapabilities', () => {
55
it('should apply default values correctly', () => {
@@ -953,3 +953,39 @@ describe('ObjectSchema.fieldGroups', () => {
953953
});
954954
});
955955
});
956+
957+
958+
describe('ADR-0066 — object access posture (D2) + requiredPermissions (D3)', () => {
959+
it('ObjectAccessConfigSchema defaults to public', () => {
960+
expect(ObjectAccessConfigSchema.parse({}).default).toBe('public');
961+
});
962+
963+
it('accepts an explicit private posture', () => {
964+
expect(ObjectAccessConfigSchema.parse({ default: 'private' }).default).toBe('private');
965+
});
966+
967+
it('rejects an unknown posture value', () => {
968+
expect(() => ObjectAccessConfigSchema.parse({ default: 'secret' })).toThrow();
969+
});
970+
971+
it('round-trips access + requiredPermissions on an object', () => {
972+
const obj = ObjectSchema.create({
973+
name: 'sys_license',
974+
tenancy: { enabled: false, strategy: 'shared' },
975+
access: { default: 'private' },
976+
requiredPermissions: ['manage_licenses'],
977+
fields: { signed_token: { type: 'text' } },
978+
});
979+
expect(obj.access?.default).toBe('private');
980+
expect(obj.requiredPermissions).toEqual(['manage_licenses']);
981+
});
982+
983+
it('leaves access undefined (public by convention) when omitted', () => {
984+
const obj = ObjectSchema.create({
985+
name: 'crm_account',
986+
fields: { name: { type: 'text' } },
987+
});
988+
expect(obj.access).toBeUndefined();
989+
expect(obj.requiredPermissions).toBeUndefined();
990+
});
991+
});

packages/spec/src/data/object.zod.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,32 @@ export const TenancyConfigSchema = lazySchema(() => z.object({
135135
crossTenantAccess: z.boolean().default(false).describe('Allow cross-tenant data access (with explicit permission)'),
136136
}));
137137

138+
/**
139+
* [ADR-0066 D2] Secure-by-default object posture.
140+
*
141+
* Declares whether the object participates in blanket wildcard permission
142+
* grants — a data-model posture like {@link TenancyConfigSchema}, NOT an
143+
* assignment (it names no principal).
144+
*
145+
* - `public` (default) — covered by a permission set's `'*'` wildcard object
146+
* grant; today's allow-by-default behaviour.
147+
* - `private` — NOT covered by the `'*'` wildcard grant; access requires an
148+
* EXPLICIT per-object grant (Salesforce "new object = no access until
149+
* granted"). A `private` object is ALSO exempt from wildcard RLS
150+
* (`tenant_isolation`, owner scoping): the posture-gated superuser bypass
151+
* (`viewAllRecords`/`modifyAllRecords`) short-circuits RLS, so a platform
152+
* admin — incl. one who is also an org admin whose `tenant_isolation` would
153+
* otherwise narrow the result — sees all rows, while non-admins without an
154+
* explicit grant see none.
155+
*
156+
* Pair with the object's `requiredPermissions` (D3) to additionally gate access
157+
* on holding a capability.
158+
*/
159+
export const ObjectAccessConfigSchema = lazySchema(() => z.object({
160+
default: z.enum(['public', 'private']).default('public')
161+
.describe('Default exposure posture: public (covered by wildcard grants) | private (needs explicit grant; exempt from wildcard RLS).'),
162+
}));
163+
138164
/**
139165
* Soft Delete Configuration Schema
140166
* Implements recycle bin / trash functionality
@@ -487,6 +513,25 @@ const ObjectSchemaBase = z.object({
487513

488514
// Multi-tenancy configuration
489515
tenancy: TenancyConfigSchema.optional().describe('Multi-tenancy configuration for SaaS applications'),
516+
517+
/**
518+
* [ADR-0066 D2] Secure-by-default object posture. `access.default: 'private'`
519+
* opts the object OUT of blanket wildcard (`'*'`) permission grants (access
520+
* then needs an explicit per-object grant) and exempts it from wildcard RLS
521+
* via the posture-gated superuser bypass. Absent ⇒ `public` (today's
522+
* allow-by-default behaviour; no migration for existing objects).
523+
*/
524+
access: ObjectAccessConfigSchema.optional().describe('[ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default).'),
525+
526+
/**
527+
* [ADR-0066 D3] Capability contract — capability name(s) (permission-set
528+
* `systemPermissions`; D1 records) a caller MUST hold to access this object at
529+
* all. Mirrors `App.requiredPermissions`. Enforced by plugin-security as an
530+
* AND-gate: checked IN ADDITION to permission-set CRUD grants — a caller
531+
* missing any listed capability is denied regardless of grants. Absent/empty
532+
* ⇒ no capability gate.
533+
*/
534+
requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D3] Capabilities required to access this object (AND-gate, checked alongside CRUD grants).'),
490535

491536
// Soft delete configuration
492537
softDelete: SoftDeleteConfigSchema.optional().describe('Soft delete (trash/recycle bin) configuration'),
@@ -867,6 +912,7 @@ export type ServiceObjectInput = z.input<typeof ObjectSchemaBase>;
867912
export type ObjectCapabilities = z.infer<typeof ObjectCapabilities>;
868913
export type ObjectIndex = z.infer<typeof IndexSchema>;
869914
export type TenancyConfig = z.infer<typeof TenancyConfigSchema>;
915+
export type ObjectAccessConfig = z.infer<typeof ObjectAccessConfigSchema>;
870916
export type SoftDeleteConfig = z.infer<typeof SoftDeleteConfigSchema>;
871917
export type VersioningConfig = z.infer<typeof VersioningConfigSchema>;
872918
export type PartitioningConfig = z.infer<typeof PartitioningConfigSchema>;

0 commit comments

Comments
 (0)