@aventara/access 0.1.0-rc.0 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +53 -7
  2. package/README.md +146 -49
  3. package/dist/access/access-options.d.ts +10 -2
  4. package/dist/access/access.factory.d.ts +3 -1
  5. package/dist/access/principal.subject.d.ts +11 -1
  6. package/dist/access/principal.subject.js +32 -3
  7. package/dist/access/request.state.js +75 -5
  8. package/dist/access-warning.emitter.d.ts +2 -0
  9. package/dist/access-warning.emitter.js +4 -0
  10. package/dist/access.builder.d.ts +48 -7
  11. package/dist/access.builder.js +10 -1
  12. package/dist/access.engine.d.ts +16 -3
  13. package/dist/access.engine.js +16 -9
  14. package/dist/better-auth/access.provider.d.ts +3 -1
  15. package/dist/cli/policies.diff.js +19 -1
  16. package/dist/foreign-references.check.d.ts +0 -6
  17. package/dist/foreign-references.check.js +2 -5
  18. package/dist/generator/prisma7/prisma-where.translator.d.ts +5 -4
  19. package/dist/generator/prisma7/prisma-where.translator.js +7 -5
  20. package/dist/mcp/agent-scope.gate.d.ts +3 -1
  21. package/dist/mcp/agent-scope.gate.js +1 -1
  22. package/dist/mcp/agent.grant.d.ts +5 -1
  23. package/dist/mcp/agent.grant.js +1 -1
  24. package/dist/policies-contract.d.ts +31 -4
  25. package/dist/policies.compiler.d.ts +3 -1
  26. package/dist/policies.compiler.js +89 -14
  27. package/dist/policies.decider.js +11 -3
  28. package/dist/policies.hash.js +2 -1
  29. package/dist/read-governed-unique.check.d.ts +3 -0
  30. package/dist/read-governed-unique.check.js +21 -0
  31. package/dist/reference-reads.check.d.ts +10 -6
  32. package/dist/reference-reads.check.js +20 -10
  33. package/dist/registry/ownership.check.js +8 -2
  34. package/dist/resources/auth-tables.catalog.d.ts +5 -0
  35. package/dist/resources/auth-tables.catalog.js +11 -3
  36. package/dist/resources/auth-tables.guard.d.ts +11 -0
  37. package/dist/resources/auth-tables.guard.js +58 -1
  38. package/dist/scope.resolver.d.ts +6 -0
  39. package/dist/scope.resolver.js +17 -0
  40. package/package.json +3 -2
  41. package/skill/SKILL.md +170 -0
  42. package/skill/reference/callers.md +99 -0
  43. package/skill/reference/errors.md +89 -0
  44. package/skill/reference/recipes.md +281 -0
  45. package/skill/reference/rules.md +153 -0
  46. package/skill/reference/setup.md +334 -0
  47. package/src/generator/prisma7/prisma-where.translator.ts +7 -5
  48. package/src/policies-contract.ts +35 -4
  49. package/src/policies.decider.ts +14 -2
  50. package/src/scope.resolver.ts +28 -0
@@ -20,7 +20,7 @@
20
20
  import { decodeProtectedHeader } from "jose";
21
21
  import { mayChange, SESSION_USE } from "../kiosk/session-use.marker.js";
22
22
  import { hasAventaraScope, withGrant, } from "../mcp/agent.grant.js";
23
- import { carrySubject } from "./principal.subject.js";
23
+ import { carrySubject, claimReturned } from "./principal.subject.js";
24
24
  import { readsSessionCookie, } from "./profile.policy.js";
25
25
  import { isAccessTokenType } from "./token.verifier.js";
26
26
  const bearerOf = (headers) => /^Bearer[ \t]+([^ \t]+)[ \t]*$/i.exec(headers.get("authorization") ?? "")?.[1];
@@ -166,20 +166,90 @@ export const requestState = (identityOf, principal) => {
166
166
  return async ({ headers, request }) => {
167
167
  const caller = await identityOf(headers, request);
168
168
  if (caller === null) {
169
- return { me: null };
169
+ return stateOf(null);
170
170
  }
171
171
  const { grant } = caller;
172
172
  const identity = grant === undefined
173
173
  ? caller.identity
174
174
  : withGrant(caller.identity, grant);
175
175
  const mapped = principal === undefined ? identity : await principal(identity);
176
- const me = grant === undefined ? mapped : withGrant(mapped, grant);
176
+ // One object handed out for two users is refused (a cache keyed wrongly would
177
+ // serve one as the other), without writing to it.
178
+ claimReturned(mapped, identity.sub);
179
+ // A copy of the plain data: the app's own objects are never frozen.
180
+ const made = new Map();
181
+ const copied = plainCopy(mapped, made);
182
+ const me = grant === undefined ? copied : withGrant(copied, grant);
183
+ // Access's own objects: the copies, and the one `withGrant` makes. A value the
184
+ // app returned that is not plain data (a class instance, a `Map`) is `me` as given.
185
+ const ours = typeof me === "object" &&
186
+ me !== null &&
187
+ (grant !== undefined || made.get(mapped) === me);
177
188
  // The `sub` travels with `me`: whichever instance the host asks for the
178
189
  // policy (under Next, another bundle's) reads it for the audit's `userId`.
179
- carrySubject(me, identity.sub);
180
- return { me: me ?? null };
190
+ carrySubject(me, identity.sub, ours);
191
+ for (const copy of made.values()) {
192
+ Object.freeze(copy);
193
+ }
194
+ if (ours) {
195
+ Object.freeze(me);
196
+ }
197
+ return stateOf(me ?? null);
181
198
  };
182
199
  };
200
+ /**
201
+ * The state over `me`: `me` is a non-writable member of the state, and nothing of
202
+ * its plain data changes once it is derived, so no code that runs during the request
203
+ * (a pipeline, a hook, a computed field) can make the policy see another principal.
204
+ */
205
+ const stateOf = (me) => Object.defineProperty({}, "me", {
206
+ value: me,
207
+ enumerable: true,
208
+ writable: false,
209
+ configurable: false,
210
+ });
211
+ /**
212
+ * Plain data: an array, or an object made by `{}`, JSON or `Object.create(null)`. A
213
+ * class instance is not: its `#private` fields cannot be copied, so it is kept as
214
+ * the app gave it, as a `Map` is.
215
+ */
216
+ const isPlainData = (value) => {
217
+ const prototype = Object.getPrototypeOf(value);
218
+ return Array.isArray(value)
219
+ ? prototype === Array.prototype
220
+ : prototype === Object.prototype || prototype === null;
221
+ };
222
+ /**
223
+ * A copy of `value`'s plain data, all the way down: each array and plain object (on
224
+ * the same prototype), with every own property, its non-enumerable and symbol-keyed
225
+ * ones included. `made` maps each original to its copy (so a cycle stays one).
226
+ * Anything else (a primitive, a class instance, a `Map`, a `Set`, a `Date`, a typed
227
+ * array, a function) is kept as the app gave it, and so is a getter.
228
+ */
229
+ const plainCopy = (value, made) => {
230
+ if (typeof value !== "object" || value === null || !isPlainData(value)) {
231
+ return value;
232
+ }
233
+ const done = made.get(value);
234
+ if (done !== undefined) {
235
+ return done;
236
+ }
237
+ const copy = Array.isArray(value)
238
+ ? []
239
+ : Object.create(Object.getPrototypeOf(value));
240
+ made.set(value, copy);
241
+ for (const key of Reflect.ownKeys(value)) {
242
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
243
+ if (descriptor === undefined) {
244
+ continue;
245
+ }
246
+ if ("value" in descriptor) {
247
+ descriptor.value = plainCopy(descriptor.value, made);
248
+ }
249
+ Object.defineProperty(copy, key, descriptor);
250
+ }
251
+ return copy;
252
+ };
183
253
  /**
184
254
  * Refuses a token for a session the store no longer has (revoked, signed out,
185
255
  * expired): with Better Auth's cookie cache on, a session read from its cookie may
@@ -1,2 +1,4 @@
1
+ /** `names` as a warning lists them: `a`, `a and b`, `a, b and c`. */
2
+ export declare const listed: (names: readonly string[]) => string;
1
3
  /** Emits `message` under `code`, once per process and `namespace`. */
2
4
  export declare const emitAccessWarning: (code: string, namespace: string | undefined, message: string) => void;
@@ -3,6 +3,10 @@
3
3
  // with `--disable-warning=<code>`), else `console.warn`. Each message is emitted
4
4
  // once per process and namespace, however many Frameworks host the rules.
5
5
  const emitted = new Set();
6
+ /** `names` as a warning lists them: `a`, `a and b`, `a, b and c`. */
7
+ export const listed = (names) => names.length < 2
8
+ ? names.join("")
9
+ : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
6
10
  /** Emits `message` under `code`, once per process and `namespace`. */
7
11
  export const emitAccessWarning = (code, namespace, message) => {
8
12
  const key = `${code}\u0000${namespace ?? ""}\u0000${message}`;
@@ -22,9 +22,36 @@ export type AccessMe<P> = {
22
22
  type Simplify<T> = {
23
23
  [K in keyof T]: T[K];
24
24
  } & {};
25
+ /** A field's `govern` mode: governed for writing only, reading only, or both. */
26
+ type GovernMode = "read" | "write" | "both";
27
+ /** The `govern` block, by Resource: a mode per governed field (the list form governs each for "both"). */
25
28
  type GovernedShape = {
26
- readonly [resource: string]: readonly string[];
29
+ readonly [resource: string]: {
30
+ readonly [field: string]: GovernMode;
31
+ };
32
+ };
33
+ /** One Resource's governed fields, whatever their mode. */
34
+ type GovernedFieldOf<G> = keyof G & string;
35
+ /** `govern(R, fields)` added to the block, as a mode per field. */
36
+ type Governing<Gov, R extends string, New> = Simplify<{
37
+ readonly [K in keyof Gov as K extends R ? never : K]: Gov[K];
38
+ } & {
39
+ readonly [K in R]: K extends keyof Gov ? Simplify<Gov[K] & New> : New;
40
+ }>;
41
+ /**
42
+ * The object form's check, at each key: a field of the Resource, not governed
43
+ * already in another mode. The type at a wrong key is the sentence that says what
44
+ * to change.
45
+ */
46
+ type CheckedModes<Field extends string, R extends string, Prior, M> = {
47
+ readonly [K in keyof M]: K extends Field ? M[K] extends GovernMode ? K extends keyof Prior ? Prior[K] extends M[K] ? M[K] : `Error: "${R}.${K & string}" is already governed as "${Prior[K] & string}"; govern each field once, with one mode` : M[K] : `Error: "${R}.${K & string}" takes a mode, "read", "write" or "both"` : `Error: "${R}" has no field "${K & string}"`;
48
+ };
49
+ /** The list form's check: a field governed already in a mode other than "both" is the sentence that says so. */
50
+ type CheckedList<R extends string, Prior, F> = {
51
+ readonly [I in keyof F]: F[I] extends keyof Prior ? Prior[F[I]] extends "both" ? F[I] : `Error: "${R}.${F[I] & string}" is already governed as "${Prior[F[I]] & string}"; govern each field once, with one mode` : F[I];
27
52
  };
53
+ /** The modes `Gov` already gives Resource `R`'s fields; none when it is not governed. */
54
+ type PriorModes<Gov, R> = R extends keyof Gov ? Gov[R] : unknown;
28
55
  /** A row role as the builder's type records it: the Resource it is held on. */
29
56
  type RoleShape = {
30
57
  readonly on: string;
@@ -181,8 +208,8 @@ type InOption<S extends AccessSchema, R, Roles, Sub> = R extends ResourceOf<S> ?
181
208
  * says to govern one first, so naming a field reads as what to change.
182
209
  */
183
210
  type GovernedRef<Gov, R> = R extends "*" ? [keyof Gov] extends [never] ? "Error: no field is governed; govern one first, .govern(<Resource>, [<field>]), and a grant can then open it" : {
184
- [K in keyof Gov]: `${K & string}.${Gov[K] extends readonly (infer F extends string)[] ? F : never}`;
185
- }[keyof Gov] : R extends keyof Gov ? `${R & string}.${Gov[R] extends readonly (infer F extends string)[] ? F : never}` : `Error: no field of "${R & string}" is governed; govern it first, .govern("${R & string}", [<field>]), and a grant can then open it`;
211
+ [K in keyof Gov]: `${K & string}.${GovernedFieldOf<Gov[K]>}`;
212
+ }[keyof Gov] : R extends keyof Gov ? `${R & string}.${GovernedFieldOf<Gov[R]>}` : `Error: no field of "${R & string}" is governed; govern it first, .govern("${R & string}", [<field>]), and a grant can then open it`;
186
213
  type ResourceWhere<S extends AccessSchema, R, Roles> = R extends ResourceOf<S> ? AccessWhereInput<S, R, Roles> : never;
187
214
  /** The options of `grant(subjects, actions, resource, options)`. */
188
215
  export type AccessGrantOptions<S extends AccessSchema, Roles, Gov, R, Sub, A> = {
@@ -245,8 +272,12 @@ export type AccessDefinition = {
245
272
  readonly roles: {
246
273
  readonly [role: string]: unknown;
247
274
  };
275
+ /** Each `govern` call's fields, with their modes, in order: the compiler refuses a field given two. */
248
276
  readonly govern: {
249
- readonly [resource: string]: readonly string[];
277
+ readonly [resource: string]: readonly {
278
+ readonly field: string;
279
+ readonly mode: GovernMode;
280
+ }[];
250
281
  };
251
282
  readonly grants: readonly AccessGrant[];
252
283
  readonly denies: readonly AccessDeny[];
@@ -263,10 +294,20 @@ export interface AccessBuilder<S extends AccessSchema, P, Roles extends {
263
294
  }>(definitions: (tools: {
264
295
  readonly me: AccessMe<P>;
265
296
  }) => D extends CheckedRoles<S, P, D> ? D : CheckedRoles<S, P, D>): AccessBuilder<S, P, Simplify<Roles & D>, Gov>;
266
- /** Governed fields: out of every default projection; only a grant that names one opens it, and only when selected. */
267
- govern<R extends OwnResourceOf<S>, const F extends readonly [AccessFieldOf<S, R>, ...AccessFieldOf<S, R>[]]>(resource: R, fields: F): AccessBuilder<S, P, Roles, Simplify<Gov & {
268
- readonly [K in R]: F;
297
+ /**
298
+ * Governed fields: only a grant that names one opens it. A list governs each field
299
+ * for reading and writing: out of every default projection, and written by no grant
300
+ * that does not name it. An object gives each field a mode: `"write"` (read as any
301
+ * other field; written only through a grant that names it: a role your rules read),
302
+ * `"read"` (written as any other field; read only through a grant that names it: a
303
+ * government id) or `"both"` (as the list).
304
+ */
305
+ govern<R extends OwnResourceOf<S>, const F extends readonly [AccessFieldOf<S, R>, ...AccessFieldOf<S, R>[]]>(resource: R, fields: F extends CheckedList<R, PriorModes<Gov, R>, F> ? F : CheckedList<R, PriorModes<Gov, R>, F>): AccessBuilder<S, P, Roles, Governing<Gov, R, {
306
+ readonly [K in F[number]]: "both";
269
307
  }>>;
308
+ govern<R extends OwnResourceOf<S>, const M extends {
309
+ readonly [field: string]: string;
310
+ }>(resource: R, fields: keyof M extends never ? 'Error: name at least one field, { <field>: "read" | "write" | "both" }' : M extends CheckedModes<AccessFieldOf<S, R>, R, PriorModes<Gov, R>, M> ? M : CheckedModes<AccessFieldOf<S, R>, R, PriorModes<Gov, R>, M>): AccessBuilder<S, P, Roles, Governing<Gov, R, M>>;
270
311
  /** Grants. Default deny; any grant allows; order never matters. */
271
312
  grants(rules: (tools: {
272
313
  readonly grant: AccessGrantFunction<S, Roles, Gov>;
@@ -34,7 +34,16 @@ const builderOf = (definition) => ({
34
34
  ...definition,
35
35
  govern: {
36
36
  ...definition.govern,
37
- [resource]: [...(definition.govern[resource] ?? []), ...fields],
37
+ [resource]: [
38
+ ...(definition.govern[resource] ?? []),
39
+ // The list form governs each field for both; the compiler checks the rest.
40
+ ...(Array.isArray(fields)
41
+ ? fields.map((field) => ({
42
+ field,
43
+ mode: "both",
44
+ }))
45
+ : Object.entries(fields).map(([field, mode]) => ({ field, mode }))),
46
+ ],
38
47
  },
39
48
  }),
40
49
  grants: (rules) => builderOf({
@@ -6,9 +6,16 @@ import { type AccessCanQuestion, type AccessFields, type AccessFieldsQuestion, t
6
6
  import type { AccessPolicies, AccessPoliciesJson } from "./policies-contract.js";
7
7
  import type { AccessLookup } from "./predicate.evaluator.js";
8
8
  import { type AuthTablesGuard } from "./resources/auth-tables.guard.js";
9
+ /** The `govern` block, by Resource: the list form (each field for both) or a mode per field. */
9
10
  type GovernedShape = {
10
- readonly [resource: string]: readonly string[];
11
+ readonly [resource: string]: readonly string[] | {
12
+ readonly [field: string]: "read" | "write" | "both";
13
+ };
11
14
  };
15
+ /** One Resource's fields governed for reading: its list, or its fields not `"write"`. */
16
+ type ReadGoverned<G> = G extends readonly string[] ? G[number] : {
17
+ [F in keyof G]: G[F] extends "write" ? never : F;
18
+ }[keyof G] & string;
12
19
  /** `explain`'s answer: the decision, each rule's part in it, and the `exists` lookups. */
13
20
  export type AccessExplanation = {
14
21
  readonly allowed: boolean;
@@ -25,8 +32,14 @@ export type AccessExplanation = {
25
32
  };
26
33
  /** The engine `access.engine()` and `createAccessEngine(policies)` return. */
27
34
  export interface AccessEngine<Governed extends GovernedShape = GovernedShape> {
28
- /** The governed fields, by Resource: out of every default projection. */
29
- readonly governed: Governed;
35
+ /**
36
+ * The fields governed for reading (`"read"`, `"both"` or the list form), by
37
+ * Resource: out of every default projection. A `"write"` field is read as any other
38
+ * field, so it is not here.
39
+ */
40
+ readonly governed: {
41
+ readonly [R in keyof Governed as [ReadGoverned<Governed[R]>] extends [never] ? never : R]: Governed[R] extends readonly string[] ? Governed[R] : readonly ReadGoverned<Governed[R]>[];
42
+ };
30
43
  /** Every relation path and operator each rule filters on, by rule id. */
31
44
  readonly paths: {
32
45
  readonly [rule: string]: readonly (readonly string[])[];
@@ -19,9 +19,10 @@ import { foreignOwnersOf, verifyLocked } from "./lock/lock.verifier.js";
19
19
  import { checkLock } from "./lock/lock-contract.js";
20
20
  import { checkPolicies, fail, isRecord } from "./policies.compiler.js";
21
21
  import { afterChecksOf, createDecider, fieldsQuestionProblem, grantsQuestionProblem, newTrace, } from "./policies.decider.js";
22
+ import { warnReadGovernedUnique } from "./read-governed-unique.check.js";
22
23
  import { warnReferenceReads } from "./reference-reads.check.js";
23
- import { guardedFields, } from "./resources/auth-tables.guard.js";
24
- import { nullSafe, ownOf } from "./scope.resolver.js";
24
+ import { guardedFields, withBuiltInRules, } from "./resources/auth-tables.guard.js";
25
+ import { governedFor, nullSafe, ownOf, } from "./scope.resolver.js";
25
26
  const ENGINE_OPTIONS = new Set(["guardrails", "lock", "requireLock"]);
26
27
  /**
27
28
  * The engine's own copy of a file handed to it: what is checked and hashed is what
@@ -313,6 +314,7 @@ export const engineOf = (policies, options, source, observe, guard) => {
313
314
  // caller does to its objects afterwards reaches what the engine runs from.
314
315
  const own = copyOf("policies", policies);
315
316
  checkPolicies(own);
317
+ warnReadGovernedUnique(own);
316
318
  if (options !== undefined &&
317
319
  (!isRecord(options) ||
318
320
  Object.keys(options).some((key) => !ENGINE_OPTIONS.has(key)) ||
@@ -345,9 +347,7 @@ export const engineOf = (policies, options, source, observe, guard) => {
345
347
  // lock or without, they are read only once the beacons have passed.
346
348
  const owners = foreignOwnersOf(own);
347
349
  const guarded = guardrails === undefined ? own : composeGuardrails(own, guardrails);
348
- const file = deepFreeze(guard === undefined || guard.denies.length === 0
349
- ? guarded
350
- : { ...guarded, denies: [...guarded.denies, ...guard.denies] });
350
+ const file = deepFreeze(guard === undefined ? guarded : withBuiltInRules(guarded, guard));
351
351
  if (file !== own) {
352
352
  checkPolicies(file, true);
353
353
  }
@@ -357,7 +357,7 @@ export const engineOf = (policies, options, source, observe, guard) => {
357
357
  // no other namespace), otherwise why the engine answers nothing yet.
358
358
  let refusal = owners.length === 0
359
359
  ? undefined
360
- : `the rules read ${owners.map((owner) => `namespace "${owner}"'s`).join(" and ")} Resources as foreign, and the beacons were not checked: \`await engine.checkBeacons(reader, pins, environment)\` when the service starts, before it takes requests, with this environment's avAccess.beacons.<environment>.json`;
360
+ : `the rules read ${owners.map((owner) => `namespace "${owner}"'s`).join(" and ")} Resources as foreign, and the beacons were not checked: \`await access.policy.checkBeacons(beacon, beaconPins, environment)\` (\`engine.checkBeacons\` on an engine of your own) when the service starts, before it takes requests, with this environment's avAccess.beacons.<environment>.json`;
361
361
  // A failed check latches: no later check opens the engine again.
362
362
  let failed;
363
363
  // The checks, one after another.
@@ -374,11 +374,18 @@ export const engineOf = (policies, options, source, observe, guard) => {
374
374
  ? decider.fieldsOf(question)
375
375
  : guardedFields(guard, question, decider.fieldsOf(question), {
376
376
  fields: ownOf(file.schema, question.resource)?.fields ?? [],
377
- governed: ownOf(file.govern, question.resource) ?? [],
377
+ // What `"all"` stands for: every field reads do not govern.
378
+ governed: governedFor(file, question.resource, "read"),
378
379
  });
379
380
  };
381
+ // The seam's `governed`: the fields reads leave out. The built-in rules govern
382
+ // for writing only, so the file's own reads are the same.
383
+ const governed = deepFreeze(Object.fromEntries(Object.keys(file.govern).flatMap((resource) => {
384
+ const fields = governedFor(file, resource, "read");
385
+ return fields.length === 0 ? [] : [[resource, [...fields]]];
386
+ })));
380
387
  return {
381
- governed: own.govern,
388
+ governed: governed,
382
389
  paths: deepFreeze(pathsOf(file)),
383
390
  afterChecks: deepFreeze(afterChecksOf(file)),
384
391
  unheld: Object.freeze(Object.entries(own.schema)
@@ -421,7 +428,7 @@ export const engineOf = (policies, options, source, observe, guard) => {
421
428
  throw new AccessError("ACCESS_QUESTION_INVALID", "`hosted` takes `{ held }`, the list of Resource names the framework holds a written reference to");
422
429
  }
423
430
  const served = new Set(held);
424
- warnReferenceReads(own, served);
431
+ warnReferenceReads(own, served, guard?.closed);
425
432
  warnForeignReferences(own, served);
426
433
  },
427
434
  explain: async (question) => {
@@ -2,7 +2,9 @@ import type { AccessEngine } from "../access.engine.js";
2
2
  import type { AccessMcpAuthorization } from "../mcp/mcp-options.js";
3
3
  import { type AccessRegistry } from "../registry/access.registry.js";
4
4
  type GovernedShape = {
5
- readonly [resource: string]: readonly string[];
5
+ readonly [resource: string]: readonly string[] | {
6
+ readonly [field: string]: "read" | "write" | "both";
7
+ };
6
8
  };
7
9
  /** The headers of a request, as the framework hands them to `state`. */
8
10
  type RequestHeaders = {
@@ -52,6 +52,24 @@ const byId = (rules) => new Map((rules ?? []).map((rule) => [
52
52
  String(rule.id),
53
53
  rule,
54
54
  ]));
55
+ /**
56
+ * Each governed Resource as its fields' modes (`"both"` for a field `governModes`
57
+ * gives none), so a change of mode is named as the field it changes.
58
+ */
59
+ const governOf = (file) => {
60
+ const govern = (file.govern ?? {});
61
+ const modes = (file.governModes ?? {});
62
+ return new Map(Object.entries(govern).map(([resource, fields]) => [
63
+ resource,
64
+ Object.fromEntries(fields.map((field) => [
65
+ field,
66
+ (Object.hasOwn(modes, resource) &&
67
+ Object.hasOwn(modes[resource] ?? {}, field)
68
+ ? modes[resource]?.[field]
69
+ : undefined) ?? "both",
70
+ ])),
71
+ ]));
72
+ };
55
73
  /** The named changes from `before` to `after`, in the order of the file's parts. */
56
74
  export const diffPolicies = (before, after) => {
57
75
  const a = canonicalOf(before);
@@ -74,7 +92,7 @@ export const diffPolicies = (before, after) => {
74
92
  ...compareEntries("claim", byKey(a.claims), byKey(b.claims)),
75
93
  ...compareEntries("schema", byKey(a.schema), byKey(b.schema)),
76
94
  ...compareEntries("role", byKey(a.roles), byKey(b.roles)),
77
- ...compareEntries("govern", byKey(a.govern), byKey(b.govern)),
95
+ ...compareEntries("govern", governOf(a), governOf(b)),
78
96
  ...compareEntries("grant", byId(a.grants), byId(b.grants)),
79
97
  ...compareEntries("deny", byId(a.denies), byId(b.denies)),
80
98
  ];
@@ -1,10 +1,4 @@
1
1
  import type { AccessPolicies } from "./policies-contract.js";
2
- /**
3
- * The warnings for `policies` hosted by a framework that serves the Resources in
4
- * `held`, one per writing role and foreign target, each naming every reference
5
- * that role may write to it unconstrained.
6
- */
7
- export declare const foreignReferenceWarnings: (policies: AccessPolicies, held: ReadonlySet<string>) => readonly string[];
8
2
  /**
9
3
  * Emits `policies`' warnings under a framework that serves the Resources in
10
4
  * `held`, each once per process and namespace.
@@ -22,7 +22,7 @@
22
22
  // ACCESS_REFERENCE_UNREADABLE does, for the writing Resources the framework serves
23
23
  // (`held`). It warns and never refuses: the engine answers exactly as before. Not
24
24
  // warned about: a reference the grant's `write.except` locks.
25
- import { emitAccessWarning } from "./access-warning.emitter.js";
25
+ import { emitAccessWarning, listed } from "./access-warning.emitter.js";
26
26
  import { ownOf } from "./scope.resolver.js";
27
27
  const WARNING_CODE = "ACCESS_FOREIGN_REFERENCE_UNCHECKED";
28
28
  /** Whether `predicate`, holding on a row, limits which row its `relation` links. */
@@ -61,15 +61,12 @@ const grantConstrains = (policies, grant, subject, resource, action, relation, f
61
61
  constrains(grant.where, relation, fields) ||
62
62
  (action === "update" && constrains(grant.after, relation, fields)) ||
63
63
  constrains(subjectRowCondition(policies, subject, resource), relation, fields);
64
- const listed = (names) => names.length < 2
65
- ? names.join("")
66
- : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
67
64
  /**
68
65
  * The warnings for `policies` hosted by a framework that serves the Resources in
69
66
  * `held`, one per writing role and foreign target, each naming every reference
70
67
  * that role may write to it unconstrained.
71
68
  */
72
- export const foreignReferenceWarnings = (policies, held) => {
69
+ const foreignReferenceWarnings = (policies, held) => {
73
70
  const found = new Map();
74
71
  for (const grant of policies.grants) {
75
72
  const actions = grant.actions.filter((action) => action === "create" || action === "update");
@@ -25,10 +25,11 @@ export declare const toPrismaWhere: (policies: AccessPolicies, resource: string,
25
25
  export declare const toPrismaScope: (policies: AccessPolicies, resource: string, scope: boolean | AccessWhere) => PrismaWhere;
26
26
  /**
27
27
  * A `fieldsOf` read answer as Prisma's `select`: the Resource's scalar fields a read
28
- * shows on every row. A governed field is selected only when the answer opens it on
29
- * every row; a field it opens on some rows only is left out, since a `select` cannot
30
- * depend on the row (ask `fieldsOf` for those). Relations are never selected: a
31
- * related row would not be scoped.
28
+ * shows on every row. A field governed for reading is selected only when the answer
29
+ * opens it on every row (one governed for writing only is read as any other); a
30
+ * field it opens on some rows only is left out, since a `select` cannot depend on
31
+ * the row (ask `fieldsOf` for those). Relations are never selected: a related row
32
+ * would not be scoped.
32
33
  */
33
34
  export declare const toPrismaSelect: (policies: AccessPolicies, resource: string, fields: AccessFields) => PrismaSelect;
34
35
  /** The property of the Prisma client that holds a model's delegate: its name, first letter lowered. */
@@ -12,6 +12,7 @@
12
12
  // Prisma's `is`, because Prisma reads an empty unwrapped one as no condition at all;
13
13
  // a to-many relation's `some`, `every` and `none` keep theirs.
14
14
  import { defineMember, } from "../../aventara-where.translator.js";
15
+ import { governedFor } from "../../scope.resolver.js";
15
16
  const isWhereNode = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
16
17
  /** A record's own member, never one it inherits. */
17
18
  const own = (record, key) => record !== undefined && Object.hasOwn(record, key) ? record[key] : undefined;
@@ -72,14 +73,15 @@ export const toPrismaScope = (policies, resource, scope) => scope === true
72
73
  : toPrismaWhere(policies, resource, scope);
73
74
  /**
74
75
  * A `fieldsOf` read answer as Prisma's `select`: the Resource's scalar fields a read
75
- * shows on every row. A governed field is selected only when the answer opens it on
76
- * every row; a field it opens on some rows only is left out, since a `select` cannot
77
- * depend on the row (ask `fieldsOf` for those). Relations are never selected: a
78
- * related row would not be scoped.
76
+ * shows on every row. A field governed for reading is selected only when the answer
77
+ * opens it on every row (one governed for writing only is read as any other); a
78
+ * field it opens on some rows only is left out, since a `select` cannot depend on
79
+ * the row (ask `fieldsOf` for those). Relations are never selected: a related row
80
+ * would not be scoped.
79
81
  */
80
82
  export const toPrismaSelect = (policies, resource, fields) => {
81
83
  const facts = own(policies.schema, resource);
82
- const governed = own(policies.govern, resource) ?? [];
84
+ const governed = governedFor(policies, resource, "read");
83
85
  const scalars = (facts?.fields ?? []).filter((field) => own(facts?.relations, field) === undefined);
84
86
  const select = {};
85
87
  for (const field of scalars) {
@@ -1,6 +1,8 @@
1
1
  import type { AccessEngine, DecisionObserver } from "../access.engine.js";
2
2
  type GovernedShape = {
3
- readonly [resource: string]: readonly string[];
3
+ readonly [resource: string]: readonly string[] | {
4
+ readonly [field: string]: "read" | "write" | "both";
5
+ };
4
6
  };
5
7
  /**
6
8
  * `engine` with the token gate before it: a principal without a token's grant is
@@ -28,7 +28,7 @@ const refusalOf = (grant, origin, writes) => {
28
28
  if (grant.via === "mcp" && origin !== "agent") {
29
29
  return MCP_AUDIENCE_RULE;
30
30
  }
31
- return grant.scopes.has(writes ? MCP_WRITE_SCOPE : MCP_READ_SCOPE)
31
+ return grant.scopes.includes(writes ? MCP_WRITE_SCOPE : MCP_READ_SCOPE)
32
32
  ? undefined
33
33
  : MCP_SCOPE_RULE;
34
34
  };
@@ -10,6 +10,10 @@ export type CarriedGrant = TokenGrant & {
10
10
  /** `mcp`: an agent's token for the MCP endpoint. `audience`: a token addressed to the service itself. */
11
11
  readonly via: "mcp" | "audience";
12
12
  };
13
+ /** A grant as the principal carries it: frozen, its scopes a frozen list (a `Set` stays writable when frozen). */
14
+ export type MarkedGrant = Omit<CarriedGrant, "scopes"> & {
15
+ readonly scopes: readonly string[];
16
+ };
13
17
  /** Who acts for the user when a principal is an agent's: what `agentOf(me)` answers. */
14
18
  export type AccessAgent = {
15
19
  /** The OAuth client the agent's token was issued to. */
@@ -26,7 +30,7 @@ export type AccessAgent = {
26
30
  */
27
31
  export declare const withGrant: (me: unknown, grant: CarriedGrant) => unknown;
28
32
  /** What a token grants the principal it yields; `undefined` for any other principal. */
29
- export declare const grantOf: (principal: unknown) => CarriedGrant | undefined;
33
+ export declare const grantOf: (principal: unknown) => MarkedGrant | undefined;
30
34
  /**
31
35
  * Whether `me` is an agent's: the principal a request with an agent's access token for
32
36
  * the MCP endpoint runs as (`ctx.$state.me`), and the identity `principal` is given
@@ -47,7 +47,7 @@ export const withGrant = (me, grant) => {
47
47
  const copy = Object.assign(Object.create(Object.getPrototypeOf(me)), me);
48
48
  const frozen = Object.freeze({
49
49
  clientId: grant.clientId,
50
- scopes: new Set(grant.scopes),
50
+ scopes: Object.freeze([...grant.scopes]),
51
51
  via: grant.via,
52
52
  });
53
53
  try {
@@ -139,9 +139,15 @@ export type AccessSchemaResourceFacts = {
139
139
  export type AccessClaimType = {
140
140
  readonly scalar: "string" | "number" | "boolean";
141
141
  };
142
- /** The `@1` policies file. `Governed` keeps the `govern` block's literal type through the builder. */
142
+ /**
143
+ * The `@1` policies file. `Governed` keeps the `govern` block's literal type through
144
+ * the builder: per Resource, the list form (each field governed for reading and
145
+ * writing) or the object form (a mode per field).
146
+ */
143
147
  export type AccessPolicies<Governed extends {
144
- readonly [resource: string]: readonly string[];
148
+ readonly [resource: string]: readonly string[] | {
149
+ readonly [field: string]: "read" | "write" | "both";
150
+ };
145
151
  } = {
146
152
  readonly [resource: string]: readonly string[];
147
153
  }> = {
@@ -157,7 +163,21 @@ export type AccessPolicies<Governed extends {
157
163
  readonly roles: {
158
164
  readonly [role: string]: AccessRoleDefinition;
159
165
  };
160
- readonly govern: Governed;
166
+ /** Every governed field, by Resource, whatever its mode. */
167
+ readonly govern: {
168
+ readonly [R in keyof Governed]: Governed[R] extends readonly string[] ? Governed[R] : readonly (keyof Governed[R] & string)[];
169
+ };
170
+ /**
171
+ * The governed fields governed for one access only, by Resource: `"write"`, read by
172
+ * default and written only through a grant that names the field; `"read"`, written
173
+ * by default and read only through a grant that names it. A governed field with no
174
+ * entry is governed for both, and a file without `governModes` has no other kind.
175
+ */
176
+ readonly governModes?: {
177
+ readonly [resource: string]: {
178
+ readonly [field: string]: "read" | "write";
179
+ };
180
+ };
161
181
  readonly grants: readonly AccessGrantRule[];
162
182
  readonly denies: readonly AccessDenyRule[];
163
183
  };
@@ -169,7 +189,9 @@ export type AccessPolicies<Governed extends {
169
189
  * is: only its outline is typed, and the engine checks the rest when it is built.
170
190
  */
171
191
  export type AccessPoliciesJson<Governed extends {
172
- readonly [resource: string]: readonly string[];
192
+ readonly [resource: string]: readonly string[] | {
193
+ readonly [field: string]: "read" | "write" | "both";
194
+ };
173
195
  } = {
174
196
  readonly [resource: string]: readonly string[];
175
197
  }> = {
@@ -187,6 +209,11 @@ export type AccessPoliciesJson<Governed extends {
187
209
  readonly [role: string]: object;
188
210
  };
189
211
  readonly govern: Governed;
212
+ readonly governModes?: {
213
+ readonly [resource: string]: {
214
+ readonly [field: string]: string;
215
+ };
216
+ };
190
217
  readonly grants: readonly object[];
191
218
  readonly denies: readonly object[];
192
219
  };
@@ -21,7 +21,9 @@ export declare const idAllocator: () => (prefix: string, name: unknown) => strin
21
21
  export declare const compilePrincipalIf: (rule: string, condition: unknown, claimTypes: ClaimTypes) => AccessPrincipalCondition;
22
22
  /** Compiles the builder's rules to the `@1` policies file and checks it. */
23
23
  export declare const compilePolicies: <Governed extends {
24
- readonly [resource: string]: readonly string[];
24
+ readonly [resource: string]: readonly string[] | {
25
+ readonly [field: string]: "read" | "write" | "both";
26
+ };
25
27
  }>(definition: AccessDefinition) => AccessPolicies<Governed>;
26
28
  export declare const checkActions: (rule: string, actions: readonly string[]) => void;
27
29
  /** Whether `value` is a name a file may give a Resource or a field. */