@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.
- package/CHANGELOG.md +53 -7
- package/README.md +146 -49
- package/dist/access/access-options.d.ts +10 -2
- package/dist/access/access.factory.d.ts +3 -1
- package/dist/access/principal.subject.d.ts +11 -1
- package/dist/access/principal.subject.js +32 -3
- package/dist/access/request.state.js +75 -5
- package/dist/access-warning.emitter.d.ts +2 -0
- package/dist/access-warning.emitter.js +4 -0
- package/dist/access.builder.d.ts +48 -7
- package/dist/access.builder.js +10 -1
- package/dist/access.engine.d.ts +16 -3
- package/dist/access.engine.js +16 -9
- package/dist/better-auth/access.provider.d.ts +3 -1
- package/dist/cli/policies.diff.js +19 -1
- package/dist/foreign-references.check.d.ts +0 -6
- package/dist/foreign-references.check.js +2 -5
- package/dist/generator/prisma7/prisma-where.translator.d.ts +5 -4
- package/dist/generator/prisma7/prisma-where.translator.js +7 -5
- package/dist/mcp/agent-scope.gate.d.ts +3 -1
- package/dist/mcp/agent-scope.gate.js +1 -1
- package/dist/mcp/agent.grant.d.ts +5 -1
- package/dist/mcp/agent.grant.js +1 -1
- package/dist/policies-contract.d.ts +31 -4
- package/dist/policies.compiler.d.ts +3 -1
- package/dist/policies.compiler.js +89 -14
- package/dist/policies.decider.js +11 -3
- package/dist/policies.hash.js +2 -1
- package/dist/read-governed-unique.check.d.ts +3 -0
- package/dist/read-governed-unique.check.js +21 -0
- package/dist/reference-reads.check.d.ts +10 -6
- package/dist/reference-reads.check.js +20 -10
- package/dist/registry/ownership.check.js +8 -2
- package/dist/resources/auth-tables.catalog.d.ts +5 -0
- package/dist/resources/auth-tables.catalog.js +11 -3
- package/dist/resources/auth-tables.guard.d.ts +11 -0
- package/dist/resources/auth-tables.guard.js +58 -1
- package/dist/scope.resolver.d.ts +6 -0
- package/dist/scope.resolver.js +17 -0
- package/package.json +3 -2
- package/skill/SKILL.md +170 -0
- package/skill/reference/callers.md +99 -0
- package/skill/reference/errors.md +89 -0
- package/skill/reference/recipes.md +281 -0
- package/skill/reference/rules.md +153 -0
- package/skill/reference/setup.md +334 -0
- package/src/generator/prisma7/prisma-where.translator.ts +7 -5
- package/src/policies-contract.ts +35 -4
- package/src/policies.decider.ts +14 -2
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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}`;
|
package/dist/access.builder.d.ts
CHANGED
|
@@ -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]:
|
|
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]
|
|
185
|
-
}[keyof Gov] : R extends keyof Gov ? `${R & string}.${Gov[R]
|
|
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
|
|
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
|
-
/**
|
|
267
|
-
|
|
268
|
-
|
|
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>;
|
package/dist/access.builder.js
CHANGED
|
@@ -34,7 +34,16 @@ const builderOf = (definition) => ({
|
|
|
34
34
|
...definition,
|
|
35
35
|
govern: {
|
|
36
36
|
...definition.govern,
|
|
37
|
-
[resource]: [
|
|
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({
|
package/dist/access.engine.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
29
|
-
|
|
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[])[];
|
package/dist/access.engine.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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",
|
|
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
|
-
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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 =
|
|
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.
|
|
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) =>
|
|
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
|
package/dist/mcp/agent.grant.js
CHANGED
|
@@ -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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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. */
|