@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
@@ -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 { isPlainObject, ownOf } from "./scope.resolver.js";
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
- for (const [resource, fields] of Object.entries(definition.govern)) {
550
- for (const field of fields) {
551
- fieldOf({ schema: definition.schema, rule: `govern("${resource}")` }, resource, field);
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: Object.fromEntries(Object.entries(definition.govern).map(([resource, fields]) => [
563
- resource,
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, fields) => {
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
- const named = pair.filter((field) => fields.includes(field));
1277
- if (named.length > 0 && named.length < pair.length) {
1278
- const missing = pair.filter((field) => !fields.includes(field));
1279
- fail(`govern("${resource}")`, `governs ${named.map((field) => `"${field}"`).join(", ")} and not ${missing.map((field) => `"${field}"`).join(", ")}: the relation "${relation}" and its reference fields (${references.join(", ")}) each tell the other, so govern them together`);
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
- checkGovernPairs(policies, resource, fields);
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");
@@ -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
- const governed = ownOf(file.govern, question.resource) ?? [];
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 = [];
@@ -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,3 @@
1
+ import type { AccessPolicies } from "./policies-contract.js";
2
+ /** Warns once per field governed `"read"` that is part of one of its Resource's identifiers. */
3
+ export declare const warnReadGovernedUnique: (policies: AccessPolicies) => void;
@@ -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
- * The warnings for `policies` hosted by a framework that holds references to the
4
- * Resources in `held`, one per writing role and target Resource, each naming every
5
- * reference that role may write to it.
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 declare const referenceReadWarnings: (policies: AccessPolicies, held: ReadonlySet<string>) => readonly string[];
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>) => void;
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
- import { emitAccessWarning } from "./access-warning.emitter.js";
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
- export const referenceReadWarnings = (policies, held) => {
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
- return `${what}, but no read grant on "${target}" reaches ${writer}. ${hold} Grant read on "${target}" to ${to}, or leave ${out}`;
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 Object.keys(isRecord(file.govern) ? file.govern : {})) {
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 Object.keys(isRecord(file.govern) ? file.govern : {})) {
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
- user: { key: "users", writes: { update: ["role"], delete: false } },
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
@@ -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;
@@ -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": "0.1.0-rc.0",
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.9",
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",