@reventlessdev/reventless-spec 3.0.0-alpha.108 → 3.0.0-alpha.109
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 +10 -0
- package/package.json +1 -1
- package/src/components/Owner.res +118 -0
- package/src/components/Owner.res.mjs +96 -0
- package/src/components/Plugin.res +50 -0
- package/src/components/Plugin.res.mjs +6 -2
- package/src/types/OwnerScope.res +227 -0
- package/src/types/OwnerScope.res.mjs +173 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.109 (2026-08-12)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **plugin:** publish the access a component's authorization rule implies ([e0d0f09](https://github.com/ReventlessDev/reventless-core/commit/e0d0f096b72fe44b185ed28dc1c133364fa841b8))
|
|
11
|
+
* **plugin:** publish which field ties a component to its owner ([ca71289](https://github.com/ReventlessDev/reventless-core/commit/ca7128931ce525af0d7d3a4487b2b5c54b19bec0))
|
|
12
|
+
* **spec:** let a record name the field that identifies its owner ([b69ee91](https://github.com/ReventlessDev/reventless-core/commit/b69ee9123e6beb38fcdd716519103ab9328213c6))
|
|
13
|
+
* **spec:** let the environment name the groups exempt from owner scoping ([2de5c51](https://github.com/ReventlessDev/reventless-core/commit/2de5c5118347a46998f9ab603308fca09addf00b))
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
# 3.0.0-alpha.108 (2026-08-11)
|
|
7
17
|
|
|
8
18
|
### Features
|
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Marks the field that ties a row — or a command — to the caller who owns it.
|
|
3
|
+
|
|
4
|
+
`@owner` is a *position*, not a type: it says "this field holds the id of the
|
|
5
|
+
principal this record belongs to". The field's nature (an entity id, a
|
|
6
|
+
reference) is declared separately and independently, exactly as a DCB tag and a
|
|
7
|
+
reference are two independent facts about one field.
|
|
8
|
+
|
|
9
|
+
Two things follow from the marker, both server-side and neither optional:
|
|
10
|
+
|
|
11
|
+
- the write path **overwrites** the field with the authenticated caller's id
|
|
12
|
+
before the command is published, so an absent field and a forged field produce
|
|
13
|
+
the same row; and
|
|
14
|
+
- reads of a view whose state carries the marker are narrowed to the caller's
|
|
15
|
+
own rows, unless the caller is elevated.
|
|
16
|
+
|
|
17
|
+
Because it drives enforcement, a reader that misses the marker fails *open* —
|
|
18
|
+
the field goes unstamped and the view goes unscoped, silently. That is why
|
|
19
|
+
`fieldNames` follows the same wrappers `Reference.getFieldTarget` follows, and
|
|
20
|
+
why it exists at all rather than leaving each consumer to look the marker up
|
|
21
|
+
itself.
|
|
22
|
+
|
|
23
|
+
There is no `@owner` ppx shorthand yet — `@s.matches(Owner.string)` is the
|
|
24
|
+
authoring form, not a workaround for one. The shorthand is sugar over exactly
|
|
25
|
+
this, the way `@ref` is sugar over `Reference.to_`, so it can be added without
|
|
26
|
+
changing what any reader here does; until it exists, prefer the explicit form
|
|
27
|
+
over inventing an attribute the ppx will reject.
|
|
28
|
+
|
|
29
|
+
@example
|
|
30
|
+
```rescript
|
|
31
|
+
@schema type command =
|
|
32
|
+
PlaceOrder({
|
|
33
|
+
@partitionTag orderId: string,
|
|
34
|
+
customerId: @s.matches(Owner.string) string,
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
*/
|
|
38
|
+
let ownerId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(~namespace="reventless", ~name="owner")
|
|
39
|
+
|
|
40
|
+
/** A string field declared as the record's owner. */
|
|
41
|
+
let string: S.t<string> = S.string->S.Metadata.set(~id=ownerId, true)
|
|
42
|
+
|
|
43
|
+
/** Whether this exact schema carries the marker. Does not look through wrappers. */
|
|
44
|
+
let isOwner = (schema: S.t<unknown>): bool =>
|
|
45
|
+
S.Metadata.get(schema, ~id=ownerId)->Option.getOr(false)
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
Whether a *field* is the owner, wherever inside the field's type the marker sits.
|
|
49
|
+
|
|
50
|
+
An optional field keeps its marker inside the union wrapper, so a walk reading
|
|
51
|
+
only the outer schema answers `false` for `customerId?: string` — which for an
|
|
52
|
+
access-control predicate means an unscoped view rather than a reported mistake.
|
|
53
|
+
Object properties are deliberately not followed: a marker on a nested record's
|
|
54
|
+
field belongs to that field, and attributing it to the enclosing one would scope
|
|
55
|
+
the view on the wrong value.
|
|
56
|
+
*/
|
|
57
|
+
let isFieldOwner = (schema: S.t<unknown>): bool =>
|
|
58
|
+
isOwner(schema) ||
|
|
59
|
+
switch schema->Semantic.unwrapOptional {
|
|
60
|
+
| Some(inner) => isOwner(inner)
|
|
61
|
+
| None => false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
The owner fields declared on an object schema, in declaration order.
|
|
66
|
+
|
|
67
|
+
Returns an array rather than an `option<string>` so the *caller* decides what a
|
|
68
|
+
second owner means. The structure walk rejects it; a plain reader emitting a
|
|
69
|
+
wire marker has no reason to.
|
|
70
|
+
*/
|
|
71
|
+
let fieldNamesOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
|
|
72
|
+
properties
|
|
73
|
+
->Dict.toArray
|
|
74
|
+
->Array.filterMap(((propName, propSchema)) => isFieldOwner(propSchema) ? Some(propName) : None)
|
|
75
|
+
|
|
76
|
+
let fieldNames = (schema: S.t<unknown>): array<string> =>
|
|
77
|
+
switch schema {
|
|
78
|
+
| Object({properties}) => fieldNamesOfProperties(properties)
|
|
79
|
+
| _ => []
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
The owner fields of one constructor of a command or event union.
|
|
84
|
+
|
|
85
|
+
A command schema is a union of variants, and only the variant actually being
|
|
86
|
+
issued may be stamped — `PlaceOrder` and `ImportProducts` live in the same union
|
|
87
|
+
and have nothing to say about each other's fields. Resolving by TAG here, rather
|
|
88
|
+
than at the call site, is what stops a caller from stamping across variants.
|
|
89
|
+
|
|
90
|
+
Answers `[]` for an unknown tag and for a payload-less variant, both of which
|
|
91
|
+
mean the same thing to every caller: this command carries no owner.
|
|
92
|
+
*/
|
|
93
|
+
let variantFieldNames = (schema: S.t<unknown>, ~variant: string): array<string> => {
|
|
94
|
+
let isVariant = (properties: dict<S.t<unknown>>) =>
|
|
95
|
+
switch properties->Dict.get("TAG") {
|
|
96
|
+
| Some(String({const: ?Some(name)})) => name == variant
|
|
97
|
+
| _ => false
|
|
98
|
+
}
|
|
99
|
+
switch schema {
|
|
100
|
+
| Union({anyOf}) =>
|
|
101
|
+
anyOf
|
|
102
|
+
->Array.find(v =>
|
|
103
|
+
switch v {
|
|
104
|
+
| Object({properties}) => isVariant(properties)
|
|
105
|
+
| _ => false
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
->Option.mapOr([], v =>
|
|
109
|
+
switch v {
|
|
110
|
+
| Object({properties}) => fieldNamesOfProperties(properties)
|
|
111
|
+
| _ => []
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
// A single-constructor command compiles to a bare object rather than a union.
|
|
115
|
+
| Object({properties}) => isVariant(properties) ? fieldNamesOfProperties(properties) : []
|
|
116
|
+
| _ => []
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
|
|
7
|
+
|
|
8
|
+
let ownerId = S.Metadata.Id.make("reventless", "owner");
|
|
9
|
+
|
|
10
|
+
let string = S.Metadata.set(S.string, ownerId, true);
|
|
11
|
+
|
|
12
|
+
function isOwner(schema) {
|
|
13
|
+
return Stdlib_Option.getOr(S.Metadata.get(schema, ownerId), false);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isFieldOwner(schema) {
|
|
17
|
+
if (isOwner(schema)) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
let inner = Semantic$Reventless.unwrapOptional(schema);
|
|
21
|
+
if (inner !== undefined) {
|
|
22
|
+
return isOwner(inner);
|
|
23
|
+
} else {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fieldNamesOfProperties(properties) {
|
|
29
|
+
return Stdlib_Array.filterMap(Object.entries(properties), param => {
|
|
30
|
+
if (isFieldOwner(param[1])) {
|
|
31
|
+
return param[0];
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fieldNames(schema) {
|
|
37
|
+
if (schema.type === "object") {
|
|
38
|
+
return fieldNamesOfProperties(schema.properties);
|
|
39
|
+
} else {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function variantFieldNames(schema, variant) {
|
|
45
|
+
let isVariant = properties => {
|
|
46
|
+
let match = properties["TAG"];
|
|
47
|
+
if (match === undefined) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (match.type !== "string") {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
let name = match.const;
|
|
54
|
+
if (name !== undefined) {
|
|
55
|
+
return name === variant;
|
|
56
|
+
} else {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
switch (schema.type) {
|
|
61
|
+
case "object" :
|
|
62
|
+
let properties = schema.properties;
|
|
63
|
+
if (isVariant(properties)) {
|
|
64
|
+
return fieldNamesOfProperties(properties);
|
|
65
|
+
} else {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
case "union" :
|
|
69
|
+
return Stdlib_Option.mapOr(schema.anyOf.find(v => {
|
|
70
|
+
if (v.type === "object") {
|
|
71
|
+
return isVariant(v.properties);
|
|
72
|
+
} else {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}), [], v => {
|
|
76
|
+
if (v.type === "object") {
|
|
77
|
+
return fieldNamesOfProperties(v.properties);
|
|
78
|
+
} else {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
default:
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
ownerId,
|
|
89
|
+
string,
|
|
90
|
+
isOwner,
|
|
91
|
+
isFieldOwner,
|
|
92
|
+
fieldNamesOfProperties,
|
|
93
|
+
fieldNames,
|
|
94
|
+
variantFieldNames,
|
|
95
|
+
}
|
|
96
|
+
/* ownerId Not a pure module */
|
|
@@ -194,6 +194,34 @@ type commandDef = {
|
|
|
194
194
|
(read as None) — those stores must be reset. See [[sury-optional-field-absent-vs-null]].
|
|
195
195
|
*/
|
|
196
196
|
apiExposed: @s.matches(boolOptionSchema) option<bool>,
|
|
197
|
+
/**
|
|
198
|
+
Access keys a caller must hold — any one of them — to be *offered* this command,
|
|
199
|
+
derived from the authorization rule the server already enforces. `None` (or `[]`)
|
|
200
|
+
means the rule asks for nothing a client can check.
|
|
201
|
+
|
|
202
|
+
A hint, never a boundary: the rule in the resolver is what refuses a call, and a
|
|
203
|
+
caller who edits this list gains nothing. It exists so a client stops advertising
|
|
204
|
+
what the server would refuse — an offered command that always fails is a worse
|
|
205
|
+
answer than no command at all. Derived rather than authored, so it cannot drift
|
|
206
|
+
from the rule it describes. js_nullable, so defs written before this field
|
|
207
|
+
existed decode as None.
|
|
208
|
+
*/
|
|
209
|
+
requiredAccess: @s.matches(stringArrayOptionSchema) option<array<string>>,
|
|
210
|
+
/**
|
|
211
|
+
Name of the command field the server stamps with the caller's own identity, when
|
|
212
|
+
the command declares one (`@owner`). A client should omit it from a generated
|
|
213
|
+
form for a caller who is not elevated: whatever it collects there is discarded
|
|
214
|
+
and replaced, so offering the field asks a question whose answer is ignored.
|
|
215
|
+
|
|
216
|
+
Derived from the annotation, never authored, so it cannot disagree with what the
|
|
217
|
+
write path actually does. js_nullable for the same JSON-safety reason as
|
|
218
|
+
`requiredAccess`.
|
|
219
|
+
|
|
220
|
+
⚠️ A client cannot decide the *elevated* half from this alone — the manifest
|
|
221
|
+
states which field carries the owner, and the caller's own identity says whether
|
|
222
|
+
they are exempt. Both are needed, and neither is derivable from the other.
|
|
223
|
+
*/
|
|
224
|
+
ownerField: @s.matches(stringOptionSchema) option<string>,
|
|
197
225
|
}
|
|
198
226
|
|
|
199
227
|
@schema
|
|
@@ -246,6 +274,17 @@ type queryableDef = {
|
|
|
246
274
|
*/
|
|
247
275
|
statusField: @s.matches(stringOptionSchema) option<string>,
|
|
248
276
|
/**
|
|
277
|
+
Name of the state field that ties a row to the principal owning it (`@owner`),
|
|
278
|
+
when the view declares one. Two consequences for a client: reads of this view
|
|
279
|
+
are narrowed server-side to a non-elevated caller's own rows, and the column is
|
|
280
|
+
constant for such a caller and so carries no information in a list.
|
|
281
|
+
|
|
282
|
+
Derived from the annotation. As on `commandDef.ownerField`, this states which
|
|
283
|
+
field carries the owner and not whether the current caller is exempt — that is
|
|
284
|
+
the caller's own identity to answer.
|
|
285
|
+
*/
|
|
286
|
+
ownerField: @s.matches(stringOptionSchema) option<string>,
|
|
287
|
+
/**
|
|
249
288
|
Component visibility hint (`@@reventless.visibility`). `Some("Internal")` marks a
|
|
250
289
|
ReadModel / StateViewSlice that the deployed AutoUI hides from its menu, drill-down
|
|
251
290
|
pages, web event graph and cross-plugin edges. `None` (absent) means Public. Internal
|
|
@@ -314,6 +353,17 @@ type queryableDef = {
|
|
|
314
353
|
`None` whenever `idField` is `None`, and on defs that predate the field.
|
|
315
354
|
*/
|
|
316
355
|
idFieldSource: @s.matches(stringOptionSchema) option<string>,
|
|
356
|
+
/**
|
|
357
|
+
Access keys a caller must hold — any one of them — to be *offered* this view,
|
|
358
|
+
derived from the component's module-level authorization rule. Same terms as
|
|
359
|
+
`commandDef.requiredAccess`: a hint that keeps a client from advertising a
|
|
360
|
+
surface the server would refuse, never the refusal itself.
|
|
361
|
+
|
|
362
|
+
Worth stating for reads in particular: a denied query does not error, it comes
|
|
363
|
+
back empty, so a client that offers a view it may not read renders a confident
|
|
364
|
+
blank table rather than a visible failure.
|
|
365
|
+
*/
|
|
366
|
+
requiredAccess: @s.matches(stringArrayOptionSchema) option<array<string>>,
|
|
317
367
|
}
|
|
318
368
|
|
|
319
369
|
/**
|
|
@@ -104,7 +104,9 @@ let commandDefSchema = S.schema(s => ({
|
|
|
104
104
|
references: s.m(S.array(fieldReferenceSchema)),
|
|
105
105
|
allowedStates: s.m(stringArrayOptionSchema),
|
|
106
106
|
targetState: s.m(stringOptionSchema),
|
|
107
|
-
apiExposed: s.m(boolOptionSchema)
|
|
107
|
+
apiExposed: s.m(boolOptionSchema),
|
|
108
|
+
requiredAccess: s.m(stringArrayOptionSchema),
|
|
109
|
+
ownerField: s.m(stringOptionSchema)
|
|
108
110
|
}));
|
|
109
111
|
|
|
110
112
|
let queryableDefSchema = S.schema(s => ({
|
|
@@ -117,11 +119,13 @@ let queryableDefSchema = S.schema(s => ({
|
|
|
117
119
|
searchableFields: s.m(S.array(S.string)),
|
|
118
120
|
labelFieldSource: s.m(stringOptionSchema),
|
|
119
121
|
statusField: s.m(stringOptionSchema),
|
|
122
|
+
ownerField: s.m(stringOptionSchema),
|
|
120
123
|
visibility: s.m(stringOptionSchema),
|
|
121
124
|
chapter: s.m(stringOptionSchema),
|
|
122
125
|
singleQueryField: s.m(stringOptionSchema),
|
|
123
126
|
idField: s.m(stringOptionSchema),
|
|
124
|
-
idFieldSource: s.m(stringOptionSchema)
|
|
127
|
+
idFieldSource: s.m(stringOptionSchema),
|
|
128
|
+
requiredAccess: s.m(stringArrayOptionSchema)
|
|
125
129
|
}));
|
|
126
130
|
|
|
127
131
|
let eventDefSchema = S.schema(s => ({
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Classifies the caller behind a request for the purposes of `@owner` enforcement:
|
|
3
|
+
whose id gets stamped into an owner-marked command field, and whose rows an
|
|
4
|
+
owner-scoped read is narrowed to.
|
|
5
|
+
|
|
6
|
+
This is deliberately one function rather than a check written twice. The write
|
|
7
|
+
path and the read path must agree about who is exempt — an implementation that
|
|
8
|
+
scopes reads but not writes labels rows correctly and shows them to everyone,
|
|
9
|
+
and one that scopes writes but not reads does the reverse. Both call `resolve`.
|
|
10
|
+
|
|
11
|
+
⚠️ **`Identity.t` is not trustworthy at this boundary, and this module is where
|
|
12
|
+
that stops mattering.** The AppSync resolver templates build the identity object
|
|
13
|
+
in generated JavaScript and hand it to a handler that types it as `Identity.t`
|
|
14
|
+
without decoding it. For a Cognito caller the shape matches. For an IAM-signed
|
|
15
|
+
caller — every AppSync API in a deployed estate carries `AWS_IAM` as an
|
|
16
|
+
additional provider for service-to-service traffic — the template emits
|
|
17
|
+
`{userArn, accountId, username, provider: 'IAM'}`: no `userId`, no `groups`, and
|
|
18
|
+
a `provider` string that is not one of the three the variant models. So the
|
|
19
|
+
fields this module reads are typed non-optional and are, at runtime, sometimes
|
|
20
|
+
absent. Every read here goes through a nullable cast for that reason; deleting
|
|
21
|
+
one restores a silent failure rather than a type error.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Whether a JS value is a primitive string — `Cognito` and `InMemory` compile to
|
|
25
|
+
bare strings while `Custom(_)` compiles to an object, so this is how a modelled
|
|
26
|
+
provider is told from an unmodelled one that arrived as raw JSON. */
|
|
27
|
+
let isJsString: 'a => bool = %raw(`v => typeof v === "string"`)
|
|
28
|
+
|
|
29
|
+
external asNullableString: string => Nullable.t<string> = "%identity"
|
|
30
|
+
external asNullableArray: array<string> => Nullable.t<array<string>> = "%identity"
|
|
31
|
+
external asString: Identity.provider => string = "%identity"
|
|
32
|
+
external asNullableIdentity: Identity.t => Nullable.t<Identity.t> = "%identity"
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
The caller, classified.
|
|
36
|
+
|
|
37
|
+
`System` and `Elevated` behave identically today — neither is stamped, neither is
|
|
38
|
+
scoped — and are still separate cases because they are different claims. `System`
|
|
39
|
+
says the platform is calling itself; `Elevated` says a named human holds an
|
|
40
|
+
operator group. Collapsing them would make an audit unable to tell a service
|
|
41
|
+
write from an administrator's.
|
|
42
|
+
|
|
43
|
+
`Unidentified` carries a short reason because it is the fail-closed branch, and
|
|
44
|
+
the fail-closed branch is the one someone will be debugging.
|
|
45
|
+
*/
|
|
46
|
+
type t =
|
|
47
|
+
| System
|
|
48
|
+
| Elevated({userId: string})
|
|
49
|
+
| Owned({userId: string})
|
|
50
|
+
| Unidentified(string)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
Providers that identify a *machine*, not a person. Members are exempt from
|
|
54
|
+
stamping and scoping.
|
|
55
|
+
|
|
56
|
+
An allowlist rather than "anything not modelled", because the fallback direction
|
|
57
|
+
is the whole safety property here: a provider nobody has classified must land in
|
|
58
|
+
`Unidentified` and be refused, not in `System` and be handed unscoped reads. Add
|
|
59
|
+
to this list deliberately.
|
|
60
|
+
*/
|
|
61
|
+
let systemProviders = ["IAM"]
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
Groups whose members read across every owner.
|
|
65
|
+
|
|
66
|
+
A single deployment-wide list, not a parameter of the annotation. Per-annotation
|
|
67
|
+
elevation would let two views disagree about who an operator is, so a caller
|
|
68
|
+
scoped on one view would be unscoped on the next — and the gap would appear one
|
|
69
|
+
view at a time, as views were added.
|
|
70
|
+
|
|
71
|
+
Resolved as: an explicit `setElevatedGroups` wins, else the environment, else
|
|
72
|
+
empty. The env fallback exists because a deployment is **two kinds of process**,
|
|
73
|
+
not one. On a cloud provider the read predicate for a table-backed view is baked
|
|
74
|
+
into resolver source by the deploy program, while stamping and the SQL-backed
|
|
75
|
+
reads run later inside separate function runtimes the deploy never enters. A
|
|
76
|
+
value set in the deploy program alone reaches the first and not the second — and
|
|
77
|
+
the failure that produces is a *wrong write*, not a narrow read: an operator
|
|
78
|
+
acting on someone's behalf gets the row stamped with their own id, because the
|
|
79
|
+
runtime believes nobody is elevated. An environment variable is the only carrier
|
|
80
|
+
both kinds of process share, which is the same reason the logger's level is one.
|
|
81
|
+
|
|
82
|
+
Empty remains the default in both directions: a deployment that configures
|
|
83
|
+
nothing shows operators too little rather than showing customers each other.
|
|
84
|
+
*/
|
|
85
|
+
@val
|
|
86
|
+
external _elevatedGroupsEnv: option<string> = "process.env.REVENTLESS_ELEVATED_GROUPS"
|
|
87
|
+
|
|
88
|
+
let explicitElevatedGroups: ref<option<array<string>>> = ref(None)
|
|
89
|
+
|
|
90
|
+
/** Set the list for this process. Wins over the environment — a platform root
|
|
91
|
+
that states its operator groups in code means it, and should not be silently
|
|
92
|
+
overridden by a stray variable. */
|
|
93
|
+
let setElevatedGroups = (groups: array<string>) => explicitElevatedGroups := Some(groups)
|
|
94
|
+
|
|
95
|
+
/** Forget an explicit setting and fall back to the environment again. */
|
|
96
|
+
let clearElevatedGroups = () => explicitElevatedGroups := None
|
|
97
|
+
|
|
98
|
+
let parseElevatedGroups = (raw: string): array<string> =>
|
|
99
|
+
raw
|
|
100
|
+
->String.split(",")
|
|
101
|
+
->Array.map(String.trim)
|
|
102
|
+
->Array.filter(part => part->String.length > 0)
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
The groups exempt from owner scoping, right now.
|
|
106
|
+
|
|
107
|
+
A function rather than a `ref` anyone can read: the answer depends on the
|
|
108
|
+
environment as well as on what was set, and it is re-read per call so a value
|
|
109
|
+
appearing later in a process still takes effect. Reading a raw ref would have
|
|
110
|
+
frozen whichever half happened to be consulted first.
|
|
111
|
+
*/
|
|
112
|
+
let elevatedGroups = (): array<string> =>
|
|
113
|
+
switch explicitElevatedGroups.contents {
|
|
114
|
+
| Some(groups) => groups
|
|
115
|
+
| None =>
|
|
116
|
+
switch _elevatedGroupsEnv {
|
|
117
|
+
| Some(raw) => parseElevatedGroups(raw)
|
|
118
|
+
| None => []
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Order matters: the provider is examined before `userId`, because the IAM
|
|
123
|
+
// caller fails the `userId` test for a reason that has nothing to do with being
|
|
124
|
+
// anonymous and must not be refused as though it did.
|
|
125
|
+
let classify = (identity: Identity.t, ~elevated: array<string>): t => {
|
|
126
|
+
let provider = identity.provider
|
|
127
|
+
let providerName = isJsString(provider) ? Some(provider->asString) : None
|
|
128
|
+
|
|
129
|
+
switch providerName {
|
|
130
|
+
| Some(name) if systemProviders->Array.includes(name) => System
|
|
131
|
+
// A modelled string provider, or `Custom(_)` (an object, so `providerName` is
|
|
132
|
+
// None) — either way a caller claiming to be a person. Unmodelled strings fall
|
|
133
|
+
// through to the refusal below.
|
|
134
|
+
| Some("Cognito") | Some("InMemory") | None =>
|
|
135
|
+
switch identity.userId->asNullableString->Nullable.toOption {
|
|
136
|
+
| None => Unidentified("identity carries no userId")
|
|
137
|
+
| Some("") => Unidentified("identity carries an empty userId")
|
|
138
|
+
| Some(userId) if userId == Identity.anonymous.userId =>
|
|
139
|
+
Unidentified("caller is anonymous")
|
|
140
|
+
| Some(userId) =>
|
|
141
|
+
let groups = identity.groups->asNullableArray->Nullable.toOption->Option.getOr([])
|
|
142
|
+
groups->Array.some(g => elevated->Array.includes(g))
|
|
143
|
+
? Elevated({userId: userId})
|
|
144
|
+
: Owned({userId: userId})
|
|
145
|
+
}
|
|
146
|
+
| Some(name) => Unidentified(`unrecognised identity provider "${name}"`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
Classify a caller. `~elevated` defaults to the configured list so call sites do
|
|
152
|
+
not each have to remember to read it.
|
|
153
|
+
*/
|
|
154
|
+
let resolve = (identity: Identity.t, ~elevated: array<string>=elevatedGroups()): t =>
|
|
155
|
+
// The whole identity, not just its fields, can be missing: an internal caller
|
|
156
|
+
// that builds a payload without one reaches here with `undefined`. Reading
|
|
157
|
+
// through it would raise a TypeError, which surfaces as a crash rather than as
|
|
158
|
+
// the refusal this case actually is.
|
|
159
|
+
switch identity->asNullableIdentity->Nullable.toOption {
|
|
160
|
+
| None => Unidentified("request carries no identity")
|
|
161
|
+
| Some(identity) => classify(identity, ~elevated)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
The id an owner-marked field takes, and the value an owner-scoped read matches.
|
|
166
|
+
|
|
167
|
+
`None` for `System` and `Elevated` means "do not stamp, do not scope" — and it
|
|
168
|
+
means the same for `Unidentified`, which is why no caller may treat this as the
|
|
169
|
+
whole answer. A write must refuse an `Unidentified` caller outright rather than
|
|
170
|
+
publish an unstamped command; use `resolve` directly there.
|
|
171
|
+
*/
|
|
172
|
+
let ownerId = (scope: t): option<string> =>
|
|
173
|
+
switch scope {
|
|
174
|
+
| Owned({userId}) => Some(userId)
|
|
175
|
+
| System | Elevated(_) | Unidentified(_) => None
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Whether the caller reads and writes across every owner. */
|
|
179
|
+
let isExempt = (scope: t): bool =>
|
|
180
|
+
switch scope {
|
|
181
|
+
| System | Elevated(_) => true
|
|
182
|
+
| Owned(_) | Unidentified(_) => false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
What owner scoping does to one read of one view.
|
|
187
|
+
|
|
188
|
+
`RefuseOwned` is kept apart from "scope to a value nobody holds" because the two
|
|
189
|
+
produce the same empty page for different reasons, and only one of them is a
|
|
190
|
+
refusal — a door that wants to say so needs to be able to tell.
|
|
191
|
+
*/
|
|
192
|
+
type decision =
|
|
193
|
+
| Unscoped
|
|
194
|
+
| ScopeTo(string, string)
|
|
195
|
+
| RefuseOwned
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
Combine a view's declared owner field with the caller behind the request.
|
|
199
|
+
|
|
200
|
+
Lives here rather than at each read path because there are four of them —
|
|
201
|
+
the shared list spec, two SQL push-downs and the generated AppSync resolver —
|
|
202
|
+
and they must answer identically. Four copies of this `switch` would be four
|
|
203
|
+
chances to decide that an unidentified caller "just sees nothing".
|
|
204
|
+
*/
|
|
205
|
+
let decide = (
|
|
206
|
+
identity: Identity.t,
|
|
207
|
+
~ownerField: option<string>,
|
|
208
|
+
~elevated: array<string>=elevatedGroups(),
|
|
209
|
+
): decision =>
|
|
210
|
+
switch ownerField {
|
|
211
|
+
| None => Unscoped
|
|
212
|
+
| Some(field) =>
|
|
213
|
+
switch resolve(identity, ~elevated) {
|
|
214
|
+
| System | Elevated(_) => Unscoped
|
|
215
|
+
| Owned({userId}) => ScopeTo(field, userId)
|
|
216
|
+
// The view records an owner and the caller has none. Scoping to a value
|
|
217
|
+
// nobody holds would be indistinguishable from an empty view.
|
|
218
|
+
| Unidentified(_) => RefuseOwned
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The `(field, value)` pair a list query narrows on, or `None` when it does not. */
|
|
223
|
+
let scopeOf = (decision: decision): option<(string, string)> =>
|
|
224
|
+
switch decision {
|
|
225
|
+
| ScopeTo(field, required) => Some((field, required))
|
|
226
|
+
| Unscoped | RefuseOwned => None
|
|
227
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
5
|
+
import * as Identity$Reventless from "./Identity.res.mjs";
|
|
6
|
+
|
|
7
|
+
let isJsString = (v => typeof v === "string");
|
|
8
|
+
|
|
9
|
+
let systemProviders = ["IAM"];
|
|
10
|
+
|
|
11
|
+
let explicitElevatedGroups = {
|
|
12
|
+
contents: undefined
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function setElevatedGroups(groups) {
|
|
16
|
+
explicitElevatedGroups.contents = groups;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clearElevatedGroups() {
|
|
20
|
+
explicitElevatedGroups.contents = undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseElevatedGroups(raw) {
|
|
24
|
+
return raw.split(",").map(prim => prim.trim()).filter(part => part.length > 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function elevatedGroups() {
|
|
28
|
+
let groups = explicitElevatedGroups.contents;
|
|
29
|
+
if (groups !== undefined) {
|
|
30
|
+
return groups;
|
|
31
|
+
}
|
|
32
|
+
let raw = process.env.REVENTLESS_ELEVATED_GROUPS;
|
|
33
|
+
if (raw !== undefined) {
|
|
34
|
+
return parseElevatedGroups(raw);
|
|
35
|
+
} else {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function classify(identity, elevated) {
|
|
41
|
+
let provider = identity.provider;
|
|
42
|
+
let providerName = isJsString(provider) ? provider : undefined;
|
|
43
|
+
if (providerName !== undefined) {
|
|
44
|
+
if (systemProviders.includes(providerName)) {
|
|
45
|
+
return "System";
|
|
46
|
+
}
|
|
47
|
+
switch (providerName) {
|
|
48
|
+
case "Cognito" :
|
|
49
|
+
case "InMemory" :
|
|
50
|
+
break;
|
|
51
|
+
default:
|
|
52
|
+
return {
|
|
53
|
+
TAG: "Unidentified",
|
|
54
|
+
_0: `unrecognised identity provider "` + providerName + `"`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
let userId = identity.userId;
|
|
59
|
+
if (userId == null) {
|
|
60
|
+
return {
|
|
61
|
+
TAG: "Unidentified",
|
|
62
|
+
_0: "identity carries no userId"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (userId === "") {
|
|
66
|
+
return {
|
|
67
|
+
TAG: "Unidentified",
|
|
68
|
+
_0: "identity carries an empty userId"
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (userId === Identity$Reventless.anonymous.userId) {
|
|
72
|
+
return {
|
|
73
|
+
TAG: "Unidentified",
|
|
74
|
+
_0: "caller is anonymous"
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
let groups = Stdlib_Option.getOr(Primitive_option.fromNullable(identity.groups), []);
|
|
78
|
+
if (groups.some(g => elevated.includes(g))) {
|
|
79
|
+
return {
|
|
80
|
+
TAG: "Elevated",
|
|
81
|
+
userId: userId
|
|
82
|
+
};
|
|
83
|
+
} else {
|
|
84
|
+
return {
|
|
85
|
+
TAG: "Owned",
|
|
86
|
+
userId: userId
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolve(identity, elevatedOpt) {
|
|
92
|
+
let elevated = elevatedOpt !== undefined ? elevatedOpt : elevatedGroups();
|
|
93
|
+
if (identity == null) {
|
|
94
|
+
return {
|
|
95
|
+
TAG: "Unidentified",
|
|
96
|
+
_0: "request carries no identity"
|
|
97
|
+
};
|
|
98
|
+
} else {
|
|
99
|
+
return classify(identity, elevated);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function ownerId(scope) {
|
|
104
|
+
if (typeof scope !== "object" || scope.TAG !== "Owned") {
|
|
105
|
+
return;
|
|
106
|
+
} else {
|
|
107
|
+
return scope.userId;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isExempt(scope) {
|
|
112
|
+
if (typeof scope !== "object") {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
switch (scope.TAG) {
|
|
116
|
+
case "Elevated" :
|
|
117
|
+
return true;
|
|
118
|
+
case "Owned" :
|
|
119
|
+
case "Unidentified" :
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function decide(identity, ownerField, elevatedOpt) {
|
|
125
|
+
let elevated = elevatedOpt !== undefined ? elevatedOpt : elevatedGroups();
|
|
126
|
+
if (ownerField === undefined) {
|
|
127
|
+
return "Unscoped";
|
|
128
|
+
}
|
|
129
|
+
let match = resolve(identity, elevated);
|
|
130
|
+
if (typeof match !== "object") {
|
|
131
|
+
return "Unscoped";
|
|
132
|
+
}
|
|
133
|
+
switch (match.TAG) {
|
|
134
|
+
case "Elevated" :
|
|
135
|
+
return "Unscoped";
|
|
136
|
+
case "Owned" :
|
|
137
|
+
return {
|
|
138
|
+
TAG: "ScopeTo",
|
|
139
|
+
_0: ownerField,
|
|
140
|
+
_1: match.userId
|
|
141
|
+
};
|
|
142
|
+
case "Unidentified" :
|
|
143
|
+
return "RefuseOwned";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function scopeOf(decision) {
|
|
148
|
+
if (typeof decision !== "object") {
|
|
149
|
+
return;
|
|
150
|
+
} else {
|
|
151
|
+
return [
|
|
152
|
+
decision._0,
|
|
153
|
+
decision._1
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export {
|
|
159
|
+
isJsString,
|
|
160
|
+
systemProviders,
|
|
161
|
+
explicitElevatedGroups,
|
|
162
|
+
setElevatedGroups,
|
|
163
|
+
clearElevatedGroups,
|
|
164
|
+
parseElevatedGroups,
|
|
165
|
+
elevatedGroups,
|
|
166
|
+
classify,
|
|
167
|
+
resolve,
|
|
168
|
+
ownerId,
|
|
169
|
+
isExempt,
|
|
170
|
+
decide,
|
|
171
|
+
scopeOf,
|
|
172
|
+
}
|
|
173
|
+
/* Identity-Reventless Not a pure module */
|