feat: harden CRUD resource contract checks (AEG-V16-020)

Reject incomplete permission, version, concurrency, idempotency, and sensitive-column contracts. Preserve runtime regression evidence; status remains IN_PROGRESS pending predecessor acceptance.
This commit is contained in:
2026-08-08 13:13:32 +09:00
parent 30c941cc5b
commit a84e5c1273
5 changed files with 58 additions and 4 deletions
+13 -3
View File
@@ -1,8 +1,18 @@
export function assertCrudResourceDefinition(definition) {
if (!definition.resourceId.trim())
throw new Error('resourceId is required');
if (!definition.querySchemaVersion.trim() || !definition.responseSchemaVersion.trim())
throw new Error('schema versions are required');
if (!definition.permissionPolicy.trim())
throw new Error('permissionPolicy is required');
if (definition.concurrencyMode !== 'NONE' && definition.concurrencyMode !== 'ETAG_IF_MATCH')
throw new Error('invalid concurrencyMode');
if (definition.idempotencyMode !== 'NONE' && definition.idempotencyMode !== 'IDEMPOTENCY_KEY')
throw new Error('invalid idempotencyMode');
const fields = new Set(definition.columns.map(x => x.field));
for (const sensitive of definition.sensitiveFields)
if (!fields.has(sensitive))
throw new Error(`Sensitive field '${sensitive}' has no grid column contract`);
for (const sensitive of definition.sensitiveFields) {
const column = definition.columns.find(x => x.field === sensitive);
if (!fields.has(sensitive) || !column?.sensitive)
throw new Error(`Sensitive field '${sensitive}' has no sensitive grid column contract`);
}
}
@@ -17,6 +17,13 @@ export interface CrudResourceDefinition<TQuery, TRow, TForm> {
}
export function assertCrudResourceDefinition(definition: CrudResourceDefinition<unknown, unknown, unknown>): void {
if (!definition.resourceId.trim()) throw new Error('resourceId is required')
if (!definition.querySchemaVersion.trim() || !definition.responseSchemaVersion.trim()) throw new Error('schema versions are required')
if (!definition.permissionPolicy.trim()) throw new Error('permissionPolicy is required')
if (definition.concurrencyMode !== 'NONE' && definition.concurrencyMode !== 'ETAG_IF_MATCH') throw new Error('invalid concurrencyMode')
if (definition.idempotencyMode !== 'NONE' && definition.idempotencyMode !== 'IDEMPOTENCY_KEY') throw new Error('invalid idempotencyMode')
const fields = new Set(definition.columns.map(x => x.field))
for (const sensitive of definition.sensitiveFields) if (!fields.has(sensitive)) throw new Error(`Sensitive field '${sensitive}' has no grid column contract`)
for (const sensitive of definition.sensitiveFields) {
const column = definition.columns.find(x => x.field === sensitive)
if (!fields.has(sensitive) || !column?.sensitive) throw new Error(`Sensitive field '${sensitive}' has no sensitive grid column contract`)
}
}
@@ -0,0 +1,16 @@
import { z } from 'zod'
import { describe, expect, it } from 'vitest'
import { assertCrudResourceDefinition, type CrudResourceDefinition } from '../resourceDefinition'
const definition: CrudResourceDefinition<unknown, unknown, unknown> = {
resourceId: 'client-advisory', querySchemaVersion: '1', responseSchemaVersion: '1',
querySchema: z.unknown(), rowSchema: z.unknown(), formSchema: z.unknown(),
columns: [{ field: 'email', header: 'Email', sensitive: true }], sensitiveFields: ['email'],
permissionPolicy: 'ClientAdvisory.Read', concurrencyMode: 'ETAG_IF_MATCH', idempotencyMode: 'IDEMPOTENCY_KEY'
}
describe('CrudResourceDefinition', () => {
it('accepts an explicit secure resource contract', () => expect(() => assertCrudResourceDefinition(definition)).not.toThrow())
it('rejects sensitive fields without a sensitive column contract', () => expect(() => assertCrudResourceDefinition({ ...definition, columns: [{ field: 'email', header: 'Email' }] })).toThrow('sensitive grid column'))
it('rejects missing permission policy', () => expect(() => assertCrudResourceDefinition({ ...definition, permissionPolicy: ' ' })).toThrow('permissionPolicy'))
})