@feasibleone/blong-gogo 1.32.0 → 1.33.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.
- package/CHANGELOG.md +7 -0
- package/package.json +2 -2
- package/src/Watch.ts +4 -0
- package/src/adapter/schema/knex/types.ts +92 -1
- package/src/adapter/server/acl.ts +440 -0
- package/src/adapter/server/knex.ts +398 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.33.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.32.0...blong-gogo-v1.33.0) (2026-09-15)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **blong-dev:** keep frictions, todos and decisions as verified memory files ([4ca7a3b](https://github.com/feasibleone/blong/commit/4ca7a3be1f8919e0dc121974d3d5ad713883c456))
|
|
9
|
+
|
|
3
10
|
## [1.32.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.31.3...blong-gogo-v1.32.0) (2026-09-12)
|
|
4
11
|
|
|
5
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"tap": "^21.6.3",
|
|
91
91
|
"typescript": "^6.0.3",
|
|
92
92
|
"vite": "^8.2.1",
|
|
93
|
-
"@feasibleone/blong-dev": "1.
|
|
93
|
+
"@feasibleone/blong-dev": "1.4.0"
|
|
94
94
|
},
|
|
95
95
|
"scripts": {
|
|
96
96
|
"browser-check": "node scripts/browser-compat-check.mjs",
|
package/src/Watch.ts
CHANGED
|
@@ -465,6 +465,10 @@ export default class Watch extends Internal implements IWatch {
|
|
|
465
465
|
this.#config.enabled ? filename + '?' + Date.now() : filename
|
|
466
466
|
)
|
|
467
467
|
).default;
|
|
468
|
+
// A module without a default export is a plain module shared by
|
|
469
|
+
// the group's handlers (e.g. `account.ts`, `accessModel.ts`), not
|
|
470
|
+
// a handler — the loader ignores it.
|
|
471
|
+
if (!item) return (api: T) => api;
|
|
468
472
|
const expectedName = this.#platform
|
|
469
473
|
.basename(filename, this.#platform.extname(filename))
|
|
470
474
|
.match(prefixRE)?.[1];
|
|
@@ -78,6 +78,82 @@ export interface IEdgeBinding {
|
|
|
78
78
|
reverse?: boolean;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Resource-backed display-name override.
|
|
83
|
+
*
|
|
84
|
+
* By default the display name of a resource-backed row is the virtual
|
|
85
|
+
* `${object}Name` field. A table whose real display column is named differently
|
|
86
|
+
* (e.g. `party_organization.legalName`) declares it here: the `core_resource` row
|
|
87
|
+
* is then named from that column — and renamed when the column changes — and no
|
|
88
|
+
* virtual `${object}Name` field is synthesised (the real column *is* the label).
|
|
89
|
+
*/
|
|
90
|
+
export interface IResourceTableSpec {
|
|
91
|
+
/** Real column of the entity table that supplies `core_resource.resourceName`. */
|
|
92
|
+
nameColumn?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Record-level (ACL) guard for a table.
|
|
97
|
+
*
|
|
98
|
+
* The ACL lives in `access_acl` rows — `(principalId, actionId, targetId,
|
|
99
|
+
* targetKind, effect)` — where `principalId` is a user, role, unit or capability,
|
|
100
|
+
* `targetId` is the guarded record (or a scope node) and `effect` is `allow` /
|
|
101
|
+
* `deny` (deny always wins).
|
|
102
|
+
*/
|
|
103
|
+
export interface IAclTableSpec {
|
|
104
|
+
/**
|
|
105
|
+
* - `none` (default) — the entity is not record-guarded; nothing changes.
|
|
106
|
+
* - `scoped` — a grant may target a scope; it covers the records linked to
|
|
107
|
+
* that scope through one of the declared `scopes` predicates.
|
|
108
|
+
* - `explicit` — only per-record grants count (`scopes` is not consulted).
|
|
109
|
+
*/
|
|
110
|
+
mode?: 'none' | 'scoped' | 'explicit';
|
|
111
|
+
/**
|
|
112
|
+
* Predicates linking a record of this table to the scope(s) it belongs to
|
|
113
|
+
* (e.g. `belongsTo`, `isPartOf`). Consulted in `scoped` mode.
|
|
114
|
+
*/
|
|
115
|
+
scopes?: string[];
|
|
116
|
+
/**
|
|
117
|
+
* The record is *its own* scope — for a table whose scopes point at it
|
|
118
|
+
* rather than the other way round (`party.organization`, which its units
|
|
119
|
+
* belong to). Its scope set is the record itself plus its parent scopes,
|
|
120
|
+
* and `scopes` is ignored. There is no unscoped fallback: a record is only
|
|
121
|
+
* reachable through a grant that covers it or one of its parents.
|
|
122
|
+
*/
|
|
123
|
+
selfScope?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Where the scope of a NEW record comes from. `add` has no record key yet,
|
|
126
|
+
* so the check runs against the scope named in the payload (`column`, e.g.
|
|
127
|
+
* `unitId`) and/or carried by a declared scope edge (`predicate`, e.g.
|
|
128
|
+
* `belongsTo`).
|
|
129
|
+
*/
|
|
130
|
+
addScope?: {
|
|
131
|
+
/** Payload column holding the scope's resource id. */
|
|
132
|
+
column?: string;
|
|
133
|
+
/** Scope predicate whose payload array holds the scope ids. */
|
|
134
|
+
predicate?: string;
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Adapter-level ACL rules shared by every guarded table (see `ISchemaTable.acl`).
|
|
140
|
+
* The defaults match `realm/blong-access` and its `access_pathRefresh()` procedure.
|
|
141
|
+
*/
|
|
142
|
+
export interface IAclAdapterConfig {
|
|
143
|
+
/** The ACL table (default `access_acl`). */
|
|
144
|
+
table?: string;
|
|
145
|
+
/** The action entity table whose PK is the action resource (default `access_action`). */
|
|
146
|
+
actionTable?: string;
|
|
147
|
+
/** `core.path` pathType whose destinations are the caller's roles (default `access.effectiveRole`). */
|
|
148
|
+
rolePathType?: string;
|
|
149
|
+
/** `core.path` pathType whose destinations are the caller's unit scopes (default `access.effectiveScope`). */
|
|
150
|
+
scopePathType?: string;
|
|
151
|
+
/** `core_triple` predicate linking a principal to a unit (default `belongsTo`). */
|
|
152
|
+
unitPredicate?: string;
|
|
153
|
+
/** `core_triple` predicate linking a principal to a scope directly (default `hasScope`). */
|
|
154
|
+
scopePredicate?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
81
157
|
export interface ISchemaTable {
|
|
82
158
|
/**
|
|
83
159
|
* The TypeBox `TObject` for the table. Optional when the definition is
|
|
@@ -95,8 +171,17 @@ export interface ISchemaTable {
|
|
|
95
171
|
* - `find`/`get` join `resourceName` as `${object}Name`;
|
|
96
172
|
* - `edit` renames `core_resource.resourceName` from `${object}Name`;
|
|
97
173
|
* - `remove` deletes the `core_resource` row (and the declared `edges`).
|
|
174
|
+
*
|
|
175
|
+
* Pass `{nameColumn}` when the table's real display column is not
|
|
176
|
+
* `${object}Name` (see `IResourceTableSpec`).
|
|
177
|
+
*/
|
|
178
|
+
resource?: boolean | IResourceTableSpec;
|
|
179
|
+
/**
|
|
180
|
+
* Record-level (ACL) guard: which predicates are narrowed to the records the
|
|
181
|
+
* caller may act on, and where a new record's scope comes from. Defaults to
|
|
182
|
+
* `mode: 'none'` (unguarded) when omitted.
|
|
98
183
|
*/
|
|
99
|
-
|
|
184
|
+
acl?: IAclTableSpec;
|
|
100
185
|
/**
|
|
101
186
|
* Declarative graph-edge master-detail bindings (`core_triple` edges such
|
|
102
187
|
* as `hasRole` / `hasCapability`). `get` attaches the edge rows as a
|
|
@@ -224,6 +309,12 @@ export interface IConfig {
|
|
|
224
309
|
*/
|
|
225
310
|
procedurePaths?: string[];
|
|
226
311
|
accessPathRefresh?: boolean; // When `true`, calls `access_pathRefresh()` after schema sync/seed (default: `false`).
|
|
312
|
+
/**
|
|
313
|
+
* Record-level (ACL) rules shared by every table declaring
|
|
314
|
+
* `ISchemaTable.acl` — the ACL table name and the graph predicates/path
|
|
315
|
+
* types used to resolve the caller's principals and a record's scopes.
|
|
316
|
+
*/
|
|
317
|
+
acl?: IAclAdapterConfig;
|
|
227
318
|
};
|
|
228
319
|
/**
|
|
229
320
|
* When `true`, mocks some handlers
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import {crockfordDecode} from '@feasibleone/blong-lib';
|
|
2
|
+
import type {IObjectSchema, Knex} from '@feasibleone/blong/types';
|
|
3
|
+
import {methodParts} from '../../lib.ts';
|
|
4
|
+
import type {IAclAdapterConfig} from '../schema/knex/types.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Record-level (ACL) SQL helpers shared by the knex adapter's generic CRUD and
|
|
8
|
+
* by realm handlers (through the `acl` library function in `blong-access`).
|
|
9
|
+
*
|
|
10
|
+
* The ACL itself is a single table (`access_acl` unless configured otherwise):
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* access_acl(aclId, principalId, actionId, targetId, targetKind, effect, isActive)
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* - `principalId` — a user, role, unit or capability resource id;
|
|
17
|
+
* - `actionId` — the `access_action` resource of the guarded method;
|
|
18
|
+
* - `targetId` — the guarded record, or a **scope** node (`targetKind`);
|
|
19
|
+
* - `effect` — `allow` / `deny`, where **deny always wins**.
|
|
20
|
+
*
|
|
21
|
+
* Two things grant access, and both narrow RBAC (a method the caller's action
|
|
22
|
+
* list does not contain is refused by the gateway before this ever runs):
|
|
23
|
+
*
|
|
24
|
+
* - **implicit** — a `<principal> --hasScope--> <scope>` graph edge: the
|
|
25
|
+
* organizational/hierarchical grant, covering every action the principal
|
|
26
|
+
* holds. The caller inherits the edges of its roles and units.
|
|
27
|
+
* - **explicit** — an `access_acl` row targeting a scope or a single record,
|
|
28
|
+
* optionally turning an implicit allow off with `effect: 'deny'`.
|
|
29
|
+
*
|
|
30
|
+
* The record side is derived from the graph too: a record belongs to a scope
|
|
31
|
+
* through one of the table's declared `scopes` predicates (`belongsTo`,
|
|
32
|
+
* `isPartOf`, …), and a scope may itself sit under a parent scope — a grant on a
|
|
33
|
+
* parent covers its descendants.
|
|
34
|
+
*
|
|
35
|
+
* All checks are plain SQL predicates evaluated by MySQL (no materialization of
|
|
36
|
+
* the effective ACL), so they can be used both for a single-record check and as
|
|
37
|
+
* a `WHERE` clause that filters a whole result set before paging.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Resolved ACL rules (defaults match `realm/blong-access`). */
|
|
41
|
+
export interface IResolvedAclConfig {
|
|
42
|
+
/** The ACL table. */
|
|
43
|
+
table: string;
|
|
44
|
+
/** The action entity table whose PK is the action resource. */
|
|
45
|
+
actionTable: string;
|
|
46
|
+
/** `core_path` pathType whose destinations are the caller's roles. */
|
|
47
|
+
rolePathType: string;
|
|
48
|
+
/** `core_path` pathType whose destinations are a node's scope ancestors (including itself). */
|
|
49
|
+
scopePathType: string;
|
|
50
|
+
/** `core_triple` predicate linking a principal to a unit. */
|
|
51
|
+
unitPredicate: string;
|
|
52
|
+
/** `core_triple` predicate linking a principal to a scope. */
|
|
53
|
+
scopePredicate: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ACL_DEFAULTS: IResolvedAclConfig = {
|
|
57
|
+
table: 'access_acl',
|
|
58
|
+
actionTable: 'access_action',
|
|
59
|
+
rolePathType: 'access.effectiveRole',
|
|
60
|
+
scopePathType: 'access.effectiveScope',
|
|
61
|
+
unitPredicate: 'belongsTo',
|
|
62
|
+
scopePredicate: 'hasScope',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Merge the adapter's `schema.acl` block over the defaults, ignoring `undefined`
|
|
67
|
+
* values so a partially-filled config cannot blank a default.
|
|
68
|
+
*/
|
|
69
|
+
export function aclConfig(config?: IAclAdapterConfig): IResolvedAclConfig {
|
|
70
|
+
const defined = Object.fromEntries(
|
|
71
|
+
Object.entries(config ?? {}).filter(([, value]) => value !== undefined && value !== null),
|
|
72
|
+
) as Partial<IResolvedAclConfig>;
|
|
73
|
+
return {...ACL_DEFAULTS, ...defined};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Binary resource id of the JWT `sub` claim.
|
|
78
|
+
*
|
|
79
|
+
* `sub` is crockford-encoded and `crockfordEncode` **reverses** the bytes, so
|
|
80
|
+
* decoding round-trips to the DB byte order used by `core_path.originId` and
|
|
81
|
+
* `core_triple.subjectId`. Dashed UUIDs, hex and base64 strings are accepted
|
|
82
|
+
* too (tests and internal callers).
|
|
83
|
+
*/
|
|
84
|
+
export function aclActorId(actorId: string): Buffer {
|
|
85
|
+
const dashed = actorId.replaceAll('-', '');
|
|
86
|
+
if (/^[0-9a-f]{32}$/i.test(dashed)) return Buffer.from(dashed, 'hex');
|
|
87
|
+
try {
|
|
88
|
+
const raw = crockfordDecode(actorId);
|
|
89
|
+
if (raw.length === 16) return Buffer.from(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
// Not crockford — fall through to the base64 attempt.
|
|
92
|
+
}
|
|
93
|
+
const base64 = Buffer.from(actorId, 'base64');
|
|
94
|
+
if (base64.length === 16) return base64;
|
|
95
|
+
throw new Error(`Cannot resolve a resource id from the actor id "${actorId}"`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The actor id of the caller (`$meta.auth.actorId`) — undefined when the call is
|
|
100
|
+
* unauthenticated (e.g. a seed merge or an internal dispatch).
|
|
101
|
+
*/
|
|
102
|
+
export function aclActorOf($meta?: unknown): string | undefined {
|
|
103
|
+
return ($meta as {auth?: {actorId?: string}} | undefined)?.auth?.actorId;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The guarded entity (`subject.object`) a method belongs to — `accessUserEdit`
|
|
108
|
+
* and `access.user.edit` both resolve to `access.user`.
|
|
109
|
+
*/
|
|
110
|
+
export function aclEntityOf(method: string): string | undefined {
|
|
111
|
+
const bare = method.split('/').pop() ?? method;
|
|
112
|
+
const parts = methodParts(bare).split('.');
|
|
113
|
+
return parts.length >= 2 ? `${parts[0]}.${parts[1]}` : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The record key column of a table — its PK, defaulting to `${object}Id`. */
|
|
117
|
+
export function aclKeyColumn(objectSchema: IObjectSchema, subject: string, object: string): string {
|
|
118
|
+
return (
|
|
119
|
+
(objectSchema[subject]?.[object] as {constraints?: {primaryKey?: string}} | undefined)
|
|
120
|
+
?.constraints?.primaryKey ?? `${object}Id`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Guard against a predicate/pathType config value breaking out of its SQL literal. */
|
|
125
|
+
function sqlLiteral(value: string, what: string): string {
|
|
126
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(value)) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Invalid ACL ${what} "${value}" — only word characters, dot and dash are allowed`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return `'${value}'`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sqlList(values: string[], what: string): string {
|
|
135
|
+
if (!values.length) throw new Error(`The ACL ${what} list is empty`);
|
|
136
|
+
return values.map(value => sqlLiteral(value, what)).join(', ');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Action resource id of a method. The action name is matched the same way the
|
|
141
|
+
* gateway matches method ids (dots stripped, lowercased) so `access.user.edit`
|
|
142
|
+
* and `accessUserEdit` resolve to the same action.
|
|
143
|
+
*
|
|
144
|
+
* An `undefined` result means the method has **no `access_action` row at all** —
|
|
145
|
+
* the realm does not manage it as an RBAC action (e.g. a plain table method), so
|
|
146
|
+
* there is nothing for the ACL to match and the caller is allowed. An action
|
|
147
|
+
* that *does* exist but has no ACL row for the caller is a **denial**: opting a
|
|
148
|
+
* table into the ACL (`mode: 'scoped' | 'explicit'`) is deny-by-default, with
|
|
149
|
+
* the implicit `hasScope` grants and the explicit `allow` rows opening it up.
|
|
150
|
+
*/
|
|
151
|
+
export async function aclActionId(
|
|
152
|
+
qb: Knex,
|
|
153
|
+
cfg: IResolvedAclConfig,
|
|
154
|
+
method: string,
|
|
155
|
+
): Promise<Buffer | undefined> {
|
|
156
|
+
const row = (await qb
|
|
157
|
+
.select('act.actionId')
|
|
158
|
+
.from(`${cfg.actionTable} as act`)
|
|
159
|
+
.join('core_resource as r', 'r.resourceId', 'act.actionId')
|
|
160
|
+
.whereRaw("LOWER(REPLACE(r.resourceName, '.', '')) = ?", [
|
|
161
|
+
method.replaceAll('.', '').toLowerCase(),
|
|
162
|
+
])
|
|
163
|
+
.first()) as {actionId: Buffer} | undefined;
|
|
164
|
+
return row?.actionId;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface IAclCheck {
|
|
168
|
+
/** Raw SQL boolean expression — usable as a `SELECT` item or a `WHERE` clause. */
|
|
169
|
+
sql: string;
|
|
170
|
+
/** Positional bindings, in `?` order. */
|
|
171
|
+
bindings: unknown[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The action resource name of a CRUD method following the repo-wide convention
|
|
176
|
+
* (`subject` + `Object` + `Predicate`, e.g. `access` + `user` + `find` →
|
|
177
|
+
* `accessUserFind`) — the same shape the gateway's `methodId` normalisation
|
|
178
|
+
* makes equivalent to `access.user.find`.
|
|
179
|
+
*/
|
|
180
|
+
export function aclActionName(subject: string, object: string, predicate: string): string {
|
|
181
|
+
const capitalise = (value: string): string => value[0].toUpperCase() + value.slice(1);
|
|
182
|
+
return `${subject}${capitalise(object)}${capitalise(predicate)}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface IAclCheckOptions {
|
|
186
|
+
cfg: IResolvedAclConfig;
|
|
187
|
+
/** Binary resource id of the caller. */
|
|
188
|
+
actorId: Buffer;
|
|
189
|
+
/**
|
|
190
|
+
* Binary resource id of the guarded action. When `undefined` the action has
|
|
191
|
+
* no ACL rows and the caller is allowed (RBAC already decided the verb).
|
|
192
|
+
*/
|
|
193
|
+
actionId: Buffer | undefined;
|
|
194
|
+
/**
|
|
195
|
+
* How the guarded record is referenced: `'?'` (bind its binary id through
|
|
196
|
+
* `recordId`) for a single-record check, or a column reference such as
|
|
197
|
+
* `party_organization.organizationId` for a correlated list filter. Omit it
|
|
198
|
+
* for `add` — the record does not exist yet and only scopes can be targeted.
|
|
199
|
+
*/
|
|
200
|
+
recordRef?: string;
|
|
201
|
+
/** Binary record id — required when `recordRef` is `'?'`. */
|
|
202
|
+
recordId?: Buffer;
|
|
203
|
+
/** The guarded table's declared scope predicates (`belongsTo`, `isPartOf`, …). */
|
|
204
|
+
scopes?: string[];
|
|
205
|
+
/**
|
|
206
|
+
* The record is its own scope (see `IAclTableSpec.selfScope`) — its scope set
|
|
207
|
+
* is the record plus the parent scopes reachable from it, and any declared
|
|
208
|
+
* `scopes` predicates are ignored.
|
|
209
|
+
*/
|
|
210
|
+
selfScope?: boolean;
|
|
211
|
+
/**
|
|
212
|
+
* Explicit scope ids, replacing the record-derived scope set. Used by `add`,
|
|
213
|
+
* where the scope comes from the payload (`addScope`).
|
|
214
|
+
*/
|
|
215
|
+
scopeIds?: Buffer[];
|
|
216
|
+
/** `explicit` drops the implicit `<principal> --hasScope--> <scope>` grant. */
|
|
217
|
+
mode: 'scoped' | 'explicit';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The caller's principals: itself, its roles (from `core_path`), the units it
|
|
222
|
+
* belongs to and those units' ancestors.
|
|
223
|
+
*
|
|
224
|
+
* `subjectColumn` names the principal column of the surrounding table — it is
|
|
225
|
+
* `principalId` in the ACL table and `subjectId` in `core_triple`.
|
|
226
|
+
*/
|
|
227
|
+
function principalFilter(
|
|
228
|
+
cfg: IResolvedAclConfig,
|
|
229
|
+
actorId: Buffer,
|
|
230
|
+
alias: string,
|
|
231
|
+
subjectColumn: string,
|
|
232
|
+
): {sql: string; bindings: unknown[]} {
|
|
233
|
+
const unitPredicate = sqlLiteral(cfg.unitPredicate, 'unit predicate');
|
|
234
|
+
const units = `SELECT tu.objectId FROM core_triple tu WHERE tu.subjectId = ? AND tu.predicateName = ${unitPredicate}`;
|
|
235
|
+
return {
|
|
236
|
+
sql: `${alias}.${subjectColumn} = ? OR ${alias}.${subjectColumn} IN (
|
|
237
|
+
SELECT pr.destinationId FROM core_path pr WHERE pr.originId = ? AND pr.pathType = ?
|
|
238
|
+
UNION SELECT t.objectId FROM core_triple t WHERE t.subjectId = ? AND t.predicateName = ${unitPredicate}
|
|
239
|
+
UNION SELECT pa.destinationId FROM core_path pa
|
|
240
|
+
WHERE pa.pathType = ? AND pa.originId IN (${units})
|
|
241
|
+
)`,
|
|
242
|
+
bindings: [actorId, actorId, cfg.rolePathType, actorId, cfg.scopePathType, actorId],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The scopes a record participates in: its own scope edges (`belongsTo`,
|
|
248
|
+
* `isPartOf`, …) plus every ancestor of those scopes — the materialized
|
|
249
|
+
* `<scope> --isPartOf--> <parent>` chain, which includes each scope itself.
|
|
250
|
+
*
|
|
251
|
+
* `recordRef` is `'?'` (then the record id is bound twice) or a column reference
|
|
252
|
+
* for a correlated list filter (then nothing is bound).
|
|
253
|
+
*/
|
|
254
|
+
function recordScopesSql(
|
|
255
|
+
cfg: IResolvedAclConfig,
|
|
256
|
+
scopes: string[],
|
|
257
|
+
recordRef: string,
|
|
258
|
+
recordId: Buffer | undefined,
|
|
259
|
+
): {sql: string; bindings: unknown[]} {
|
|
260
|
+
const predicateList = sqlList(scopes, 'scope predicate');
|
|
261
|
+
const refs = recordRef === '?' ? [recordId, recordId] : [];
|
|
262
|
+
return {
|
|
263
|
+
sql: `SELECT ts.objectId FROM core_triple ts
|
|
264
|
+
WHERE ts.subjectId = ${recordRef} AND ts.predicateName IN (${predicateList})
|
|
265
|
+
UNION SELECT pa.destinationId FROM core_path pa
|
|
266
|
+
WHERE pa.pathType = ? AND pa.originId IN (
|
|
267
|
+
SELECT ps.objectId FROM core_triple ps
|
|
268
|
+
WHERE ps.subjectId = ${recordRef} AND ps.predicateName IN (${predicateList})
|
|
269
|
+
)`,
|
|
270
|
+
bindings: [...refs.slice(0, 1), cfg.scopePathType, ...refs.slice(1)],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The scope set of a record that *is* its own scope: the record plus its parent
|
|
276
|
+
* scopes (the materialized scope path). Used by tables whose scopes point at
|
|
277
|
+
* them — `party.organization`, whose units carry `belongsTo` towards it — where
|
|
278
|
+
* the record has no scope edge of its own to follow.
|
|
279
|
+
*/
|
|
280
|
+
function ownScopeSql(
|
|
281
|
+
cfg: IResolvedAclConfig,
|
|
282
|
+
recordRef: string,
|
|
283
|
+
recordId: Buffer | undefined,
|
|
284
|
+
): {sql: string; bindings: unknown[]} {
|
|
285
|
+
const refs = recordRef === '?' ? [recordId, recordId] : [];
|
|
286
|
+
return {
|
|
287
|
+
sql: `SELECT ${recordRef} AS objectId
|
|
288
|
+
UNION SELECT pa.destinationId FROM core_path pa
|
|
289
|
+
WHERE pa.pathType = ? AND pa.originId = ${recordRef}`,
|
|
290
|
+
bindings: [...refs.slice(0, 1), cfg.scopePathType, ...refs.slice(1)],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The scope set of an explicit list of scope ids: the ids themselves plus their ancestors. */
|
|
295
|
+
function explicitScopesSql(
|
|
296
|
+
cfg: IResolvedAclConfig,
|
|
297
|
+
scopeIds: Buffer[],
|
|
298
|
+
): {sql: string; bindings: unknown[]} {
|
|
299
|
+
const ids = scopeIds.map(() => 'SELECT ? AS id').join(' UNION ');
|
|
300
|
+
const placeholders = scopeIds.map(() => '?').join(', ');
|
|
301
|
+
return {
|
|
302
|
+
sql: `${ids}
|
|
303
|
+
UNION SELECT pa.destinationId FROM core_path pa
|
|
304
|
+
WHERE pa.pathType = ? AND pa.originId IN (${placeholders})`,
|
|
305
|
+
bindings: [...scopeIds, cfg.scopePathType, ...scopeIds],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Build the boolean expression that decides whether the caller may act on the
|
|
311
|
+
* record with the given action:
|
|
312
|
+
*
|
|
313
|
+
* ```sql
|
|
314
|
+
* (implicit OR explicit-allow) AND NOT explicit-deny
|
|
315
|
+
* ```
|
|
316
|
+
*
|
|
317
|
+
* `implicit` is a `hasScope` edge from one of the caller's principals to a scope
|
|
318
|
+
* the record participates in; the explicit halves are `access_acl` rows
|
|
319
|
+
* targeting the record itself or one of those scopes. An unknown action (no
|
|
320
|
+
* `actionId`) short-circuits to `TRUE` — RBAC already decided the verb.
|
|
321
|
+
*
|
|
322
|
+
* The SQL and its bindings are assembled strictly in placeholder order; every
|
|
323
|
+
* fragment's bindings are spliced in exactly where its SQL text is spliced.
|
|
324
|
+
*/
|
|
325
|
+
export function aclCheckSql(options: IAclCheckOptions): IAclCheck {
|
|
326
|
+
const {cfg, actorId, actionId, recordRef, mode} = options;
|
|
327
|
+
if (!actionId) return {sql: 'TRUE', bindings: []};
|
|
328
|
+
|
|
329
|
+
const scopeIds = options.scopeIds?.length ? options.scopeIds : undefined;
|
|
330
|
+
const scopes = options.scopes ?? [];
|
|
331
|
+
// Nothing to evaluate against: no record key and no scope. Nothing can
|
|
332
|
+
// match, so the answer is "denied" — a caller that forgot the target fails
|
|
333
|
+
// here rather than being let through (see `access.session.verify`, which
|
|
334
|
+
// turns this into a `recordRequired` refusal).
|
|
335
|
+
if (!recordRef && !scopeIds) return {sql: 'FALSE', bindings: []};
|
|
336
|
+
const scopeSet = scopeIds
|
|
337
|
+
? explicitScopesSql(cfg, scopeIds)
|
|
338
|
+
: options.selfScope && recordRef
|
|
339
|
+
? ownScopeSql(cfg, recordRef, options.recordId)
|
|
340
|
+
: mode === 'scoped' && recordRef && scopes.length
|
|
341
|
+
? recordScopesSql(cfg, scopes, recordRef, options.recordId)
|
|
342
|
+
: undefined;
|
|
343
|
+
|
|
344
|
+
// The target test: the record itself (when there is one) and/or the scopes
|
|
345
|
+
// it participates in. `add` has no record key yet, so only scopes. A
|
|
346
|
+
// wildcard rule (`targetKind: 'all'`, anchored on a sentinel node) matches
|
|
347
|
+
// every record in every scope shape. Built per alias so the allow (`a`) and
|
|
348
|
+
// deny (`d`) sub-queries each refer to their own row.
|
|
349
|
+
const targetOf = (alias: string): {sql: string; bindings: unknown[]} =>
|
|
350
|
+
scopeSet
|
|
351
|
+
? {
|
|
352
|
+
sql: recordRef
|
|
353
|
+
? `(${alias}.targetKind = 'all'
|
|
354
|
+
OR (${alias}.targetKind = 'record' AND ${alias}.targetId = ${recordRef})
|
|
355
|
+
OR (${alias}.targetKind = 'scope' AND ${alias}.targetId IN (${scopeSet.sql})))`
|
|
356
|
+
: `(${alias}.targetKind = 'all'
|
|
357
|
+
OR (${alias}.targetKind = 'scope' AND ${alias}.targetId IN (${scopeSet.sql})))`,
|
|
358
|
+
bindings: [
|
|
359
|
+
...(recordRef === '?' ? [options.recordId] : []),
|
|
360
|
+
...scopeSet.bindings,
|
|
361
|
+
],
|
|
362
|
+
}
|
|
363
|
+
: {
|
|
364
|
+
sql: `(${alias}.targetKind = 'all'
|
|
365
|
+
OR (${alias}.targetKind = 'record' AND ${alias}.targetId = ${recordRef ?? '?'}))`,
|
|
366
|
+
bindings: recordRef ? [options.recordId] : [],
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const explicit = (effect: 'allow' | 'deny', alias: string): IAclCheck => {
|
|
370
|
+
const principals = principalFilter(cfg, actorId, alias, 'principalId');
|
|
371
|
+
const target = targetOf(alias);
|
|
372
|
+
return {
|
|
373
|
+
sql: `EXISTS (SELECT 1 FROM ${cfg.table} ${alias}
|
|
374
|
+
WHERE ${alias}.isActive = 1 AND ${alias}.effect = ${sqlLiteral(effect, 'effect')}
|
|
375
|
+
AND ${alias}.actionId = ?
|
|
376
|
+
AND (${principals.sql})
|
|
377
|
+
AND ${target.sql})`,
|
|
378
|
+
bindings: [actionId, ...principals.bindings, ...target.bindings],
|
|
379
|
+
};
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const allow = explicit('allow', 'a');
|
|
383
|
+
const deny = explicit('deny', 'd');
|
|
384
|
+
|
|
385
|
+
// Implicit grant: <principal> --hasScope--> <scope of the record>.
|
|
386
|
+
let implicit: IAclCheck | undefined;
|
|
387
|
+
if (scopeSet && mode === 'scoped') {
|
|
388
|
+
const principals = principalFilter(cfg, actorId, 'hs', 'subjectId');
|
|
389
|
+
implicit = {
|
|
390
|
+
sql: `EXISTS (SELECT 1 FROM core_triple hs
|
|
391
|
+
WHERE hs.predicateName = ${sqlLiteral(cfg.scopePredicate, 'scope predicate')}
|
|
392
|
+
AND (${principals.sql})
|
|
393
|
+
AND hs.objectId IN (${scopeSet.sql}))`,
|
|
394
|
+
bindings: [...principals.bindings, ...scopeSet.bindings],
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// A record that participates in no scope has nothing to narrow it against,
|
|
399
|
+
// so RBAC alone decides it — the ACL restricts records *within* scopes.
|
|
400
|
+
// This is what makes opting a table in safe for existing, unsorted data.
|
|
401
|
+
const unscoped =
|
|
402
|
+
recordRef && mode === 'scoped' && scopes.length
|
|
403
|
+
? {
|
|
404
|
+
sql: `NOT EXISTS (SELECT 1 FROM core_triple us
|
|
405
|
+
WHERE us.subjectId = ${recordRef}
|
|
406
|
+
AND us.predicateName IN (${sqlList(scopes, 'scope predicate')}))`,
|
|
407
|
+
bindings: recordRef === '?' ? [options.recordId] : [],
|
|
408
|
+
}
|
|
409
|
+
: undefined;
|
|
410
|
+
|
|
411
|
+
const grant = implicit ? `(${implicit.sql}) OR (${allow.sql})` : `(${allow.sql})`;
|
|
412
|
+
const guarded = `((${grant}) AND NOT (${deny.sql}))`;
|
|
413
|
+
const sql = unscoped ? `((${unscoped.sql}) OR ${guarded})` : guarded;
|
|
414
|
+
const bindings = [
|
|
415
|
+
...(unscoped?.bindings ?? []),
|
|
416
|
+
...(implicit?.bindings ?? []),
|
|
417
|
+
...allow.bindings,
|
|
418
|
+
...deny.bindings,
|
|
419
|
+
];
|
|
420
|
+
const expected = sql.split('?').length - 1;
|
|
421
|
+
if (expected !== bindings.length) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`ACL SQL placeholder mismatch: ${expected} placeholders, ${bindings.length} bindings`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
return {sql, bindings};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Evaluate the ACL for a single record (or a single scope, for `add`) in one
|
|
431
|
+
* round trip. Returns `true` when the caller may proceed.
|
|
432
|
+
*/
|
|
433
|
+
export async function aclAllowed(qb: Knex, options: IAclCheckOptions): Promise<boolean> {
|
|
434
|
+
const {sql, bindings} = aclCheckSql(options);
|
|
435
|
+
if (sql === 'TRUE') return true;
|
|
436
|
+
const row = (await qb.select(qb.raw(`${sql} AS allowed`, bindings as never[])).first()) as
|
|
437
|
+
| {allowed?: number | boolean | null}
|
|
438
|
+
| undefined;
|
|
439
|
+
return Boolean(row?.allowed);
|
|
440
|
+
}
|
|
@@ -15,6 +15,17 @@ import {type TFunction, type TObject} from 'typebox';
|
|
|
15
15
|
import {v4} from 'uuid';
|
|
16
16
|
import yaml from 'yaml';
|
|
17
17
|
import {methodParts} from '../../lib.ts';
|
|
18
|
+
import {
|
|
19
|
+
aclActionId,
|
|
20
|
+
aclActionName,
|
|
21
|
+
aclActorId,
|
|
22
|
+
aclActorOf,
|
|
23
|
+
aclAllowed,
|
|
24
|
+
aclCheckSql,
|
|
25
|
+
aclConfig,
|
|
26
|
+
aclEntityOf,
|
|
27
|
+
aclKeyColumn,
|
|
28
|
+
} from './acl.ts';
|
|
18
29
|
import {
|
|
19
30
|
binaryToStr,
|
|
20
31
|
discoverBinaryColumns,
|
|
@@ -37,6 +48,7 @@ import {
|
|
|
37
48
|
schemaTableSyncImpl,
|
|
38
49
|
} from '../schema/knex/schemaTable.ts';
|
|
39
50
|
import {
|
|
51
|
+
type IAclTableSpec,
|
|
40
52
|
type IConfig,
|
|
41
53
|
type IEdgeBinding,
|
|
42
54
|
type IKnexConfig,
|
|
@@ -71,6 +83,16 @@ const errorMap: IErrorMap = {
|
|
|
71
83
|
'knex.exists': 'Knex Exists',
|
|
72
84
|
'knex.unique': 'Knex Unique',
|
|
73
85
|
'knex.missingKey': 'Missing key value for {key}',
|
|
86
|
+
// Record-level (ACL) refusals from the generic CRUD — the same `acl.*` family
|
|
87
|
+
// the access realm declares, so a caller sees one error name wherever the
|
|
88
|
+
// check runs (adapter or realm handler).
|
|
89
|
+
'acl.denied': {message: 'Not allowed to access this record', statusCode: 403},
|
|
90
|
+
'acl.scopeDenied': {
|
|
91
|
+
message: 'Not allowed to create a record in this scope',
|
|
92
|
+
statusCode: 403,
|
|
93
|
+
},
|
|
94
|
+
// A single-record read refuses with "not found" so it does not leak existence.
|
|
95
|
+
'acl.notFound': {message: 'Record not found', statusCode: 404},
|
|
74
96
|
};
|
|
75
97
|
|
|
76
98
|
let _errors: Errors<typeof errorMap>;
|
|
@@ -404,8 +426,10 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
404
426
|
// resource-backed table of the subject (see `_dropdownList`).
|
|
405
427
|
if (object === 'dropdown' && operation === 'list') {
|
|
406
428
|
return (
|
|
407
|
-
this as unknown as {
|
|
408
|
-
|
|
429
|
+
this as unknown as {
|
|
430
|
+
_dropdownList(s: string, meta?: IMeta): Promise<unknown>;
|
|
431
|
+
}
|
|
432
|
+
)._dropdownList(subject, $meta);
|
|
409
433
|
}
|
|
410
434
|
// Structure discovery for the commander explorer:
|
|
411
435
|
// `{ns}.schema.list` — databases/schemas on the server
|
|
@@ -453,6 +477,149 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
453
477
|
};
|
|
454
478
|
}
|
|
455
479
|
const table = `${subject}_${object}`;
|
|
480
|
+
// ── Record-level (ACL) gates ──────────────────────────────────────
|
|
481
|
+
// A table opts into record-level authorization with
|
|
482
|
+
// `acl: {mode, scopes, addScope}`; everything below is a no-op for a
|
|
483
|
+
// table that does not (`mode: 'none'`, the default) and for system
|
|
484
|
+
// calls, which carry no `$meta.auth` (seed merges, internal dispatches).
|
|
485
|
+
const aclCfg = aclConfig(this.config.schema?.acl);
|
|
486
|
+
const aclActor = ($meta?.auth as {actorId?: string} | undefined)?.actorId;
|
|
487
|
+
/** The guarded table's PK column (the record key). */
|
|
488
|
+
const aclKeyName = (): string => aclKeyColumn(objectSchema, subject, object);
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Gate a single-record operation (`get` / `edit` / `remove`).
|
|
492
|
+
* Writes are refused with 403; a read refuses with 404 so it does not
|
|
493
|
+
* leak the record's existence.
|
|
494
|
+
*/
|
|
495
|
+
const aclCheckRecord = async (
|
|
496
|
+
opts: IResolvedTableOptions,
|
|
497
|
+
operation: string,
|
|
498
|
+
recordId: Buffer | undefined,
|
|
499
|
+
): Promise<void> => {
|
|
500
|
+
const mode = opts.acl.mode ?? 'none';
|
|
501
|
+
if (mode === 'none' || !aclActor || !recordId) return;
|
|
502
|
+
const actionId = await aclActionId(this.config.context.queryBuilder!, aclCfg, method!);
|
|
503
|
+
if (!actionId) return; // the method is not an RBAC-managed action
|
|
504
|
+
const allowed = await aclAllowed(this.config.context.queryBuilder!, {
|
|
505
|
+
cfg: aclCfg,
|
|
506
|
+
actorId: aclActorId(aclActor),
|
|
507
|
+
actionId,
|
|
508
|
+
recordRef: '?',
|
|
509
|
+
recordId,
|
|
510
|
+
scopes: opts.acl.scopes,
|
|
511
|
+
selfScope: opts.acl.selfScope,
|
|
512
|
+
mode,
|
|
513
|
+
});
|
|
514
|
+
if (allowed) return;
|
|
515
|
+
throw this.error(
|
|
516
|
+
operation === 'get'
|
|
517
|
+
? _errors['acl.notFound']({params: {reason: 'notFound'}})
|
|
518
|
+
: _errors['acl.denied']({params: {reason: 'denied'}}),
|
|
519
|
+
$meta,
|
|
520
|
+
);
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Release the ACL rules that reference a guarded record about to be
|
|
525
|
+
* removed.
|
|
526
|
+
*
|
|
527
|
+
* `access_acl` keeps foreign keys to `core_resource` on
|
|
528
|
+
* `principalId`, `actionId` and `targetId`, so a rule left behind
|
|
529
|
+
* makes the resource delete fail *after* the entity row is already
|
|
530
|
+
* gone. What remains is a resource-only ghost, and because
|
|
531
|
+
* `core.resource.ensure` matches a record by name it then skips the
|
|
532
|
+
* entity insert — the entity can never be created again. A record
|
|
533
|
+
* that is being removed can no longer be a principal, a target or an
|
|
534
|
+
* action, so its rules go first.
|
|
535
|
+
*/
|
|
536
|
+
const aclReleaseResource = async (
|
|
537
|
+
opts: IResolvedTableOptions,
|
|
538
|
+
recordId: Buffer,
|
|
539
|
+
): Promise<void> => {
|
|
540
|
+
const mode = opts.acl.mode ?? 'none';
|
|
541
|
+
if (mode === 'none') return;
|
|
542
|
+
await this.config.context
|
|
543
|
+
.queryBuilder!(aclCfg.table)
|
|
544
|
+
.where('principalId', recordId)
|
|
545
|
+
.orWhere('targetId', recordId)
|
|
546
|
+
.orWhere('actionId', recordId)
|
|
547
|
+
.del();
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The ACL predicate for a multi-record read, correlated on the guarded
|
|
552
|
+
* table's key — applied to the query **before** paging so the result
|
|
553
|
+
* set (and its total) only contains records the caller may see.
|
|
554
|
+
*/
|
|
555
|
+
const aclReadFilter = async (
|
|
556
|
+
opts: IResolvedTableOptions,
|
|
557
|
+
): Promise<{sql: string; bindings: unknown[]} | undefined> => {
|
|
558
|
+
const mode = opts.acl.mode ?? 'none';
|
|
559
|
+
if (mode === 'none' || !aclActor) return undefined;
|
|
560
|
+
const actionId = await aclActionId(this.config.context.queryBuilder!, aclCfg, method!);
|
|
561
|
+
if (!actionId) return undefined;
|
|
562
|
+
return aclCheckSql({
|
|
563
|
+
cfg: aclCfg,
|
|
564
|
+
actorId: aclActorId(aclActor),
|
|
565
|
+
actionId,
|
|
566
|
+
recordRef: `${table}.${aclKeyName()}`,
|
|
567
|
+
scopes: opts.acl.scopes,
|
|
568
|
+
selfScope: opts.acl.selfScope,
|
|
569
|
+
mode,
|
|
570
|
+
});
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Gate `add`, which has no record key yet: the check runs against the
|
|
575
|
+
* scope named by the declaration (`addScope.column` on the payload, or
|
|
576
|
+
* `addScope.predicate` reading the ids of the submitted scope edges).
|
|
577
|
+
*/
|
|
578
|
+
const aclCheckAdd = async (
|
|
579
|
+
opts: IResolvedTableOptions,
|
|
580
|
+
cols: Record<string, unknown>,
|
|
581
|
+
rest: Record<string, unknown>,
|
|
582
|
+
): Promise<void> => {
|
|
583
|
+
const mode = opts.acl.mode ?? 'none';
|
|
584
|
+
const addScope = opts.acl.addScope;
|
|
585
|
+
if (mode === 'none' || !aclActor || !addScope) return;
|
|
586
|
+
const ids: string[] = [];
|
|
587
|
+
if (addScope.column && typeof cols[addScope.column] === 'string') {
|
|
588
|
+
ids.push(cols[addScope.column] as string);
|
|
589
|
+
}
|
|
590
|
+
if (addScope.predicate) {
|
|
591
|
+
// The payload array is keyed by the edge's object name
|
|
592
|
+
// (`unit`), which the declared binding owns.
|
|
593
|
+
const binding = opts.edges.find(e => e.predicate === addScope.predicate);
|
|
594
|
+
const detailObject =
|
|
595
|
+
binding?.object ?? addScope.predicate.replace(/^has/, '').toLowerCase();
|
|
596
|
+
for (const row of (rest[detailObject] as Array<Record<string, unknown>>) ?? []) {
|
|
597
|
+
// The scope id is the object's key in the edge row —
|
|
598
|
+
// `{unitId: '<base64>'}` for a `belongsTo` edge.
|
|
599
|
+
const found = Object.entries(row ?? {}).find(
|
|
600
|
+
([key, value]) => key.endsWith('Id') && typeof value === 'string',
|
|
601
|
+
);
|
|
602
|
+
if (found) ids.push(found[1] as string);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (!ids.length) return;
|
|
606
|
+
const actionId = await aclActionId(this.config.context.queryBuilder!, aclCfg, method!);
|
|
607
|
+
if (!actionId) return;
|
|
608
|
+
const allowed = await aclAllowed(this.config.context.queryBuilder!, {
|
|
609
|
+
cfg: aclCfg,
|
|
610
|
+
actorId: aclActorId(aclActor),
|
|
611
|
+
actionId,
|
|
612
|
+
scopeIds: ids.map(id => strToBinary(id)),
|
|
613
|
+
mode,
|
|
614
|
+
});
|
|
615
|
+
if (!allowed) {
|
|
616
|
+
throw this.error(
|
|
617
|
+
_errors['acl.scopeDenied']({params: {reason: 'scopeDenied'}}),
|
|
618
|
+
$meta,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
456
623
|
switch (operation) {
|
|
457
624
|
case 'get': {
|
|
458
625
|
const {select: _select, ...where} = params;
|
|
@@ -478,13 +645,25 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
478
645
|
// the row in place (Buffers → base64 strings). The edge
|
|
479
646
|
// attachment needs the binary master key.
|
|
480
647
|
const masterKey = row?.[keyName];
|
|
648
|
+
// Record-level ACL: checked after the fetch so a `where` that
|
|
649
|
+
// does not carry the key (e.g. by email address) is covered too.
|
|
650
|
+
await aclCheckRecord(
|
|
651
|
+
opts,
|
|
652
|
+
'get',
|
|
653
|
+
Buffer.isBuffer(masterKey) ? masterKey : undefined,
|
|
654
|
+
);
|
|
481
655
|
const result: Record<string, unknown> = {
|
|
482
656
|
[object]: prepareResultRow(row, binaryCols, table),
|
|
483
657
|
};
|
|
484
658
|
const masterRow = result[object] as Record<string, unknown> | undefined;
|
|
485
659
|
// Resource-backed: join the display name from
|
|
486
660
|
// `core_resource.resourceName` as `${object}Name`.
|
|
487
|
-
if (
|
|
661
|
+
if (
|
|
662
|
+
opts.resource &&
|
|
663
|
+
!opts.nameColumn &&
|
|
664
|
+
masterRow &&
|
|
665
|
+
typeof masterRow[keyName] === 'string'
|
|
666
|
+
) {
|
|
488
667
|
const [joined] = await joinResourceNames(
|
|
489
668
|
qb,
|
|
490
669
|
[masterRow],
|
|
@@ -560,13 +739,19 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
560
739
|
});
|
|
561
740
|
}
|
|
562
741
|
if (order) query = query.orderBy(order);
|
|
742
|
+
// Record-level ACL: the filter is applied before paging so both
|
|
743
|
+
// the page and its total only contain readable records.
|
|
744
|
+
const aclFilter = await aclReadFilter(opts);
|
|
745
|
+
if (aclFilter) query = query.whereRaw(aclFilter.sql, aclFilter.bindings as never[]);
|
|
563
746
|
if (limit) query = query.limit(limit);
|
|
564
747
|
if (offset) query = query.offset(offset);
|
|
565
748
|
const rows = (await query.select(select)) as Record<string, unknown>[];
|
|
566
749
|
const prepared = prepareResultRows(rows, binaryCols, table);
|
|
567
750
|
// Resource-backed: join the display name from
|
|
568
|
-
// `core_resource.resourceName` as `${object}Name`.
|
|
569
|
-
|
|
751
|
+
// `core_resource.resourceName` as `${object}Name`. A table
|
|
752
|
+
// declaring `resource: {nameColumn}` keeps its real display
|
|
753
|
+
// column — no virtual field is synthesised.
|
|
754
|
+
if (opts.resource && !opts.nameColumn) {
|
|
570
755
|
return joinResourceNames(qb, prepared, `${object}Id`, `${object}Name`);
|
|
571
756
|
}
|
|
572
757
|
return prepared;
|
|
@@ -601,13 +786,18 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
601
786
|
const binaryCols = getBinaryCols(this.config.context);
|
|
602
787
|
const opts = tableOptions(objectSchema, this.config, subject, object);
|
|
603
788
|
const cols = columns as Record<string, unknown>;
|
|
604
|
-
// Resource-backed:
|
|
605
|
-
//
|
|
606
|
-
//
|
|
789
|
+
// Resource-backed: the display name lives in
|
|
790
|
+
// `core_resource.resourceName`. By default it is the virtual
|
|
791
|
+
// `${object}Name` field — captured for the resource row and
|
|
792
|
+
// excluded from the table insert. A table declaring
|
|
793
|
+
// `resource: {nameColumn}` names the resource from that real
|
|
794
|
+
// column instead, which therefore stays in the insert.
|
|
607
795
|
const nameColValue = opts.resource
|
|
608
|
-
? (cols[`${object}Name`] as
|
|
796
|
+
? ((opts.nameColumn ? cols[opts.nameColumn] : cols[`${object}Name`]) as
|
|
797
|
+
| string
|
|
798
|
+
| undefined)
|
|
609
799
|
: undefined;
|
|
610
|
-
if (opts.resource) {
|
|
800
|
+
if (opts.resource && !opts.nameColumn) {
|
|
611
801
|
delete cols[`${object}Name`];
|
|
612
802
|
}
|
|
613
803
|
// Generate real PKs server-side for id columns that carry a
|
|
@@ -677,6 +867,9 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
677
867
|
await ensureResourceRow(generatedKey, keyName);
|
|
678
868
|
}
|
|
679
869
|
}
|
|
870
|
+
// Record-level ACL: `add` has no record key yet, so the scope
|
|
871
|
+
// declared by `addScope` decides whether it may be created.
|
|
872
|
+
await aclCheckAdd(opts, cols, rest);
|
|
680
873
|
// Convert any string values for binary columns to Buffer
|
|
681
874
|
const insertCols = prepareInputParams(cols, binaryCols, table);
|
|
682
875
|
const inserted = await qb(table).insert(insertCols);
|
|
@@ -756,7 +949,7 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
756
949
|
}
|
|
757
950
|
// Resource-backed: join the display name onto the master so
|
|
758
951
|
// the caller sees `${object}Name` in the created row.
|
|
759
|
-
if (opts.resource && Buffer.isBuffer(masterKey)) {
|
|
952
|
+
if (opts.resource && !opts.nameColumn && Buffer.isBuffer(masterKey)) {
|
|
760
953
|
const masterRow = result[object] as Record<string, unknown> | undefined;
|
|
761
954
|
if (masterRow && typeof masterRow[`${object}Id`] === 'string') {
|
|
762
955
|
const [joined] = await joinResourceNames(
|
|
@@ -779,15 +972,20 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
779
972
|
const {[keyName]: key, ...update} = cols as Record<string, unknown>;
|
|
780
973
|
const isBinaryKey =
|
|
781
974
|
isBinaryColumn(binaryCols, table, keyName) && typeof key === 'string';
|
|
782
|
-
// Resource-backed:
|
|
783
|
-
//
|
|
784
|
-
//
|
|
975
|
+
// Resource-backed: the display name lives in
|
|
976
|
+
// `core_resource.resourceName`, so a change to the display
|
|
977
|
+
// field renames that row. With `resource: {nameColumn}` the
|
|
978
|
+
// real column is updated as well (it stays in the update).
|
|
979
|
+
const nameField = opts.nameColumn ?? `${object}Name`;
|
|
785
980
|
const resourceName = opts.resource
|
|
786
|
-
? (update[
|
|
981
|
+
? (update[nameField] as string | undefined)
|
|
787
982
|
: undefined;
|
|
788
|
-
if (opts.resource) {
|
|
983
|
+
if (opts.resource && !opts.nameColumn) {
|
|
789
984
|
delete update[`${object}Name`];
|
|
790
985
|
}
|
|
986
|
+
// Record-level ACL: refuse an edit of a record the caller may
|
|
987
|
+
// not act on, before anything is written.
|
|
988
|
+
await aclCheckRecord(opts, 'edit', isBinaryKey ? strToBinary(key) : undefined);
|
|
791
989
|
// Convert any string values for binary columns to Buffer (the
|
|
792
990
|
// form round-trips them as base64 strings returned by `get`).
|
|
793
991
|
const preparedUpdate = prepareInputParams(update, binaryCols, table);
|
|
@@ -891,6 +1089,16 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
891
1089
|
throw this.error(_errors['knex.missingKey']({key: keyName}), $meta);
|
|
892
1090
|
}
|
|
893
1091
|
const qb = this.config.context.queryBuilder!;
|
|
1092
|
+
// Record-level ACL: refuse to delete a record the caller may
|
|
1093
|
+
// not act on before anything is touched.
|
|
1094
|
+
await aclCheckRecord(
|
|
1095
|
+
opts,
|
|
1096
|
+
'remove',
|
|
1097
|
+
Buffer.isBuffer(masterKey) ? masterKey : undefined,
|
|
1098
|
+
);
|
|
1099
|
+
// …then release the rules that reference the record, before
|
|
1100
|
+
// the entity row is deleted (see `aclReleaseResource`).
|
|
1101
|
+
if (Buffer.isBuffer(masterKey)) await aclReleaseResource(opts, masterKey);
|
|
894
1102
|
// Master-detail: delete each FK-constrained detail table's
|
|
895
1103
|
// rows for this master BEFORE deleting the master row, so a
|
|
896
1104
|
// non-cascading FK does not block the delete.
|
|
@@ -1020,6 +1228,129 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
1020
1228
|
}
|
|
1021
1229
|
throw this.error(_errors['knex.generic']({}), $meta);
|
|
1022
1230
|
},
|
|
1231
|
+
/**
|
|
1232
|
+
* `{subject}.dropdown.list` — produce `{value, label}` pairs for every
|
|
1233
|
+
* resource-backed table of the subject. A table is resource-backed when
|
|
1234
|
+
* its PK (or any column) is a FK to `core.resource.resourceId`.
|
|
1235
|
+
*
|
|
1236
|
+
* Entries are resolved directly from `core_resource` (joined with
|
|
1237
|
+
* `core_type` by `typeAlias = ${subject}.${object}`), so every realm gets
|
|
1238
|
+
* dropdowns for free, matching the `blong-mock` `{subject}.dropdown.list`
|
|
1239
|
+
* shape (`{value: base64, label: resourceName}`).
|
|
1240
|
+
*
|
|
1241
|
+
* Per-table overrides are declared via the `dropdown` option on the table
|
|
1242
|
+
* spec (see `ISchemaTable`): `typeAlias`, `joinTable`, `joinColumn`,
|
|
1243
|
+
* `labelColumn`.
|
|
1244
|
+
*/
|
|
1245
|
+
/**
|
|
1246
|
+
* Evaluate the record-level ACL for one record — or for one or more
|
|
1247
|
+
* scopes, which is what `add` needs (the record has no key yet).
|
|
1248
|
+
*
|
|
1249
|
+
* Realm handlers cannot import this module (the runtime is not a library
|
|
1250
|
+
* dependency of a realm), so they reach it through the port —
|
|
1251
|
+
* `await this.aclCheck({recordId}, $meta)` — while the generic CRUD calls
|
|
1252
|
+
* the same helper directly.
|
|
1253
|
+
*
|
|
1254
|
+
* `guarded: false` means the table does not opt into the ACL, or the
|
|
1255
|
+
* method is not an RBAC-managed action at all: the caller is allowed.
|
|
1256
|
+
* `actionId` (a raw Buffer — this call never crosses a realm boundary)
|
|
1257
|
+
* and `predicate` are returned even for an unguarded entity, so a caller
|
|
1258
|
+
* can reuse them for its own live-permission check.
|
|
1259
|
+
*/
|
|
1260
|
+
async aclCheck(
|
|
1261
|
+
params: {
|
|
1262
|
+
/** Guarded entity as `subject.object` — defaults to the entity of `method`. */
|
|
1263
|
+
entity?: string;
|
|
1264
|
+
/** The method whose action is checked — defaults to `$meta.method`. */
|
|
1265
|
+
method?: string;
|
|
1266
|
+
/** Binary record key as a base64/hex string. */
|
|
1267
|
+
recordId?: string;
|
|
1268
|
+
/** Scope ids (used by `add`) as base64/hex strings. */
|
|
1269
|
+
scopeIds?: string[];
|
|
1270
|
+
/** Actor id override — defaults to `$meta.auth.actorId`. */
|
|
1271
|
+
actorId?: string;
|
|
1272
|
+
},
|
|
1273
|
+
$meta?: IMeta,
|
|
1274
|
+
): Promise<{
|
|
1275
|
+
guarded: boolean;
|
|
1276
|
+
allowed: boolean;
|
|
1277
|
+
entity?: string;
|
|
1278
|
+
predicate?: string;
|
|
1279
|
+
actionId?: Buffer;
|
|
1280
|
+
}> {
|
|
1281
|
+
const qb = this.config?.context?.queryBuilder;
|
|
1282
|
+
const method = params.method ?? $meta?.method;
|
|
1283
|
+
const actor = params.actorId ?? aclActorOf($meta);
|
|
1284
|
+
if (!qb || !method) return {guarded: false, allowed: true};
|
|
1285
|
+
const entity = params.entity ?? aclEntityOf(method);
|
|
1286
|
+
const predicate = methodParts(method).split('.').pop();
|
|
1287
|
+
const [subject, object] = entity ? entity.split('.') : [undefined, undefined];
|
|
1288
|
+
const spec =
|
|
1289
|
+
subject && object
|
|
1290
|
+
? resolveTableSpec(
|
|
1291
|
+
objectSchema,
|
|
1292
|
+
this.config?.schema?.tables?.[`${subject}.${object}`],
|
|
1293
|
+
subject,
|
|
1294
|
+
object,
|
|
1295
|
+
).acl
|
|
1296
|
+
: undefined;
|
|
1297
|
+
const cfg = aclConfig(this.config?.schema?.acl);
|
|
1298
|
+
const actionId = await aclActionId(qb, cfg, method);
|
|
1299
|
+
const mode = spec?.mode ?? 'none';
|
|
1300
|
+
const verdict = {entity, predicate, actionId};
|
|
1301
|
+
if (!actionId || mode === 'none' || !actor) {
|
|
1302
|
+
return {guarded: false, allowed: true, ...verdict};
|
|
1303
|
+
}
|
|
1304
|
+
const allowed = await aclAllowed(qb, {
|
|
1305
|
+
cfg,
|
|
1306
|
+
actionId,
|
|
1307
|
+
actorId: aclActorId(actor),
|
|
1308
|
+
recordRef: params.recordId ? '?' : undefined,
|
|
1309
|
+
recordId: params.recordId ? strToBinary(params.recordId) : undefined,
|
|
1310
|
+
scopeIds: params.scopeIds?.map(id => strToBinary(id)),
|
|
1311
|
+
scopes: spec?.scopes,
|
|
1312
|
+
selfScope: spec?.selfScope,
|
|
1313
|
+
mode,
|
|
1314
|
+
});
|
|
1315
|
+
return {guarded: true, allowed, ...verdict};
|
|
1316
|
+
},
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* The ACL predicate that filters a whole result set, correlated on the
|
|
1320
|
+
* guarded table's key column — realm handlers apply it with
|
|
1321
|
+
* `query.whereRaw(sql, bindings)` before paging. Returns `undefined`
|
|
1322
|
+
* when the entity is not guarded.
|
|
1323
|
+
*/
|
|
1324
|
+
async aclFilter(
|
|
1325
|
+
params: {entity: string; method?: string; actorId?: string},
|
|
1326
|
+
$meta?: IMeta,
|
|
1327
|
+
): Promise<{sql: string; bindings: unknown[]} | undefined> {
|
|
1328
|
+
const method = params.method ?? $meta?.method;
|
|
1329
|
+
const [subject, object] = params.entity.split('.');
|
|
1330
|
+
const actor = params.actorId ?? aclActorOf($meta);
|
|
1331
|
+
const qb = this.config?.context?.queryBuilder;
|
|
1332
|
+
const spec = resolveTableSpec(
|
|
1333
|
+
objectSchema,
|
|
1334
|
+
this.config?.schema?.tables?.[params.entity],
|
|
1335
|
+
subject,
|
|
1336
|
+
object,
|
|
1337
|
+
).acl;
|
|
1338
|
+
const mode = spec?.mode ?? 'none';
|
|
1339
|
+
if (mode === 'none' || !actor || !method || !qb) return undefined;
|
|
1340
|
+
const cfg = aclConfig(this.config?.schema?.acl);
|
|
1341
|
+
const actionId = await aclActionId(qb, cfg, method);
|
|
1342
|
+
if (!actionId) return undefined;
|
|
1343
|
+
return aclCheckSql({
|
|
1344
|
+
cfg,
|
|
1345
|
+
actionId,
|
|
1346
|
+
actorId: aclActorId(actor),
|
|
1347
|
+
recordRef: `${subject}_${object}.${aclKeyColumn(objectSchema, subject, object)}`,
|
|
1348
|
+
scopes: spec?.scopes,
|
|
1349
|
+
selfScope: spec?.selfScope,
|
|
1350
|
+
mode,
|
|
1351
|
+
});
|
|
1352
|
+
},
|
|
1353
|
+
|
|
1023
1354
|
/**
|
|
1024
1355
|
* `{subject}.dropdown.list` — produce `{value, label}` pairs for every
|
|
1025
1356
|
* resource-backed table of the subject. A table is resource-backed when
|
|
@@ -1037,6 +1368,7 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
1037
1368
|
async _dropdownList(
|
|
1038
1369
|
this: Adapter<IConfig>,
|
|
1039
1370
|
subject: string,
|
|
1371
|
+
$meta?: IMeta,
|
|
1040
1372
|
): Promise<Record<string, Array<{value: string; label: string}>>> {
|
|
1041
1373
|
const qb = this.config.context?.queryBuilder;
|
|
1042
1374
|
if (!qb) return {};
|
|
@@ -1078,6 +1410,31 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
|
|
|
1078
1410
|
'r.resourceId',
|
|
1079
1411
|
);
|
|
1080
1412
|
}
|
|
1413
|
+
// Record-level ACL: a dropdown is a read path, so a guarded entity
|
|
1414
|
+
// must not offer records the caller may not read.
|
|
1415
|
+
const aclSpec = resolveTableSpec(objectSchema, tableConfig, s, object).acl;
|
|
1416
|
+
const aclMode = aclSpec?.mode ?? 'none';
|
|
1417
|
+
const dropdownActor = ($meta?.auth as {actorId?: string} | undefined)?.actorId;
|
|
1418
|
+
if (aclMode !== 'none' && dropdownActor) {
|
|
1419
|
+
const dropdownAcl = aclConfig(this.config.schema?.acl);
|
|
1420
|
+
const actionId = await aclActionId(
|
|
1421
|
+
qb,
|
|
1422
|
+
dropdownAcl,
|
|
1423
|
+
aclActionName(s, object, 'find'),
|
|
1424
|
+
);
|
|
1425
|
+
if (actionId) {
|
|
1426
|
+
const filter = aclCheckSql({
|
|
1427
|
+
cfg: dropdownAcl,
|
|
1428
|
+
actorId: aclActorId(dropdownActor),
|
|
1429
|
+
actionId,
|
|
1430
|
+
recordRef: 'r.resourceId',
|
|
1431
|
+
scopes: aclSpec?.scopes,
|
|
1432
|
+
selfScope: aclSpec?.selfScope,
|
|
1433
|
+
mode: aclMode,
|
|
1434
|
+
});
|
|
1435
|
+
query = query.whereRaw(filter.sql, filter.bindings as never[]);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1081
1438
|
const rows = (await query) as Array<{resourceId: Buffer; label: string}>;
|
|
1082
1439
|
result[`${s}.${object}`] = rows.map(r => ({
|
|
1083
1440
|
value: r.resourceId.toString('base64'),
|
|
@@ -1238,8 +1595,16 @@ export {attachHandlers, methodId, snakeToCamel};
|
|
|
1238
1595
|
export interface IResolvedTableOptions {
|
|
1239
1596
|
/** Whether the table is resource-backed (PK → `core.resource`). */
|
|
1240
1597
|
resource: boolean;
|
|
1598
|
+
/**
|
|
1599
|
+
* Real display-name column when it is not the virtual `${object}Name`
|
|
1600
|
+
* (declared as `resource: {nameColumn}`) — the resource row is named from it
|
|
1601
|
+
* and the column stays a normal table column.
|
|
1602
|
+
*/
|
|
1603
|
+
nameColumn?: string;
|
|
1241
1604
|
/** Declarative graph-edge master-detail bindings. */
|
|
1242
1605
|
edges: IEdgeBinding[];
|
|
1606
|
+
/** Record-level ACL guard (`mode` defaults to `none`). */
|
|
1607
|
+
acl: IAclTableSpec;
|
|
1243
1608
|
}
|
|
1244
1609
|
|
|
1245
1610
|
/**
|
|
@@ -1260,7 +1625,9 @@ function resolveTableSpec(
|
|
|
1260
1625
|
definition?: TObject;
|
|
1261
1626
|
dropdown?: ISchemaTable['dropdown'];
|
|
1262
1627
|
resource?: boolean;
|
|
1628
|
+
nameColumn?: string;
|
|
1263
1629
|
edges?: IEdgeBinding[];
|
|
1630
|
+
acl?: IAclTableSpec;
|
|
1264
1631
|
} {
|
|
1265
1632
|
if (tableConfig === undefined) return {};
|
|
1266
1633
|
if (typeof tableConfig === 'number') {
|
|
@@ -1275,14 +1642,21 @@ function resolveTableSpec(
|
|
|
1275
1642
|
'order' in tableConfig ||
|
|
1276
1643
|
'dropdown' in tableConfig ||
|
|
1277
1644
|
'resource' in tableConfig ||
|
|
1278
|
-
'edges' in tableConfig
|
|
1645
|
+
'edges' in tableConfig ||
|
|
1646
|
+
'acl' in tableConfig
|
|
1279
1647
|
) {
|
|
1280
1648
|
const spec = tableConfig as ISchemaTable;
|
|
1649
|
+
const resourceSpec =
|
|
1650
|
+
typeof spec.resource === 'object' && spec.resource !== null
|
|
1651
|
+
? spec.resource
|
|
1652
|
+
: undefined;
|
|
1281
1653
|
return {
|
|
1282
1654
|
definition: spec.definition ?? objectSchema[subject]?.[object],
|
|
1283
1655
|
dropdown: spec.dropdown,
|
|
1284
|
-
resource: spec.resource,
|
|
1656
|
+
resource: resourceSpec ? true : spec.resource === true,
|
|
1657
|
+
nameColumn: resourceSpec?.nameColumn,
|
|
1285
1658
|
edges: spec.edges,
|
|
1659
|
+
acl: spec.acl,
|
|
1286
1660
|
};
|
|
1287
1661
|
}
|
|
1288
1662
|
return {definition: tableConfig as TObject};
|
|
@@ -1308,8 +1682,13 @@ function tableOptions(
|
|
|
1308
1682
|
: {};
|
|
1309
1683
|
const edges = spec.edges ?? [];
|
|
1310
1684
|
return {
|
|
1311
|
-
|
|
1685
|
+
// `resource` is declared explicitly — an `edges` binding alone does NOT
|
|
1686
|
+
// switch on the resource-backed name machinery (a table may carry edges
|
|
1687
|
+
// while keeping its own display-name column).
|
|
1688
|
+
resource: spec.resource === true,
|
|
1689
|
+
nameColumn: spec.nameColumn,
|
|
1312
1690
|
edges,
|
|
1691
|
+
acl: spec.acl ?? {},
|
|
1313
1692
|
};
|
|
1314
1693
|
}
|
|
1315
1694
|
|