From f99ab6b990721ed3f12893d53570762c8e0383c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 21:39:37 +0000 Subject: [PATCH] feat(conformance-fixtures): pin the grantee arms and the evaluation-time refusals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixtures covered a _grant with no grantee and a group arm with no role, and both stated in prose that the other arms "are refused the same way" — a rule argued in a fixture description and checked by nothing. Nine fixtures now stand behind it. Seven at the write: each incomplete arm, an empty entityId as a separate input class from an absent one, the listing-only role 'any' that a listGrants() query may ask for and a stored grantee may never carry, an unrecognized kind, a null grantee reported as the missing field it is rather than read as absent-then-defaulted, and an `anyone` element labelled anything but `read`. That last one is load-bearing past the element: stored, it would read as world read *and* satisfy the `read` a `write` element needs beside it, so one malformed element would silence write-implies-read for every grantee on the record. Two more pin the half no request shape can show — that a grant reaching storage unvetted confers nothing when it is read back. A grant naming _app@1 still answers 403 on a create, and a mutate verb with no read companion in the same grant conveys neither the write nor a read, so it answers 404 rather than 403. Both are states grant() refuses and a server mapping a request body, an import, or a foreign server's response can still produce, which is the case the two-sided rule exists for. Every expected response was observed against the reference server rather than derived from the spec, details included — adapter-api's error dispatch had no arm for the permissions endpoint, which the `anyone` fixture needed. No behavior change: each rule is already in docs/spec/access-control.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01St5i1czsAp26y8H56pmANC --- .changeset/grant-evaluation-fixtures.md | 13 + .../adapter-api/tests/conformance.test.ts | 6 + packages/conformance-fixtures/src/index.ts | 284 ++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 .changeset/grant-evaluation-fixtures.md diff --git a/.changeset/grant-evaluation-fixtures.md b/.changeset/grant-evaluation-fixtures.md new file mode 100644 index 00000000..57585b92 --- /dev/null +++ b/.changeset/grant-evaluation-fixtures.md @@ -0,0 +1,13 @@ +--- +'@haverstack/conformance-fixtures': minor +--- + +Pin the grantee arms and the evaluation-time refusals that the fixtures only described. + +Two fixtures covered a grant with no grantee at all and a group arm with no role, and both said in prose that the other arms "are refused the same way" — a rule stated in a fixture and checked by nothing. Nine fixtures now stand behind it: each incomplete arm (`entity` with no `entityId`, `group` with no `groupId`), an empty `entityId` as distinct from an absent one, the listing-only `role: 'any'` that a `listGrants()` query may ask for and a stored grantee may never carry, an unrecognized `kind`, and a `null` grantee reported as the missing field it is rather than read as absent-then-defaulted. + +Two more pin the half of the rule no request shape can show: that a grant reaching storage unvetted confers nothing when it is read back. A grant naming `_app@1` still answers 403 on a create, and a mutate verb with no read companion in the same grant conveys neither the write nor a read — so it answers 404, not 403. Both are states `grant()` refuses and a server mapping a request body, an import, or a foreign server's response can still produce. + +Also pins that an `anyone` element labelled anything but `read` is refused, which is load-bearing beyond the element: stored, it would read as world read _and_ satisfy the `read` a `write` element needs beside it, silencing write-implies-read for every grantee on the record. + +No behavior change — every rule here is already in `docs/spec/access-control.md`. A consumer holding these fixtures to a coverage gate will see the new names and need to dispatch or skip them. diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts index 9087b5aa..abd52f70 100644 --- a/packages/adapter-api/tests/conformance.test.ts +++ b/packages/adapter-api/tests/conformance.test.ts @@ -763,6 +763,12 @@ describe('error response fixtures', () => { createdAt: new Date(createdAt as string), }); } + // An `anyone`/`permission` element travels on the permissions + // endpoint, which associate() selects from the element's own kind — + // see APIAdapter.associationPath(). + if (fixture.method === 'POST' && fixture.path.endsWith('/permissions')) { + return adapter.associate(idFromPath(fixture.path), fixture.requestBody as Association); + } if (fixture.method === 'POST' && fixture.path.includes('/restore/')) { const version = Number(fixture.path.split('/').pop()); return adapter.restoreVersion(idFromPath(fixture.path), version); diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts index 89fff6bd..a14d1bd7 100644 --- a/packages/conformance-fixtures/src/index.ts +++ b/packages/conformance-fixtures/src/index.ts @@ -2241,6 +2241,290 @@ export const errorResponseFixtures: ConformanceFixture[] = [ }, }, }, + { + name: 'error-validation-grant-entity-grantee-without-entity-id', + description: + 'POST /records creating a _grant@1 record whose grantee names the `entity` tier but ' + + 'carries no `entityId` returns 422 with code "validation" — the `entity` arm\'s half of ' + + 'the rule error-validation-grant-group-grantee-without-role pins for the `group` arm. The ' + + 'schema cannot ask it, so the write does: a closed `object` field holds one properties ' + + 'set, which can only require `kind`. Stored instead, the grant would name nobody and deny ' + + 'forever while reading as a share that worked. ' + + 'See docs/spec/access-control.md § Who a grant reaches.', + method: 'POST', + path: '/records', + requestBody: { + id: '1hk153x06010', + typeId: '_grant@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { + typeId: 'com.example/comment@1', + actions: ['read-any'], + grantee: { kind: 'entity' }, + }, + version: 1, + }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [ + { + path: 'grantee.entityId', + message: 'An entity grantee requires a non-empty entityId', + }, + ], + }, + }, + }, + { + name: 'error-validation-grant-entity-grantee-with-empty-entity-id', + description: + 'POST /records creating a _grant@1 record whose `entityId` is the empty string returns ' + + '422 with code "validation", and the same detail an absent one earns. Empty is a ' + + 'separate input class from absent, and the distinction is where a presence check and a ' + + 'truthiness check stop agreeing: a grantee that is present but names nobody reaches no ' + + 'one, so it is refused on the same grounds rather than stored. An empty `groupId` is ' + + 'refused the same way. See docs/spec/access-control.md § Who a grant reaches.', + method: 'POST', + path: '/records', + requestBody: { + id: '1hk153x06011', + typeId: '_grant@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { + typeId: 'com.example/comment@1', + actions: ['read-any'], + grantee: { kind: 'entity', entityId: '' }, + }, + version: 1, + }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [ + { + path: 'grantee.entityId', + message: 'An entity grantee requires a non-empty entityId', + }, + ], + }, + }, + }, + { + name: 'error-validation-grant-group-grantee-without-group-id', + description: + 'POST /records creating a _grant@1 record whose grantee names the `group` tier and a ' + + 'role but no `groupId` returns 422 with code "validation". A role with no roster to read ' + + 'it from names nobody, so the arm is incomplete in the same way one carrying a groupId ' + + 'and no role is. See docs/spec/access-control.md § Who a grant reaches.', + method: 'POST', + path: '/records', + requestBody: { + id: '1hk153x06012', + typeId: '_grant@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { + typeId: 'com.example/comment@1', + actions: ['read-any'], + grantee: { kind: 'group', role: 'member' }, + }, + version: 1, + }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [ + { path: 'grantee.groupId', message: 'A group grantee requires a non-empty groupId' }, + ], + }, + }, + }, + { + name: 'error-validation-grant-group-grantee-with-listing-only-role', + description: + "POST /records creating a _grant@1 record whose group grantee carries role 'any' returns " + + '422 with code "validation". `any` is a widening a *query* can ask for — ' + + "listGrants({ kind: 'group', groupId, role: 'any' }) returns every grant naming that " + + 'group, whichever role — and it is not a role an entity can hold, so it can never reach ' + + 'storage. A stored grant carrying it would match a roster question nobody can answer. ' + + 'This is the one grantee value that is well-formed in one API and refused in the other, ' + + 'which is why it is pinned separately from an unrecognized role. ' + + 'See docs/spec/access-control.md § Listing and revoking.', + method: 'POST', + path: '/records', + requestBody: { + id: '1hk153x06013', + typeId: '_grant@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { + typeId: 'com.example/comment@1', + actions: ['read-any'], + grantee: { kind: 'group', groupId: '1hk153x05001', role: 'any' }, + }, + version: 1, + }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [ + { path: 'grantee.role', message: "A group grantee requires role 'member' or 'admin'" }, + ], + }, + }, + }, + { + name: 'error-validation-grant-grantee-with-unknown-kind', + description: + 'POST /records creating a _grant@1 record whose grantee names a `kind` outside the three ' + + 'tiers returns 422 with code "validation". There is no wider tier to fall back to: the ' + + 'refusal is what keeps an unrecognized grantee from being read as the nearest thing that ' + + 'does resolve. Evaluation takes the same posture toward one already stored, reading it as ' + + 'conferring nothing. See docs/spec/access-control.md § Who a grant reaches.', + method: 'POST', + path: '/records', + requestBody: { + id: '1hk153x06014', + typeId: '_grant@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { + typeId: 'com.example/comment@1', + actions: ['read-any'], + grantee: { kind: 'everyone' }, + }, + version: 1, + }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [ + { + path: 'grantee.kind', + message: "A grantee must name its tier: 'entity', 'group' or 'authenticated'", + }, + ], + }, + }, + }, + { + name: 'error-validation-grant-null-grantee', + description: + 'POST /records creating a _grant@1 record whose `grantee` is JSON `null` returns 422 with ' + + 'code "validation", reported as the missing required field it is. `null` is not read as ' + + 'absent-then-defaulted, which is the reading that would turn a serializer emitting nulls ' + + 'for unset fields into a grant to every authenticated entity. ' + + 'See docs/spec/access-control.md § Type-level grants.', + method: 'POST', + path: '/records', + requestBody: { + id: '1hk153x06015', + typeId: '_grant@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { + typeId: 'com.example/comment@1', + actions: ['read-any'], + grantee: null, + }, + version: 1, + }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [{ path: 'grantee', message: 'Required field is missing' }], + }, + }, + }, + { + name: 'error-validation-anyone-element-labelled-write', + description: + 'POST /records/:id/permissions with an `anyone` element labelled anything but `read` ' + + 'returns 422 with code "validation". `read` is the whole of what the tier can say — ' + + 'world-writability is not in the model — so the label is not a bit to widen. Refusing it ' + + 'at the write matters beyond the element itself: a stored `anyone` element carrying ' + + '`write` would otherwise be read as world read *and* satisfy the `read` a `write` element ' + + 'needs beside it, so one malformed element would silence the ' + + 'write-implies-read invariant for every grantee on the record. Evaluation reads it as ' + + 'naming no reach for the same reason. ' + + 'See docs/spec/access-control.md § Record-level permissions.', + method: 'POST', + path: '/records/1hk153x00001/permissions', + requestBody: { kind: 'anyone', label: 'write' }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [ + { path: 'permission.label', message: 'An `anyone` association carries only `read`.' }, + ], + }, + }, + }, + { + name: 'error-permission-grant-on-an-ungrantable-family-confers-nothing', + description: + 'A grant cannot be written for `_grant`, `_config` or `_app`, and one that reached ' + + 'storage some other way confers nothing at evaluation. Assumes a stored _grant@1 Record ' + + "naming `_app@1` with actions ['create', 'read-any'] and this requester as its entity " + + 'grantee — reachable because a server mapping a request body, an import and a foreign ' + + "server's response all produce grant Records that grant() never vetted. The create below " + + 'MUST still return 403 with code "permission". Each of the three families hands the ' + + 'grantee the machinery the model rests on; `_app` is the sharpest, since an app card ' + + 'claiming a DID that is not its own is what verified attribution rests on. ' + + 'See docs/spec/access-control.md § Refused at the write, and again at evaluation.', + method: 'POST', + path: '/records', + requestBody: { + id: '1hk153x06016', + typeId: '_app@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { + name: 'Claimed app', + did: 'did:key:z6MkfNotesAppKeyClaimedByTheClient00000000000000', + }, + version: 1, + }, + responseStatus: 403, + responseBody: { error: { code: 'permission', message: 'Permission denied' } }, + }, + { + name: 'error-not-found-mutate-grant-with-no-read-companion', + description: + 'A mutate verb needs a read verb of matching scope in the same grant, or it conveys ' + + 'nothing. Assumes a stored _grant@1 Record naming com.example/note@1 with actions ' + + "['create', 'update-any'] and this requester as its entity grantee, plus a record of " + + 'that type authored by someone else at "1hk153x00001". The PATCH MUST return 404 with ' + + 'code "not_found", not 403: the grant conveys neither the update nor a read, and a ' + + 'requester who cannot read the record is told nothing about its existence. The companion ' + + 'has to sit in the same Record because a grant is revoked whole — satisfying the rule ' + + 'across two would let revoking the read one leave a mutate-without-read grant standing. ' + + '`create` is the exception and needs no companion: writing a record you cannot read is ' + + 'the drop-box, and it discloses nothing. ' + + 'See docs/spec/access-control.md § Write implies read.', + method: 'PATCH', + path: '/records/1hk153x00001', + requestBody: { contentPatch: { title: 'Seized' } }, + responseStatus: 404, + responseBody: { error: { code: 'not_found', message: 'Record not found' } }, + }, { name: 'error-query-permission-kind-in-associations-key', description: