@protobase/schema 0.61.0 → 0.62.0

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.
@@ -24,8 +24,8 @@ export type ResolveOptions = {
24
24
  roles?: {
25
25
  /** The rule allowing a capability, as `defineRoles` makes it. */
26
26
  can: (capability: any) => Rule;
27
- /** The fields the user's roles list for a capability, or `undefined` for every field. */
28
- fields?: (user: AccessUser, capability: string) => readonly string[] | undefined;
27
+ /** The fields the user's roles list for a capability on the resource, or `undefined` for every field. */
28
+ fields?: (user: AccessUser, capability: string, model: ResourceModel) => readonly string[] | undefined;
29
29
  /** The roles by name, to tell whether the project has an `anonymous` role. */
30
30
  definitions?: Record<string, unknown>;
31
31
  /** Whether the user's roles grant all of a capability, as `defineRoles` makes it; the gate of API functions. */
@@ -104,7 +104,7 @@ const resolveAccess = async (source, ctx, options = {}) => {
104
104
  model,
105
105
  operation
106
106
  });
107
- const listed = (operation) => options.roles?.fields?.(ctx.user, capabilityOf(model, operation));
107
+ const listed = (operation) => options.roles?.fields?.(ctx.user, capabilityOf(model, operation), model);
108
108
  const lists = {
109
109
  read: listed("read"),
110
110
  create: listed("create"),
@@ -1,3 +1,4 @@
1
+ import { ResourceModel } from "../model.js";
1
2
  import { AccessUser } from "./types.js";
2
3
  import { Capability, ExactCapability, Grant } from "./capability.js";
3
4
  import { HeldCapability, RoleEntry } from "./role-entry.js";
@@ -51,12 +52,14 @@ export declare const defineRoles: <Name extends string = string>(definitions: Re
51
52
  holds: (user: AccessUser, capability: Capability<Name>) => boolean;
52
53
  /**
53
54
  * The fields the user's roles allow for a capability: the union of their lists, or `undefined` when a role grants
54
- * it without a list (every field) or none grants it.
55
+ * it without a list (every field) or none grants it. With the resource's model, as `can` has it, wildcards over
56
+ * resources count only where the resource takes them.
55
57
  */
56
- fields: (user: AccessUser, capability: string) => readonly string[] | undefined;
58
+ fields: (user: AccessUser, capability: string, model?: ResourceModel) => readonly string[] | undefined;
57
59
  /**
58
60
  * A rule that allows when the user's roles grant the capability: `true` for an unscoped
59
- * grant, a row filter for `.own`, `.team` and a limit's filter, `false` otherwise. Refuses a wildcard.
61
+ * grant, a row filter for `.own`, `.team` and a limit's filter, `false` otherwise. Refuses a wildcard. On a resource
62
+ * with `.explicitGrants()`, a role's wildcard over resources such as `*.read` grants nothing.
60
63
  */
61
64
  can: (capability: ExactCapability<Name>) => Rule;
62
65
  /** A rule that allows when the user has any of the roles. */
@@ -4,6 +4,7 @@ import { heldCapabilities } from "./role-entry.js";
4
4
  import { rule } from "./rule.js";
5
5
  //#region src/access/roles.ts
6
6
  const rolesOf = (user) => user.roles ?? [];
7
+ const reaches = (held, model) => !model?.explicitGrants || held.capability === "*" || parseCapability(held.capability).name !== "*";
7
8
  /**
8
9
  * Defines roles as bundles of capability strings. Pass the resource and area names as the type
9
10
  * parameter to reject unknown names at compile time:
@@ -66,22 +67,24 @@ const defineRoles = (definitions, options = {}) => {
66
67
  holds: (user, capability) => heldBy(user).some((held) => !held.fields && !held.filter && holdsAll(held.capability, capability)),
67
68
  /**
68
69
  * The fields the user's roles allow for a capability: the union of their lists, or `undefined` when a role grants
69
- * it without a list (every field) or none grants it.
70
+ * it without a list (every field) or none grants it. With the resource's model, as `can` has it, wildcards over
71
+ * resources count only where the resource takes them.
70
72
  */
71
- fields: (user, capability) => {
73
+ fields: (user, capability, model) => {
72
74
  const wanted = parseCapability(capability);
73
- const matching = heldBy(user).filter((held) => grantsOf(held.capability, wanted).length > 0);
75
+ const matching = heldBy(user).filter((held) => reaches(held, model) && grantsOf(held.capability, wanted).length > 0);
74
76
  if (matching.length === 0 || matching.some((held) => !held.fields)) return void 0;
75
77
  return [...new Set(matching.flatMap((held) => held.fields))];
76
78
  },
77
79
  /**
78
80
  * A rule that allows when the user's roles grant the capability: `true` for an unscoped
79
- * grant, a row filter for `.own`, `.team` and a limit's filter, `false` otherwise. Refuses a wildcard.
81
+ * grant, a row filter for `.own`, `.team` and a limit's filter, `false` otherwise. Refuses a wildcard. On a resource
82
+ * with `.explicitGrants()`, a role's wildcard over resources such as `*.read` grants nothing.
80
83
  */
81
84
  can: (capability) => {
82
85
  const wanted = parseExactCapability(capability);
83
86
  return rule(async (ctx) => {
84
- const matching = heldBy(ctx.user ?? {}).flatMap((held) => grantsOf(held.capability, wanted).map((grant) => ({
87
+ const matching = heldBy(ctx.user ?? {}).filter((held) => reaches(held, ctx.model)).flatMap((held) => grantsOf(held.capability, wanted).map((grant) => ({
85
88
  grant,
86
89
  filter: held.filter
87
90
  })));
package/dist/model.d.ts CHANGED
@@ -149,6 +149,8 @@ export type ResourceModel = {
149
149
  comments?: true;
150
150
  /** Whether open pages refresh as soon as its records change (`.live()`). */
151
151
  live?: true;
152
+ /** Whether role wildcards such as `*.read` leave it out, so only `*` and capabilities naming it grant it (`.explicitGrants()`). */
153
+ explicitGrants?: true;
152
154
  /**
153
155
  * The fields of a unique key whose conflict on create answers the record already holding those values, to a caller
154
156
  * who may read it (`.openExisting()`).
@@ -24,6 +24,7 @@ export type ResourceState = {
24
24
  history?: boolean;
25
25
  comments?: boolean;
26
26
  live?: boolean;
27
+ explicitGrants?: boolean;
27
28
  openExisting?: string[];
28
29
  };
29
30
  export declare const parseTable: (table: string) => {
@@ -75,6 +75,7 @@ const assembleModel = (state) => {
75
75
  ...state.history && { history: true },
76
76
  ...state.comments && { comments: true },
77
77
  ...state.live && { live: true },
78
+ ...state.explicitGrants && { explicitGrants: true },
78
79
  ...state.position && { position: { ...state.position } },
79
80
  ...state.parents && { parents: state.parents.map((parent) => ({ ...parent })) },
80
81
  ...state.openExisting && { openExisting: [...state.openExisting] }
@@ -170,6 +170,12 @@ export declare class ResourceBuilder<F extends FieldsInput = {}, K = never> {
170
170
  * [Live updates](docs/website/src/content/docs/reference/live.mdx).
171
171
  */
172
172
  live(): ResourceBuilder<F, K>;
173
+ /**
174
+ * Leaves it out of role wildcards over resources such as `*.read`: with roles, only `*` itself and capabilities that
175
+ * name it, such as `webhook_subscriptions.read`, grant it. For resources whose records hold secrets, which a role
176
+ * written as "read everything" should not reach. See [Resources that need named grants](docs/website/src/content/docs/reference/access.mdx#resources-that-need-named-grants).
177
+ */
178
+ explicitGrants(): ResourceBuilder<F, K>;
173
179
  /** The code predicates (`match`) of its events, by event name. */
174
180
  eventMatches(): Record<string, EventMatch<any>>;
175
181
  /**
package/dist/resource.js CHANGED
@@ -192,6 +192,14 @@ var ResourceBuilder = class ResourceBuilder {
192
192
  live() {
193
193
  return this.next({ live: true });
194
194
  }
195
+ /**
196
+ * Leaves it out of role wildcards over resources such as `*.read`: with roles, only `*` itself and capabilities that
197
+ * name it, such as `webhook_subscriptions.read`, grant it. For resources whose records hold secrets, which a role
198
+ * written as "read everything" should not reach. See [Resources that need named grants](docs/website/src/content/docs/reference/access.mdx#resources-that-need-named-grants).
199
+ */
200
+ explicitGrants() {
201
+ return this.next({ explicitGrants: true });
202
+ }
195
203
  /** The code predicates (`match`) of its events, by event name. */
196
204
  eventMatches() {
197
205
  return eventMatches(this.toModel(), this.state.events ?? []);
@@ -3,7 +3,10 @@ import { resource } from "../resource.js";
3
3
  import { view } from "../view.js";
4
4
  import { layout } from "../layout.js";
5
5
  //#region src/webhooks/webhook-resources.ts
6
- /** Webhook subscriptions: a row per subscription someone made in the admin app, scoped to their tenant. */
6
+ /**
7
+ * Webhook subscriptions: a row per subscription someone made in the admin app, scoped to their tenant. Their URLs and
8
+ * secrets let anyone call the receivers, so a role's wildcard such as `*.read` reaches none of the webhook resources.
9
+ */
7
10
  const subscriptionsResource = (schema) => resource("webhook_subscriptions").table(`${schema}.subscriptions`).fields({
8
11
  id: f.uuid().readOnly().dbDefault(),
9
12
  name: f.text().min(1).max(100).filterable().sortable(),
@@ -26,7 +29,7 @@ const subscriptionsResource = (schema) => resource("webhook_subscriptions").tabl
26
29
  rotate_secret: f.boolean().default(false),
27
30
  send_test: f.boolean().default(false),
28
31
  resend_failed: f.boolean().default(false)
29
- }).primaryKey((r) => r.id).tenant((r) => r.tenant_id).owner((r) => r.owner_id).search((r) => [r.name, r.url]);
32
+ }).primaryKey((r) => r.id).tenant((r) => r.tenant_id).owner((r) => r.owner_id).search((r) => [r.name, r.url]).explicitGrants();
30
33
  const subscriptionsView = view("webhook_subscriptions").names({
31
34
  singular: "Webhook subscription",
32
35
  plural: "Webhook subscriptions"
@@ -117,7 +120,7 @@ const deliveriesResource = (schema) => resource("webhook_deliveries").table(`${s
117
120
  last_error: f.text().optional().readOnly(),
118
121
  created_at: f.timestamp().readOnly().sortable(),
119
122
  resend: f.boolean()
120
- }).primaryKey((r) => r.id).tenant((r) => r.tenant_id).access({
123
+ }).primaryKey((r) => r.id).tenant((r) => r.tenant_id).explicitGrants().access({
121
124
  create: () => false,
122
125
  delete: () => false
123
126
  });
@@ -169,7 +172,7 @@ const deliveryLogResource = (schema) => resource("webhook_delivery_log").table(`
169
172
  response: f.text().optional().readOnly(),
170
173
  duration_ms: f.integer().readOnly(),
171
174
  at: f.timestamp().readOnly().filterable().sortable()
172
- }).primaryKey((r) => r.id).tenant((r) => r.tenant_id).access({
175
+ }).primaryKey((r) => r.id).tenant((r) => r.tenant_id).explicitGrants().access({
173
176
  create: () => false,
174
177
  update: () => false,
175
178
  delete: () => false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/schema",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "Resource, field, view and access definitions for Protobase configs.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {