@stigmer/server 3.28.0 → 3.29.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/dist/authorization/model/data/authorization-model.json +27 -0
- package/dist/extensions/list-read-scope.d.ts +2 -1
- package/dist/extensions/list-read-scope.d.ts.map +1 -1
- package/dist/extensions/list-read-scope.js.map +1 -1
- package/package.json +6 -6
- package/src/authorization/__tests__/store-tests.test.ts +1 -0
- package/src/authorization/model/__tests__/registry.test.ts +244 -2
- package/src/authorization/model/data/authorization-model.json +27 -0
- package/src/extensions/list-read-scope.ts +2 -1
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"can_grant_access": {},
|
|
11
11
|
"can_impersonate": {},
|
|
12
12
|
"can_issue_license": {},
|
|
13
|
+
"can_manage_credits": {},
|
|
13
14
|
"can_manage_cursor_accounts": {},
|
|
14
15
|
"can_manage_identity_accounts": {},
|
|
15
16
|
"can_manage_model_pricing": {},
|
|
@@ -17,6 +18,13 @@
|
|
|
17
18
|
"can_view_access": {},
|
|
18
19
|
"can_view_provider_standing": {},
|
|
19
20
|
"can_write_reserved_labels": {},
|
|
21
|
+
"credit_issuer": {
|
|
22
|
+
"directly_related_user_types": [
|
|
23
|
+
{
|
|
24
|
+
"type": "identity_account"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
},
|
|
20
28
|
"operator": {
|
|
21
29
|
"directly_related_user_types": [
|
|
22
30
|
{
|
|
@@ -55,6 +63,22 @@
|
|
|
55
63
|
"relation": "operator"
|
|
56
64
|
}
|
|
57
65
|
},
|
|
66
|
+
"can_manage_credits": {
|
|
67
|
+
"union": {
|
|
68
|
+
"child": [
|
|
69
|
+
{
|
|
70
|
+
"computedUserset": {
|
|
71
|
+
"relation": "operator"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"computedUserset": {
|
|
76
|
+
"relation": "credit_issuer"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
},
|
|
58
82
|
"can_manage_cursor_accounts": {
|
|
59
83
|
"computedUserset": {
|
|
60
84
|
"relation": "operator"
|
|
@@ -90,6 +114,9 @@
|
|
|
90
114
|
"relation": "operator"
|
|
91
115
|
}
|
|
92
116
|
},
|
|
117
|
+
"credit_issuer": {
|
|
118
|
+
"this": {}
|
|
119
|
+
},
|
|
93
120
|
"operator": {
|
|
94
121
|
"this": {}
|
|
95
122
|
}
|
|
@@ -99,7 +99,8 @@
|
|
|
99
99
|
* with. A driver MAY answer the parent's question in place of the
|
|
100
100
|
* child's: by the model's construction the two answers are identical
|
|
101
101
|
* (the child's `owner`, `viewer` and `can_view` are all `from` the
|
|
102
|
-
* parent
|
|
102
|
+
* parent; authorization/model/__tests__/registry.test.ts holds that
|
|
103
|
+
* for every such kind), and the parent's resolution is direct tuples where the
|
|
103
104
|
* child's walks the parent's whole set (OpenFGA's learned `weight2`
|
|
104
105
|
* strategy read the founder's 423 sessions per execution; that is what
|
|
105
106
|
* rolled back twice on 2026-09-14). A driver that ignores the field is
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"list-read-scope.d.ts","sourceRoot":"","sources":["../../src/extensions/list-read-scope.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"list-read-scope.d.ts","sourceRoot":"","sources":["../../src/extensions/list-read-scope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4HG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qFAAqF,CAAC;AAC3H,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wDAAwD,CAAC;AACpG,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0DAA0D,CAAC;AAajG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EACV,kBAAkB,EAClB,qBAAqB,EACtB,MAAM,6BAA6B,CAAC;AAErC;;;;;;GAMG;AACH,MAAM,WAAW,aAAc,SAAQ,qBAAqB;IAC1D,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAClD;;;;;;;;;OASG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,CAAC;CACnD;AAED,kFAAkF;AAClF,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;;;;;OAaG;IACH,qBAAqB,CACnB,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC;IAEhC;;;;;;;;;;;;OAYG;IACH,mBAAmB,CACjB,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,eAAe,EACrB,OAAO,EAAE,aAAa,CAAC,aAAa,CAAC,GACpC,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC;CACjC;AAED;;;;;;;;;;GAUG;AACH,qBAAa,0BAA2B,SAAQ,KAAK;IACnD,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;gBAEnB,IAAI,EAAE,eAAe;CAOlC;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE;QACT,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAChC,UAAU,CAAC,EAAE,qBAAqB,CAAC;KACpC,CAAC;IACF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,gBAAgB,CAAA;KAAE,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAsB,uBAAuB,CAAC,CAAC,SAAS,kBAAkB,EACxE,KAAK,EAAE,aAAa,GAAG,SAAS,EAChC,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,eAAe,EACrB,SAAS,EAAE,aAAa,CAAC,CAAC,CAAC,EAC3B,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,CAAC,EAAE,CAAC,CA2Bd"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"list-read-scope.js","sourceRoot":"","sources":["../../src/extensions/list-read-scope.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"list-read-scope.js","sourceRoot":"","sources":["../../src/extensions/list-read-scope.ts"],"names":[],"mappings":"AAiIA,sEAAsE;AACtE,uEAAuE;AACvE,yEAAyE;AACzE,yEAAyE;AACzE,sBAAsB;AACtB,OAAO,EACL,8BAA8B,EAC9B,YAAY,GACb,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,0CAA0C,CAAC;AAuEnF;;;;;;;;;;GAUG;AACH,MAAM,OAAO,0BAA2B,SAAQ,KAAK;IAC1C,IAAI,CAAkB;IAE/B,YAAY,IAAqB;QAC/B,KAAK,CACH,qEAAqE,YAAY,CAAC,IAAI,CAAC,sGAAsG,CAC9L,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,4BAA4B,CAAC;QACzC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAqBD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,KAAgC,EAChC,MAAsB,EACtB,IAAqB,EACrB,SAA2B,EAC3B,UAAkB;IAElB,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,OAAO,CAAC,GAAG,SAAS,CAAC,CAAC;IACxB,CAAC;IACD,MAAM,OAAO,GACX,UAAU,KAAK,EAAE;QACf,CAAC,CAAC,SAAS;QACX,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,EAAE,GAAG,KAAK,UAAU,CAAC,CAAC;IAC5E,sEAAsE;IACtE,wEAAwE;IACxE,qEAAqE;IACrE,IAAI,MAAM,CAAC,WAAW,KAAK,UAAU,EAAE,CAAC;QACtC,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC;IACtB,CAAC;IACD,MAAM,QAAQ,GAAG,yBAAyB,CAAC,IAAI,CAAC,CAAC;IACjD,MAAM,OAAO,GAAoB,OAAO,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE;QACxD,MAAM,KAAK,GAAkB;YAC3B,GAAG,uBAAuB,CAAC,IAAI,EAAE,QAAQ,CAAC;YAC1C,MAAM,EAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE;SACxC,CAAC;QACF,MAAM,mBAAmB,GAAG,QAAQ,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;QACxD,OAAO,mBAAmB,KAAK,SAAS;YACtC,CAAC,CAAC,KAAK;YACP,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,mBAAmB,EAAE,CAAC;IACxC,CAAC,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,mBAAmB,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;IACvE,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;AAChF,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,yBAAyB,CAChC,IAAqB;IAIrB,MAAM,MAAM,GAAG,8BAA8B,CAAC,IAAI,CAAC,CAAC;IACpD,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,OAAO,GAAG,EAAE,CAAC,SAAS,CAAC;IACzB,CAAC;IACD,OAAO,CAAC,WAAW,EAAE,EAAE,CACrB,WAAW,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,KAAK,MAAM,CAAC,QAAQ,CAAC,CAAC;AAClE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.29.0",
|
|
4
4
|
"description": "The Stigmer OSS control plane as a composable library: composeServer, the extension registry and the driver seams a composition builds on. The self-contained deployable of the same server is @stigmer/server-slim.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -63,11 +63,11 @@
|
|
|
63
63
|
"@connectrpc/connect-node": "^2.0.0",
|
|
64
64
|
"@kubernetes/client-node": "2.0.0",
|
|
65
65
|
"@opentelemetry/api": "^1.9.0",
|
|
66
|
-
"@stigmer/outbound": "3.
|
|
67
|
-
"@stigmer/plugin-package": "3.
|
|
68
|
-
"@stigmer/protos": "3.
|
|
69
|
-
"@stigmer/temporal-codecs": "3.
|
|
70
|
-
"@stigmer/zip-structure": "3.
|
|
66
|
+
"@stigmer/outbound": "3.29.0",
|
|
67
|
+
"@stigmer/plugin-package": "3.29.0",
|
|
68
|
+
"@stigmer/protos": "3.29.0",
|
|
69
|
+
"@stigmer/temporal-codecs": "3.29.0",
|
|
70
|
+
"@stigmer/zip-structure": "3.29.0",
|
|
71
71
|
"@temporalio/activity": "1.16.2",
|
|
72
72
|
"@temporalio/client": "1.16.2",
|
|
73
73
|
"@temporalio/common": "1.16.2",
|
|
@@ -55,6 +55,7 @@ const DOCUMENTS = [
|
|
|
55
55
|
"channel-app-owner.fga.yaml",
|
|
56
56
|
"channel-participation.fga.yaml",
|
|
57
57
|
"channel-session-visibility.fga.yaml",
|
|
58
|
+
"credit-issuer.fga.yaml",
|
|
58
59
|
"default-instance-inheritance.fga.yaml",
|
|
59
60
|
"identity-provider-administration.fga.yaml",
|
|
60
61
|
"invitation-administration.fga.yaml",
|
|
@@ -17,7 +17,14 @@
|
|
|
17
17
|
* step and the model cannot disagree about where a team may be
|
|
18
18
|
* granted;
|
|
19
19
|
* - the derived rules are exactly the three relations `kind_meta` cannot
|
|
20
|
-
* derive
|
|
20
|
+
* derive;
|
|
21
|
+
* - a kind whose authorization is its parent's whole
|
|
22
|
+
* (`inheritedAuthorizationParentOf`) holds nothing of its own: its one
|
|
23
|
+
* direct relation is the parent link, every other relation is built
|
|
24
|
+
* through it, and its `can_view` answers exactly what the parent's
|
|
25
|
+
* does, so the list read scope may ask the parent in the child's place
|
|
26
|
+
* (extensions/list-read-scope.ts) without hiding or showing a row the
|
|
27
|
+
* child's own check would not.
|
|
21
28
|
*
|
|
22
29
|
* Whether the model defines what the wire asks is the whole contract's
|
|
23
30
|
* question, pinned in __tests__/wire-permissions.test.ts.
|
|
@@ -39,12 +46,21 @@ import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
|
39
46
|
import {
|
|
40
47
|
getKindMeta,
|
|
41
48
|
grantableRolesFor,
|
|
49
|
+
inheritedAuthorizationParentOf,
|
|
42
50
|
kindEnumName,
|
|
43
51
|
teamGrantableRolesFor,
|
|
44
52
|
} from "../../../pipeline/apiresource-meta.js";
|
|
53
|
+
import {
|
|
54
|
+
computed,
|
|
55
|
+
direct,
|
|
56
|
+
from,
|
|
57
|
+
objectOf,
|
|
58
|
+
throwawayDeclaration,
|
|
59
|
+
union,
|
|
60
|
+
} from "../../__tests__/throwaway-model.js";
|
|
45
61
|
import { KIND_BINDINGS, ROWLESS } from "../bindings.js";
|
|
46
62
|
import { builtInModel, declarationFor } from "../index.js";
|
|
47
|
-
import type { Rewrite, SubjectType } from "../rewrite.js";
|
|
63
|
+
import type { KindDeclaration, Rewrite, SubjectType } from "../rewrite.js";
|
|
48
64
|
|
|
49
65
|
/** The server package root, where fga/model lives. */
|
|
50
66
|
const PACKAGE_ROOT = new URL("../../../../", import.meta.url);
|
|
@@ -93,6 +109,180 @@ function directSubjects(rewrite: Rewrite): ReadonlyArray<SubjectType> {
|
|
|
93
109
|
}
|
|
94
110
|
}
|
|
95
111
|
|
|
112
|
+
/** The relation every list lane reads through, on the child and on its parent. */
|
|
113
|
+
const LIST_RELATION = "can_view";
|
|
114
|
+
|
|
115
|
+
/** The `this`, `computed` and `from` nodes a rewrite is built from, in the line's order. */
|
|
116
|
+
function leavesOf(rewrite: Rewrite): ReadonlyArray<Rewrite> {
|
|
117
|
+
switch (rewrite.node) {
|
|
118
|
+
case "this":
|
|
119
|
+
case "computed":
|
|
120
|
+
case "from":
|
|
121
|
+
return [rewrite];
|
|
122
|
+
case "union":
|
|
123
|
+
case "intersection":
|
|
124
|
+
return rewrite.members.flatMap(leavesOf);
|
|
125
|
+
default: {
|
|
126
|
+
const exhaustive: never = rewrite;
|
|
127
|
+
throw new Error(`unknown rewrite node: ${JSON.stringify(exhaustive)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The relations of `declaration` that `relation` includes whole: itself,
|
|
134
|
+
* and every sibling a union member or a computed node names, transitively.
|
|
135
|
+
* An intersection includes none of its members whole, so it stops here.
|
|
136
|
+
*/
|
|
137
|
+
function includedWhole(declaration: KindDeclaration, relation: string): ReadonlySet<string> {
|
|
138
|
+
const included = new Set<string>();
|
|
139
|
+
const visit = (name: string): void => {
|
|
140
|
+
if (included.has(name)) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
included.add(name);
|
|
144
|
+
const walk = (rewrite: Rewrite | undefined): void => {
|
|
145
|
+
if (rewrite?.node === "computed") {
|
|
146
|
+
visit(rewrite.relation);
|
|
147
|
+
} else if (rewrite?.node === "union") {
|
|
148
|
+
rewrite.members.forEach(walk);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
walk(declaration.relations.get(name));
|
|
152
|
+
};
|
|
153
|
+
visit(relation);
|
|
154
|
+
return included;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The parent relations `relation` of `child` reaches through `link`,
|
|
159
|
+
* following the child's own computed relations to their leaves.
|
|
160
|
+
*/
|
|
161
|
+
function parentRelationsReached(
|
|
162
|
+
child: KindDeclaration,
|
|
163
|
+
relation: string,
|
|
164
|
+
link: string,
|
|
165
|
+
): ReadonlySet<string> {
|
|
166
|
+
const reached = new Set<string>();
|
|
167
|
+
const seen = new Set<string>();
|
|
168
|
+
const visit = (name: string): void => {
|
|
169
|
+
if (seen.has(name)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
seen.add(name);
|
|
173
|
+
const rewrite = child.relations.get(name);
|
|
174
|
+
for (const leaf of rewrite === undefined ? [] : leavesOf(rewrite)) {
|
|
175
|
+
if (leaf.node === "computed") {
|
|
176
|
+
visit(leaf.relation);
|
|
177
|
+
} else if (leaf.node === "from" && leaf.tupleset === link) {
|
|
178
|
+
reached.add(leaf.relation);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
visit(relation);
|
|
183
|
+
return reached;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Every way `child` breaks the promise its `kind_meta` makes when it says
|
|
188
|
+
* the kind's authorization is its parent's whole (PARENT scope, INHERITED
|
|
189
|
+
* owner); empty when the model keeps it. The list read scope reads that
|
|
190
|
+
* promise to let a driver ask the parent's `can_view` in the child's
|
|
191
|
+
* place, which gives the child's answer only while:
|
|
192
|
+
* - the child's one direct relation is the link, to the parent type
|
|
193
|
+
* alone, so nothing is granted on the child that the parent does not
|
|
194
|
+
* hold;
|
|
195
|
+
* - every other relation is built through the link or from the child's
|
|
196
|
+
* own relations, never from another object;
|
|
197
|
+
* - the child's `can_view` includes the parent's (`can_view from <link>`
|
|
198
|
+
* as a union member), so asking the parent hides nothing;
|
|
199
|
+
* - every parent relation the child's `can_view` reaches is one the
|
|
200
|
+
* parent's `can_view` includes whole, so asking the parent shows
|
|
201
|
+
* nothing more.
|
|
202
|
+
* The last reads inclusion structurally (union and computed), so it errs
|
|
203
|
+
* toward a violation: a shape it cannot prove is a design review, never a
|
|
204
|
+
* pass.
|
|
205
|
+
*/
|
|
206
|
+
function inheritedWholeViolations(
|
|
207
|
+
child: KindDeclaration,
|
|
208
|
+
parent: KindDeclaration,
|
|
209
|
+
link: string,
|
|
210
|
+
): ReadonlyArray<string> {
|
|
211
|
+
const violations: string[] = [];
|
|
212
|
+
const linkRewrite = child.relations.get(link);
|
|
213
|
+
const [linked, ...more] = linkRewrite?.node === "this" ? linkRewrite.subjects : [];
|
|
214
|
+
if (linked?.form !== "object" || linked.type !== parent.type || more.length > 0) {
|
|
215
|
+
violations.push(`${child.type}#${link} is not the direct link [${parent.type}] alone`);
|
|
216
|
+
}
|
|
217
|
+
for (const [name, rewrite] of child.relations) {
|
|
218
|
+
if (name === link) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
for (const leaf of leavesOf(rewrite)) {
|
|
222
|
+
if (leaf.node === "this") {
|
|
223
|
+
violations.push(`${child.type}#${name} admits a tuple on the child itself`);
|
|
224
|
+
} else if (leaf.node === "from" && leaf.tupleset !== link) {
|
|
225
|
+
violations.push(
|
|
226
|
+
`${child.type}#${name} reads ${leaf.relation} from ${leaf.tupleset}, not from ${link}`,
|
|
227
|
+
);
|
|
228
|
+
} else if (leaf.node === "computed" && !child.relations.has(leaf.relation)) {
|
|
229
|
+
violations.push(
|
|
230
|
+
`${child.type}#${name} names ${leaf.relation}, which the child does not declare`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const childView = child.relations.get(LIST_RELATION);
|
|
236
|
+
if (childView === undefined || !parent.relations.has(LIST_RELATION)) {
|
|
237
|
+
violations.push(`${child.type} and ${parent.type} must both declare ${LIST_RELATION}`);
|
|
238
|
+
return violations;
|
|
239
|
+
}
|
|
240
|
+
const members = childView.node === "union" ? childView.members : [childView];
|
|
241
|
+
if (
|
|
242
|
+
!members.some(
|
|
243
|
+
(member) =>
|
|
244
|
+
member.node === "from" && member.relation === LIST_RELATION && member.tupleset === link,
|
|
245
|
+
)
|
|
246
|
+
) {
|
|
247
|
+
violations.push(
|
|
248
|
+
`${child.type}#${LIST_RELATION} does not include ${LIST_RELATION} from ${link}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const parentView = includedWhole(parent, LIST_RELATION);
|
|
252
|
+
for (const relation of parentRelationsReached(child, LIST_RELATION, link)) {
|
|
253
|
+
if (!parentView.has(relation)) {
|
|
254
|
+
violations.push(
|
|
255
|
+
`${child.type}#${LIST_RELATION} reaches ${parent.type}#${relation}, which ${parent.type}#${LIST_RELATION} does not include`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return violations;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A declaration the model must hold, or a loud failure naming it. */
|
|
263
|
+
function declared(declaration: KindDeclaration | undefined, type: string): KindDeclaration {
|
|
264
|
+
if (declaration === undefined) {
|
|
265
|
+
throw new Error(`${type} is not in the built-in model`);
|
|
266
|
+
}
|
|
267
|
+
return declaration;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The kinds whose `kind_meta` makes their authorization their parent's whole, with that parent. */
|
|
271
|
+
function inheritedWholeKinds(): ReadonlyArray<{
|
|
272
|
+
readonly kind: ApiResourceKind;
|
|
273
|
+
readonly parentType: string;
|
|
274
|
+
readonly link: string;
|
|
275
|
+
}> {
|
|
276
|
+
return ApiResourceKindSchema.values.flatMap((value) => {
|
|
277
|
+
const kind = value.number as ApiResourceKind;
|
|
278
|
+
if (kind === ApiResourceKind.api_resource_kind_unknown) {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
const parent = inheritedAuthorizationParentOf(kind);
|
|
282
|
+
return parent === undefined ? [] : [{ kind, parentType: parent.kind, link: parent.relation }];
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
96
286
|
function isTeamMembers(subject: SubjectType): boolean {
|
|
97
287
|
return subject.form === "userset" && subject.type === "team" && subject.relation === "member";
|
|
98
288
|
}
|
|
@@ -202,4 +392,56 @@ describe("the built-in model", () => {
|
|
|
202
392
|
"workflow_instance#execution_viewer",
|
|
203
393
|
]);
|
|
204
394
|
});
|
|
395
|
+
|
|
396
|
+
it("gives a kind whose authorization is its parent's nothing of its own, so asking the parent answers for it", () => {
|
|
397
|
+
const inherited = inheritedWholeKinds();
|
|
398
|
+
expect(inherited.length, "kinds whose authorization is their parent's").toBeGreaterThan(0);
|
|
399
|
+
for (const { kind, parentType, link } of inherited) {
|
|
400
|
+
const child = declared(declarationFor(kind), kindEnumName(kind));
|
|
401
|
+
const parent = declared(builtInModel.byType(parentType), parentType);
|
|
402
|
+
expect(inheritedWholeViolations(child, parent, link), kindEnumName(kind)).toEqual([]);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("the parent-inheritance walker refuses what would make asking the parent wrong", () => {
|
|
408
|
+
const execution = declared(declarationFor(ApiResourceKind.agent_execution), "agent_execution");
|
|
409
|
+
const session = declared(builtInModel.byType("session"), "session");
|
|
410
|
+
|
|
411
|
+
/** agent_execution as the model has it, with one line rewritten. */
|
|
412
|
+
function executionWith(relation: string, rewrite: Rewrite): KindDeclaration {
|
|
413
|
+
return throwawayDeclaration({
|
|
414
|
+
kind: ApiResourceKind.agent_execution,
|
|
415
|
+
relations: [...execution.relations].map(([name, current]) =>
|
|
416
|
+
name === relation ? ([name, rewrite] as const) : ([name, current] as const),
|
|
417
|
+
),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
it.each([
|
|
422
|
+
{
|
|
423
|
+
shape: "a viewer granted on the execution itself",
|
|
424
|
+
child: executionWith(
|
|
425
|
+
"viewer",
|
|
426
|
+
union(direct(objectOf("identity_account")), from("viewer", "session"), computed("owner")),
|
|
427
|
+
),
|
|
428
|
+
violation: "agent_execution#viewer admits a tuple on the child itself",
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
shape: "a can_view that no longer includes the session's",
|
|
432
|
+
child: executionWith("can_view", computed("viewer")),
|
|
433
|
+
violation: "agent_execution#can_view does not include can_view from session",
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
shape: "a can_view reaching a session relation its can_view does not include",
|
|
437
|
+
child: executionWith(
|
|
438
|
+
"can_view",
|
|
439
|
+
union(computed("viewer"), from("can_view", "session"), from("can_delete", "session")),
|
|
440
|
+
),
|
|
441
|
+
violation:
|
|
442
|
+
"agent_execution#can_view reaches session#can_delete, which session#can_view does not include",
|
|
443
|
+
},
|
|
444
|
+
])("$shape", ({ child, violation }) => {
|
|
445
|
+
expect(inheritedWholeViolations(child, session, "session")).toEqual([violation]);
|
|
446
|
+
});
|
|
205
447
|
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"can_grant_access": {},
|
|
11
11
|
"can_impersonate": {},
|
|
12
12
|
"can_issue_license": {},
|
|
13
|
+
"can_manage_credits": {},
|
|
13
14
|
"can_manage_cursor_accounts": {},
|
|
14
15
|
"can_manage_identity_accounts": {},
|
|
15
16
|
"can_manage_model_pricing": {},
|
|
@@ -17,6 +18,13 @@
|
|
|
17
18
|
"can_view_access": {},
|
|
18
19
|
"can_view_provider_standing": {},
|
|
19
20
|
"can_write_reserved_labels": {},
|
|
21
|
+
"credit_issuer": {
|
|
22
|
+
"directly_related_user_types": [
|
|
23
|
+
{
|
|
24
|
+
"type": "identity_account"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
},
|
|
20
28
|
"operator": {
|
|
21
29
|
"directly_related_user_types": [
|
|
22
30
|
{
|
|
@@ -55,6 +63,22 @@
|
|
|
55
63
|
"relation": "operator"
|
|
56
64
|
}
|
|
57
65
|
},
|
|
66
|
+
"can_manage_credits": {
|
|
67
|
+
"union": {
|
|
68
|
+
"child": [
|
|
69
|
+
{
|
|
70
|
+
"computedUserset": {
|
|
71
|
+
"relation": "operator"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"computedUserset": {
|
|
76
|
+
"relation": "credit_issuer"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
},
|
|
58
82
|
"can_manage_cursor_accounts": {
|
|
59
83
|
"computedUserset": {
|
|
60
84
|
"relation": "operator"
|
|
@@ -90,6 +114,9 @@
|
|
|
90
114
|
"relation": "operator"
|
|
91
115
|
}
|
|
92
116
|
},
|
|
117
|
+
"credit_issuer": {
|
|
118
|
+
"this": {}
|
|
119
|
+
},
|
|
93
120
|
"operator": {
|
|
94
121
|
"this": {}
|
|
95
122
|
}
|
|
@@ -99,7 +99,8 @@
|
|
|
99
99
|
* with. A driver MAY answer the parent's question in place of the
|
|
100
100
|
* child's: by the model's construction the two answers are identical
|
|
101
101
|
* (the child's `owner`, `viewer` and `can_view` are all `from` the
|
|
102
|
-
* parent
|
|
102
|
+
* parent; authorization/model/__tests__/registry.test.ts holds that
|
|
103
|
+
* for every such kind), and the parent's resolution is direct tuples where the
|
|
103
104
|
* child's walks the parent's whole set (OpenFGA's learned `weight2`
|
|
104
105
|
* strategy read the founder's 423 sessions per execution; that is what
|
|
105
106
|
* rolled back twice on 2026-09-14). A driver that ignores the field is
|