@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
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// Nothing here runs per request.
|
|
8
8
|
import { POLICIES_FORMAT, RETIRED_POLICIES_FORMAT, } from "./policies-contract.js";
|
|
9
9
|
import { decimalParts, instantOf } from "./predicate.evaluator.js";
|
|
10
|
-
import {
|
|
10
|
+
import { warnReadGovernedUnique } from "./read-governed-unique.check.js";
|
|
11
|
+
import { governedFor, isPlainObject, ownOf } from "./scope.resolver.js";
|
|
11
12
|
/** The marker a `me` value carries; the compiler writes it as `{ principal: [...] }`. */
|
|
12
13
|
export const PRINCIPAL_REF = Symbol.for("aventara.access.principal-ref");
|
|
13
14
|
export const ACTIONS = [
|
|
@@ -33,6 +34,7 @@ const FILE_KEYS = new Set([
|
|
|
33
34
|
"schema",
|
|
34
35
|
"roles",
|
|
35
36
|
"govern",
|
|
37
|
+
"governModes",
|
|
36
38
|
"grants",
|
|
37
39
|
"denies",
|
|
38
40
|
]);
|
|
@@ -546,9 +548,28 @@ export const compilePolicies = (definition) => {
|
|
|
546
548
|
const allocate = idAllocator();
|
|
547
549
|
const grants = definition.grants.map((rule) => compileGrant(definition, rule, allocate));
|
|
548
550
|
const denies = definition.denies.map((rule) => compileDeny(definition, rule, allocate, claimTypes));
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
551
|
+
// Each governed field once, with one mode: "both" in `govern` alone, "read" or
|
|
552
|
+
// "write" in `governModes` too.
|
|
553
|
+
const govern = {};
|
|
554
|
+
const governModes = {};
|
|
555
|
+
for (const [resource, entries] of Object.entries(definition.govern)) {
|
|
556
|
+
const at = `govern("${resource}")`;
|
|
557
|
+
const modes = new Map();
|
|
558
|
+
for (const { field, mode } of entries) {
|
|
559
|
+
fieldOf({ schema: definition.schema, rule: at }, resource, field);
|
|
560
|
+
if (mode !== "read" && mode !== "write" && mode !== "both") {
|
|
561
|
+
fail(at, `"${field}" is ${JSON.stringify(mode)}; a mode is "read", "write" or "both"`);
|
|
562
|
+
}
|
|
563
|
+
const prior = modes.get(field);
|
|
564
|
+
if (prior !== undefined && prior !== mode) {
|
|
565
|
+
fail(at, `"${field}" is governed twice, as "${prior}" and as "${mode}"; govern each field once, with one mode`);
|
|
566
|
+
}
|
|
567
|
+
modes.set(field, mode);
|
|
568
|
+
}
|
|
569
|
+
govern[resource] = [...modes.keys()];
|
|
570
|
+
const moded = [...modes].filter(([, mode]) => mode !== "both");
|
|
571
|
+
if (moded.length > 0) {
|
|
572
|
+
governModes[resource] = Object.fromEntries(moded);
|
|
552
573
|
}
|
|
553
574
|
}
|
|
554
575
|
const policies = {
|
|
@@ -559,14 +580,13 @@ export const compilePolicies = (definition) => {
|
|
|
559
580
|
claims: Object.fromEntries(Object.entries(claimTypes).sort(([a], [b]) => a.localeCompare(b))),
|
|
560
581
|
schema: schemaFacts(definition.schema),
|
|
561
582
|
roles,
|
|
562
|
-
govern:
|
|
563
|
-
|
|
564
|
-
[...new Set(fields)],
|
|
565
|
-
])),
|
|
583
|
+
govern: govern,
|
|
584
|
+
...(Object.keys(governModes).length > 0 ? { governModes } : {}),
|
|
566
585
|
grants,
|
|
567
586
|
denies,
|
|
568
587
|
};
|
|
569
588
|
checkPolicies(policies);
|
|
589
|
+
warnReadGovernedUnique(policies);
|
|
570
590
|
return policies;
|
|
571
591
|
};
|
|
572
592
|
// ---------------------------------------------------------------------------
|
|
@@ -1088,6 +1108,15 @@ const checkShape = (policies) => {
|
|
|
1088
1108
|
fail("policies", `"${block}" is an object`);
|
|
1089
1109
|
}
|
|
1090
1110
|
}
|
|
1111
|
+
if (policies.governModes !== undefined && !isRecord(policies.governModes)) {
|
|
1112
|
+
fail("policies", '"governModes" is an object');
|
|
1113
|
+
}
|
|
1114
|
+
// One set of rules, one file: a file whose fields are all governed for both
|
|
1115
|
+
// leaves the member out.
|
|
1116
|
+
if (policies.governModes !== undefined &&
|
|
1117
|
+
Object.keys(policies.governModes).length === 0) {
|
|
1118
|
+
fail("policies", '"governModes" names at least one Resource; a file whose governed fields are all governed for both has no "governModes"');
|
|
1119
|
+
}
|
|
1091
1120
|
for (const block of ["grants", "denies"]) {
|
|
1092
1121
|
if (!Array.isArray(policies[block])) {
|
|
1093
1122
|
fail("policies", `"${block}" is a list`);
|
|
@@ -1265,18 +1294,61 @@ const checkTypes = (resource, facts) => {
|
|
|
1265
1294
|
* A governed relation seals its reference fields, and a governed reference field its
|
|
1266
1295
|
* relation: reading either one tells the other. So `govern` names both or neither.
|
|
1267
1296
|
*/
|
|
1268
|
-
const checkGovernPairs = (policies, resource
|
|
1297
|
+
const checkGovernPairs = (policies, resource) => {
|
|
1269
1298
|
const relations = ownOf(policies.schema, resource)?.relations ?? {};
|
|
1299
|
+
const read = governedFor(policies, resource, "read");
|
|
1300
|
+
const write = governedFor(policies, resource, "write");
|
|
1270
1301
|
for (const [relation, fact] of Object.entries(relations)) {
|
|
1271
1302
|
const references = fact.referenceFields ?? [];
|
|
1272
1303
|
if (references.length === 0) {
|
|
1273
1304
|
continue;
|
|
1274
1305
|
}
|
|
1275
1306
|
const pair = [relation, ...references];
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1307
|
+
// Checked for each access: reading either one tells the other, and writing
|
|
1308
|
+
// either one sets the other.
|
|
1309
|
+
const gapIn = (fields) => {
|
|
1310
|
+
const named = pair.filter((field) => fields.includes(field));
|
|
1311
|
+
return named.length > 0 && named.length < pair.length ? named : undefined;
|
|
1312
|
+
};
|
|
1313
|
+
const readGap = gapIn(read);
|
|
1314
|
+
const writeGap = gapIn(write);
|
|
1315
|
+
const named = readGap ?? writeGap;
|
|
1316
|
+
if (named === undefined) {
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
const fields = readGap !== undefined ? read : write;
|
|
1320
|
+
const missing = pair.filter((field) => !fields.includes(field));
|
|
1321
|
+
const access = readGap !== undefined &&
|
|
1322
|
+
writeGap !== undefined &&
|
|
1323
|
+
readGap.join() === writeGap.join()
|
|
1324
|
+
? ""
|
|
1325
|
+
: readGap !== undefined
|
|
1326
|
+
? " for reading"
|
|
1327
|
+
: " for writing";
|
|
1328
|
+
fail(`govern("${resource}")`, `governs ${named.map((field) => `"${field}"`).join(", ")} and not ${missing.map((field) => `"${field}"`).join(", ")}${access}: the relation "${relation}" and its reference fields (${references.join(", ")}) each tell the other, so govern them together`);
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
/**
|
|
1332
|
+
* `governModes`: each entry a Resource's fields `govern` lists, each `"read"` or
|
|
1333
|
+
* `"write"`; a field governed for both has no entry, so one set of rules has one file.
|
|
1334
|
+
*/
|
|
1335
|
+
const checkGovernModes = (policies) => {
|
|
1336
|
+
for (const [resource, modes] of Object.entries(policies.governModes ?? {})) {
|
|
1337
|
+
const at = `governModes("${resource}")`;
|
|
1338
|
+
if (!isRecord(modes)) {
|
|
1339
|
+
fail(at, 'an entry is an object of fields to "read" or "write"');
|
|
1340
|
+
}
|
|
1341
|
+
if (Object.keys(modes).length === 0) {
|
|
1342
|
+
fail(at, "an entry names at least one field");
|
|
1343
|
+
}
|
|
1344
|
+
const governed = ownOf(policies.govern, resource) ?? [];
|
|
1345
|
+
for (const [field, mode] of Object.entries(modes)) {
|
|
1346
|
+
if (!governed.includes(field)) {
|
|
1347
|
+
fail(at, `"${field}" is not governed; govern("${resource}") lists every governed field`);
|
|
1348
|
+
}
|
|
1349
|
+
if (mode !== "read" && mode !== "write") {
|
|
1350
|
+
fail(at, `"${field}" is ${JSON.stringify(mode)}; a mode there is "read" or "write" (a field governed for both has no entry)`);
|
|
1351
|
+
}
|
|
1280
1352
|
}
|
|
1281
1353
|
}
|
|
1282
1354
|
};
|
|
@@ -1330,7 +1402,10 @@ export const checkPolicies = (policies, composed = false) => {
|
|
|
1330
1402
|
fail(at, `${resource} has no field "${field}"`);
|
|
1331
1403
|
}
|
|
1332
1404
|
}
|
|
1333
|
-
|
|
1405
|
+
}
|
|
1406
|
+
checkGovernModes(policies);
|
|
1407
|
+
for (const resource of Object.keys(policies.govern)) {
|
|
1408
|
+
checkGovernPairs(policies, resource);
|
|
1334
1409
|
}
|
|
1335
1410
|
policies.grants.forEach((grant, index) => {
|
|
1336
1411
|
checkRuleShape(grant, index, "grant");
|
package/dist/policies.decider.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { translateAnswer, translateWhere, } from "./aventara-where.translator.js";
|
|
9
9
|
import { canonicalJson } from "./canonical-json.writer.js";
|
|
10
10
|
import { decideOnRow, evaluateOnRow, } from "./predicate.evaluator.js";
|
|
11
|
-
import { FALSE, foldDenies, foldGrants, nullSafe, orOf, ownOf, principalFactsOf, rulesFor, scopeCondition, TRUE, } from "./scope.resolver.js";
|
|
11
|
+
import { FALSE, foldDenies, foldGrants, governedFor, nullSafe, orOf, ownOf, principalFactsOf, rulesFor, scopeCondition, TRUE, } from "./scope.resolver.js";
|
|
12
12
|
import { sha256Hex } from "./sha256.digest.js";
|
|
13
13
|
/** A trace with nothing in it yet. */
|
|
14
14
|
export const newTrace = () => ({
|
|
@@ -102,6 +102,8 @@ export const grantsQuestionProblem = (question) => {
|
|
|
102
102
|
? undefined
|
|
103
103
|
: `grantsOf takes \`action: "update"\`; it was asked ${JSON.stringify({ action })}`;
|
|
104
104
|
};
|
|
105
|
+
/** Whether two field lists name the same fields. */
|
|
106
|
+
const sameFields = (a, b) => a.length === b.length && a.every((field) => b.includes(field));
|
|
105
107
|
/** The decisions of one policies file, already checked. */
|
|
106
108
|
export const createDecider = (file) => {
|
|
107
109
|
const locks = lockedFieldsOf(file);
|
|
@@ -266,7 +268,13 @@ export const createDecider = (file) => {
|
|
|
266
268
|
throw new Error(`Aventara Access: ${problem}`);
|
|
267
269
|
}
|
|
268
270
|
const facts = principalFactsOf(question.principal, question.origin);
|
|
269
|
-
|
|
271
|
+
// The fields governed for this access: only a grant that names one opens it.
|
|
272
|
+
const governed = governedFor(file, question.resource, question.access);
|
|
273
|
+
// `"all"` stands for every field the read side does not govern (the framework's
|
|
274
|
+
// default projection, whatever the access): a write answers it only when the
|
|
275
|
+
// write side governs the same fields.
|
|
276
|
+
const allStandsFor = question.access === "read" ||
|
|
277
|
+
sameFields(governed, governedFor(file, question.resource, "read"));
|
|
270
278
|
const actions = question.access === "read"
|
|
271
279
|
? ["read"]
|
|
272
280
|
: question.action !== undefined
|
|
@@ -295,7 +303,7 @@ export const createDecider = (file) => {
|
|
|
295
303
|
}
|
|
296
304
|
}
|
|
297
305
|
const excluded = new Set(live.flatMap((grant) => question.access === "write" ? [...(locks.get(grant.grant) ?? [])] : []));
|
|
298
|
-
if (excluded.size === 0) {
|
|
306
|
+
if (excluded.size === 0 && allStandsFor) {
|
|
299
307
|
return { always: "all", perRow };
|
|
300
308
|
}
|
|
301
309
|
const always = [];
|
package/dist/policies.hash.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// (members sorted by key, no whitespace) and encoded as UTF-8, with:
|
|
8
8
|
// - the lists whose order the format gives no meaning sorted: `grants` and `denies`
|
|
9
9
|
// by `id`; a rule's `subjects`, `actions`, `fields` and `write.except`; each
|
|
10
|
-
// `govern` list; each schema entry's `fields` and `nullable`.
|
|
10
|
+
// `govern` list; each schema entry's `fields` and `nullable`. `governModes` is objects
|
|
11
|
+
// only, so its members are sorted like any other.
|
|
11
12
|
// Every other list keeps its order: identifiers (the first is the primary one),
|
|
12
13
|
// relation reference pairs, principal and claim paths, a condition's arguments and
|
|
13
14
|
// an `in` list's values.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The warning ACCESS_READ_GOVERNED_UNIQUE: a field governed for reading only
|
|
2
|
+
// (`"read"`) that is one of its Resource's identifiers. Anyone who may write the row
|
|
3
|
+
// writes the field, and a value another row holds fails on the duplicate: the
|
|
4
|
+
// refusal tells the writer that value exists, which reading it would not. The
|
|
5
|
+
// warning changes nothing the engine decides.
|
|
6
|
+
import { emitAccessWarning } from "./access-warning.emitter.js";
|
|
7
|
+
import { ownOf } from "./scope.resolver.js";
|
|
8
|
+
const WARNING_CODE = "ACCESS_READ_GOVERNED_UNIQUE";
|
|
9
|
+
/** Warns once per field governed `"read"` that is part of one of its Resource's identifiers. */
|
|
10
|
+
export const warnReadGovernedUnique = (policies) => {
|
|
11
|
+
for (const [resource, modes] of Object.entries(policies.governModes ?? {})) {
|
|
12
|
+
const identifiers = ownOf(policies.schema, resource)?.identifiers ?? [];
|
|
13
|
+
for (const [field, mode] of Object.entries(modes)) {
|
|
14
|
+
if (mode !== "read" ||
|
|
15
|
+
!identifiers.some((identifier) => identifier.includes(field))) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
emitAccessWarning(WARNING_CODE, policies.namespace, `${resource}.${field} is governed for reading only ("read") and is unique (one of ${resource}'s identifiers): a create or update that sends a value another row holds fails on the duplicate, which tells the writer that value exists. Govern it as "both", so only a grant that names it writes it, or accept that writers learn which values exist.`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { AccessPolicies } from "./policies-contract.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Better Auth's tables `createAccess` keeps closed and a `resources` key would open,
|
|
4
|
+
* by Resource: no grant on one is taken until that key opens it, so a warning about
|
|
5
|
+
* one names the key.
|
|
6
6
|
*/
|
|
7
|
-
export
|
|
7
|
+
export type ClosedTables = ReadonlyMap<string, {
|
|
8
|
+
readonly model: string;
|
|
9
|
+
readonly key: string;
|
|
10
|
+
}>;
|
|
8
11
|
/**
|
|
9
12
|
* Emits `policies`' warnings under a framework that holds references to the
|
|
10
|
-
* Resources in `held`, each once per process and namespace
|
|
13
|
+
* Resources in `held`, each once per process and namespace; a target in `closed`
|
|
14
|
+
* is named with the `resources` key that opens it.
|
|
11
15
|
*/
|
|
12
|
-
export declare const warnReferenceReads: (policies: AccessPolicies, held: ReadonlySet<string
|
|
16
|
+
export declare const warnReferenceReads: (policies: AccessPolicies, held: ReadonlySet<string>, closed?: ClosedTables) => void;
|
|
@@ -31,7 +31,13 @@
|
|
|
31
31
|
// which row may be linked, foreign-references.check.ts), and a writing Resource it
|
|
32
32
|
// does not serve (`held` is its Core Contract's Resources, and no write reaches one
|
|
33
33
|
// outside it).
|
|
34
|
-
|
|
34
|
+
//
|
|
35
|
+
// Under `createAccess` (or its plugin) a target may be one of Better Auth's tables
|
|
36
|
+
// that stays closed until a `resources` key opens it (`User` until
|
|
37
|
+
// `resources: { users: true }`): no grant on it is taken before that, and the
|
|
38
|
+
// built-in deny refuses a `*` read grant there too, so no read grant reaches it
|
|
39
|
+
// and the warning names the key.
|
|
40
|
+
import { emitAccessWarning, listed } from "./access-warning.emitter.js";
|
|
35
41
|
import { ownOf } from "./scope.resolver.js";
|
|
36
42
|
const WARNING_CODE = "ACCESS_REFERENCE_UNREADABLE";
|
|
37
43
|
/** The Resource a row role is held on, or `undefined` for `signedIn`, `anyone` and a global role. */
|
|
@@ -56,15 +62,12 @@ const reaches = (policies, read, writer, target) => read.subjects.some((subject)
|
|
|
56
62
|
const on = heldOn(policies, writer);
|
|
57
63
|
return on !== undefined && heldOn(policies, subject) === on;
|
|
58
64
|
});
|
|
59
|
-
const listed = (names) => names.length < 2
|
|
60
|
-
? names.join("")
|
|
61
|
-
: `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
62
65
|
/**
|
|
63
66
|
* The warnings for `policies` hosted by a framework that holds references to the
|
|
64
67
|
* Resources in `held`, one per writing role and target Resource, each naming every
|
|
65
68
|
* reference that role may write to it.
|
|
66
69
|
*/
|
|
67
|
-
|
|
70
|
+
const referenceReadWarnings = (policies, held, closed) => {
|
|
68
71
|
const reads = policies.grants.filter((grant) => grant.actions.includes("read"));
|
|
69
72
|
const found = new Map();
|
|
70
73
|
for (const grant of policies.grants) {
|
|
@@ -97,8 +100,10 @@ export const referenceReadWarnings = (policies, held) => {
|
|
|
97
100
|
}
|
|
98
101
|
// A target the schema does not have takes no grant: `*` does not
|
|
99
102
|
// reach it (`rulesFor`), and a grant cannot name it.
|
|
103
|
+
// A Better Auth table createAccess keeps closed takes no read either: the
|
|
104
|
+
// built-in deny refuses every action on it, `*` grants included.
|
|
100
105
|
const own = target !== undefined;
|
|
101
|
-
const readsOfTarget = own
|
|
106
|
+
const readsOfTarget = own && closed?.has(fact.target) !== true
|
|
102
107
|
? reads.filter((read) => read.resource === fact.target || read.resource === "*")
|
|
103
108
|
: [];
|
|
104
109
|
for (const writer of grant.subjects) {
|
|
@@ -134,15 +139,20 @@ export const referenceReadWarnings = (policies, held) => {
|
|
|
134
139
|
const to = writer === "signedIn" || writer === "anyone"
|
|
135
140
|
? writer
|
|
136
141
|
: `${writer} (or to signedIn)`;
|
|
137
|
-
|
|
142
|
+
const table = closed?.get(target);
|
|
143
|
+
const opening = table === undefined
|
|
144
|
+
? ""
|
|
145
|
+
: ` (Better Auth's \`${table.model}\` table, closed until \`resources: { ${table.key}: true }\` in createAccess opens it)`;
|
|
146
|
+
return `${what}, but no read grant on "${target}" reaches ${writer}. ${hold} Grant read on "${target}" to ${to}${opening}, or leave ${out}`;
|
|
138
147
|
});
|
|
139
148
|
};
|
|
140
149
|
/**
|
|
141
150
|
* Emits `policies`' warnings under a framework that holds references to the
|
|
142
|
-
* Resources in `held`, each once per process and namespace
|
|
151
|
+
* Resources in `held`, each once per process and namespace; a target in `closed`
|
|
152
|
+
* is named with the `resources` key that opens it.
|
|
143
153
|
*/
|
|
144
|
-
export const warnReferenceReads = (policies, held) => {
|
|
145
|
-
for (const message of referenceReadWarnings(policies, held)) {
|
|
154
|
+
export const warnReferenceReads = (policies, held, closed) => {
|
|
155
|
+
for (const message of referenceReadWarnings(policies, held, closed)) {
|
|
146
156
|
emitAccessWarning(WARNING_CODE, policies.namespace, message);
|
|
147
157
|
}
|
|
148
158
|
};
|
|
@@ -48,7 +48,10 @@ export const resourcesNamed = (file) => {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
for (const resource of
|
|
51
|
+
for (const resource of [
|
|
52
|
+
...Object.keys(isRecord(file.govern) ? file.govern : {}),
|
|
53
|
+
...Object.keys(isRecord(file.governModes) ? file.governModes : {}),
|
|
54
|
+
]) {
|
|
52
55
|
named.add(resource);
|
|
53
56
|
}
|
|
54
57
|
return [...named];
|
|
@@ -144,7 +147,10 @@ export const checkNamespaceOwnership = (namespace, file, owners, ownership = "fi
|
|
|
144
147
|
refuse(`deny ${ruleId(deny)} in namespace "${namespace}" names ${String(resource)}, which ${owns(owner)}. A namespace's denies narrow only its own Resources; narrowing another namespace's is an org guardrail`);
|
|
145
148
|
}
|
|
146
149
|
}
|
|
147
|
-
for (const resource of
|
|
150
|
+
for (const resource of new Set([
|
|
151
|
+
...Object.keys(isRecord(file.govern) ? file.govern : {}),
|
|
152
|
+
...Object.keys(isRecord(file.governModes) ? file.governModes : {}),
|
|
153
|
+
])) {
|
|
148
154
|
const owner = ownerOf(resource);
|
|
149
155
|
if (owner !== undefined) {
|
|
150
156
|
refuse(`namespace "${namespace}" governs fields of ${resource}, which ${owns(owner)}. Only the owner governs a Resource's fields`);
|
|
@@ -13,6 +13,11 @@ type WritableFields = {
|
|
|
13
13
|
type OpenedTable = {
|
|
14
14
|
readonly key: AuthResourceKey;
|
|
15
15
|
readonly writes: WritableFields;
|
|
16
|
+
/**
|
|
17
|
+
* Fields Access governs for writing (`"write"`): read as the rules grant, written
|
|
18
|
+
* only through a grant that names the field. A role the app's rules read is one.
|
|
19
|
+
*/
|
|
20
|
+
readonly governed?: readonly string[];
|
|
16
21
|
/** Where the table comes from, when a plugin adds it: named in the refusal when it is missing. */
|
|
17
22
|
readonly plugin?: string;
|
|
18
23
|
};
|
|
@@ -9,16 +9,23 @@
|
|
|
9
9
|
// write through Aventara is a plain database write: it skips Better Auth's hooks
|
|
10
10
|
// (`databaseHooks`, plugins' `after` hooks), its cookie cache and its secondary
|
|
11
11
|
// storage, so the writes Access lets through are the ones those do not guard:
|
|
12
|
-
// a user's role and an organization's memberships.
|
|
12
|
+
// a user's role and an organization's memberships. A role is governed for writing,
|
|
13
|
+
// so only a grant that names it sets it.
|
|
13
14
|
/** The tables each key opens, by Better Auth model name. */
|
|
14
15
|
export const OPENED_TABLES = {
|
|
15
16
|
// The users: a user's role may be set; creating or deleting a user, and every
|
|
16
17
|
// other field (name, email, ban), stays with Better Auth's own API.
|
|
17
|
-
|
|
18
|
+
// The role is governed for writing: only a grant that names `User.role` sets it.
|
|
19
|
+
user: {
|
|
20
|
+
key: "users",
|
|
21
|
+
writes: { update: ["role"], delete: false },
|
|
22
|
+
governed: ["role"],
|
|
23
|
+
},
|
|
18
24
|
session: { key: "users", writes: { delete: false } },
|
|
19
25
|
account: { key: "users", writes: { delete: false } },
|
|
20
26
|
// The organization plugin's memberships: a membership is created or removed, and
|
|
21
|
-
// its role changed; the organization itself is read only.
|
|
27
|
+
// its role changed; the organization itself is read only. The role is governed
|
|
28
|
+
// for writing: only a grant that names it sets it, at create or update.
|
|
22
29
|
member: {
|
|
23
30
|
key: "members",
|
|
24
31
|
plugin: "organization()",
|
|
@@ -27,6 +34,7 @@ export const OPENED_TABLES = {
|
|
|
27
34
|
update: ["role"],
|
|
28
35
|
delete: true,
|
|
29
36
|
},
|
|
37
|
+
governed: ["role"],
|
|
30
38
|
},
|
|
31
39
|
organization: {
|
|
32
40
|
key: "members",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AccessFields, AccessFieldsQuestion } from "../policies.decider.js";
|
|
2
2
|
import type { AccessDenyRule, AccessPolicies } from "../policies-contract.js";
|
|
3
|
+
import type { ClosedTables } from "../reference-reads.check.js";
|
|
3
4
|
import { type AuthResourceKey, type AuthTable, type AuthTableNaming } from "./auth-tables.catalog.js";
|
|
4
5
|
/**
|
|
5
6
|
* The `resources` block: which of Better Auth's tables are Resources of the app's
|
|
@@ -53,6 +54,10 @@ export type AuthTablesGuard = {
|
|
|
53
54
|
readonly sealed: ReadonlyMap<string, ReadonlySet<string>>;
|
|
54
55
|
/** The fields a write may set, by opened Resource. */
|
|
55
56
|
readonly writable: ReadonlyMap<string, WritableSets>;
|
|
57
|
+
/** The closed tables a `resources` key opens, by Resource: Better Auth's model name and that key. */
|
|
58
|
+
readonly closed: ClosedTables;
|
|
59
|
+
/** The fields governed for writing, by opened Resource: a grant that names one sets it. */
|
|
60
|
+
readonly governed: ReadonlyMap<string, readonly string[]>;
|
|
56
61
|
};
|
|
57
62
|
/** The `resources` block, checked: `users` and `members` `true` or `false`, `authTables` a map of names or `null`. */
|
|
58
63
|
export declare const checkResourcesOptions: (value: unknown) => ResourcesChoice;
|
|
@@ -82,6 +87,12 @@ export declare const guardAuthTables: (input: {
|
|
|
82
87
|
readonly naming: AuthTableNaming;
|
|
83
88
|
readonly policies: AccessPolicies;
|
|
84
89
|
}) => AuthTablesGuard;
|
|
90
|
+
/**
|
|
91
|
+
* The rules' file with the built-in rules in it: their denies beside the file's, and
|
|
92
|
+
* each field they govern for writing governed so (a field the rules govern for
|
|
93
|
+
* reading only is then governed for both; one they govern for writing or both stays).
|
|
94
|
+
*/
|
|
95
|
+
export declare const withBuiltInRules: (policies: AccessPolicies, guard: AuthTablesGuard) => AccessPolicies;
|
|
85
96
|
/**
|
|
86
97
|
* `fieldsOf`'s answer with the built-in rules applied: a secret column is never
|
|
87
98
|
* listed, and a write lists only the fields its action may set on an opened table.
|
|
@@ -309,6 +309,8 @@ export const guardAuthTables = (input) => {
|
|
|
309
309
|
const denies = [];
|
|
310
310
|
const sealed = new Map();
|
|
311
311
|
const writable = new Map();
|
|
312
|
+
const closed = new Map();
|
|
313
|
+
const governed = new Map();
|
|
312
314
|
for (const table of tables.values()) {
|
|
313
315
|
const { resource } = table;
|
|
314
316
|
const facts = resource === undefined ? undefined : ownOf(policies.schema, resource);
|
|
@@ -341,6 +343,9 @@ export const guardAuthTables = (input) => {
|
|
|
341
343
|
actions: "*",
|
|
342
344
|
resource,
|
|
343
345
|
});
|
|
346
|
+
if (opened !== undefined) {
|
|
347
|
+
closed.set(resource, { model: table.model, key: opened.key });
|
|
348
|
+
}
|
|
344
349
|
const [grant] = grants;
|
|
345
350
|
if (grant !== undefined) {
|
|
346
351
|
refuse(opened === undefined
|
|
@@ -370,9 +375,61 @@ export const guardAuthTables = (input) => {
|
|
|
370
375
|
create: writableSet(table, facts, writes.create, true),
|
|
371
376
|
update: writableSet(table, facts, writes.update, false),
|
|
372
377
|
});
|
|
378
|
+
const writeGoverned = (opened.governed ?? []).flatMap((field) => {
|
|
379
|
+
const name = fieldOf(table, facts, field);
|
|
380
|
+
return name === undefined ? [] : [name];
|
|
381
|
+
});
|
|
382
|
+
if (writeGoverned.length > 0) {
|
|
383
|
+
governed.set(resource, writeGoverned);
|
|
384
|
+
}
|
|
373
385
|
}
|
|
374
386
|
closeBeaconTables(policies, tables, denies, sealed);
|
|
375
|
-
return { denies, sealed, writable };
|
|
387
|
+
return { denies, sealed, writable, closed, governed };
|
|
388
|
+
};
|
|
389
|
+
/**
|
|
390
|
+
* The rules' file with the built-in rules in it: their denies beside the file's, and
|
|
391
|
+
* each field they govern for writing governed so (a field the rules govern for
|
|
392
|
+
* reading only is then governed for both; one they govern for writing or both stays).
|
|
393
|
+
*/
|
|
394
|
+
export const withBuiltInRules = (policies, guard) => {
|
|
395
|
+
if (guard.denies.length === 0 && guard.governed.size === 0) {
|
|
396
|
+
return policies;
|
|
397
|
+
}
|
|
398
|
+
const govern = {
|
|
399
|
+
...policies.govern,
|
|
400
|
+
};
|
|
401
|
+
const governModes = { ...policies.governModes };
|
|
402
|
+
for (const [resource, fields] of guard.governed) {
|
|
403
|
+
const listed = ownOf(policies.govern, resource) ?? [];
|
|
404
|
+
const modes = Object.hasOwn(governModes, resource)
|
|
405
|
+
? { ...governModes[resource] }
|
|
406
|
+
: {};
|
|
407
|
+
const added = [];
|
|
408
|
+
for (const field of fields) {
|
|
409
|
+
if (!listed.includes(field)) {
|
|
410
|
+
added.push(field);
|
|
411
|
+
modes[field] = "write";
|
|
412
|
+
}
|
|
413
|
+
else if (Object.hasOwn(modes, field) && modes[field] === "read") {
|
|
414
|
+
// Governed for reading by the rules, for writing by Access: for both.
|
|
415
|
+
delete modes[field];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
govern[resource] = [...listed, ...added];
|
|
419
|
+
if (Object.keys(modes).length > 0) {
|
|
420
|
+
governModes[resource] = modes;
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
delete governModes[resource];
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const { governModes: _given, ...rest } = policies;
|
|
427
|
+
return {
|
|
428
|
+
...rest,
|
|
429
|
+
govern,
|
|
430
|
+
...(Object.keys(governModes).length > 0 ? { governModes } : {}),
|
|
431
|
+
denies: [...policies.denies, ...guard.denies],
|
|
432
|
+
};
|
|
376
433
|
};
|
|
377
434
|
/**
|
|
378
435
|
* `fieldsOf`'s answer with the built-in rules applied: a secret column is never
|
package/dist/scope.resolver.d.ts
CHANGED
|
@@ -55,6 +55,12 @@ export declare function ownOf(record: AccessPolicies["claims"] | undefined, key:
|
|
|
55
55
|
export declare function ownOf(record: {
|
|
56
56
|
readonly [key: string]: readonly string[];
|
|
57
57
|
} | undefined, key: string): readonly string[] | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* A Resource's governed fields for one access: each field `govern` lists, except one
|
|
60
|
+
* `governModes` governs for the other access only (`"write"` is read as any other
|
|
61
|
+
* field, `"read"` written as any other). A field with no mode is governed for both.
|
|
62
|
+
*/
|
|
63
|
+
export declare const governedFor: (policies: Pick<AccessPolicies, "govern" | "governModes">, resource: string, access: "read" | "write") => readonly string[];
|
|
58
64
|
/** A plain object: made by `{}` or JSON, not an array, a `Date`, a `Map` or a class instance. */
|
|
59
65
|
export declare const isPlainObject: (value: unknown) => value is {
|
|
60
66
|
readonly [key: string]: unknown;
|
package/dist/scope.resolver.js
CHANGED
|
@@ -44,6 +44,23 @@ export function ownOf(record, key) {
|
|
|
44
44
|
? record[key]
|
|
45
45
|
: undefined;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* A Resource's governed fields for one access: each field `govern` lists, except one
|
|
49
|
+
* `governModes` governs for the other access only (`"write"` is read as any other
|
|
50
|
+
* field, `"read"` written as any other). A field with no mode is governed for both.
|
|
51
|
+
*/
|
|
52
|
+
export const governedFor = (policies, resource, access) => {
|
|
53
|
+
const fields = ownOf(policies.govern, resource);
|
|
54
|
+
const modes = policies.governModes !== undefined &&
|
|
55
|
+
Object.hasOwn(policies.governModes, resource)
|
|
56
|
+
? policies.governModes[resource]
|
|
57
|
+
: undefined;
|
|
58
|
+
if (fields === undefined || modes === undefined) {
|
|
59
|
+
return fields ?? [];
|
|
60
|
+
}
|
|
61
|
+
const other = access === "read" ? "write" : "read";
|
|
62
|
+
return fields.filter((field) => !(Object.hasOwn(modes, field) && modes[field] === other));
|
|
63
|
+
};
|
|
47
64
|
/** A plain object: made by `{}` or JSON, not an array, a `Date`, a `Map` or a class instance. */
|
|
48
65
|
export const isPlainObject = (value) => {
|
|
49
66
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aventara/access",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "A policy engine for Aventara: role-first rules typed from your schema, compiled to a passive policies file.",
|
|
6
6
|
"type": "module",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"src/policies.decider.ts",
|
|
58
58
|
"src/beacon/beacon.check.ts",
|
|
59
59
|
"src/generator/prisma7/prisma-where.translator.ts",
|
|
60
|
+
"skill",
|
|
60
61
|
"LICENSE-ADDITIONAL-PERMISSION.md",
|
|
61
62
|
"CHANGELOG.md"
|
|
62
63
|
],
|
|
@@ -89,7 +90,7 @@
|
|
|
89
90
|
}
|
|
90
91
|
},
|
|
91
92
|
"devDependencies": {
|
|
92
|
-
"@aventara/core": "1.0.0-rc.
|
|
93
|
+
"@aventara/core": "1.0.0-rc.11",
|
|
93
94
|
"@better-auth/cimd": "1.7.14",
|
|
94
95
|
"@better-auth/expo": "1.7.14",
|
|
95
96
|
"@better-auth/oauth-provider": "1.7.14",
|