@astrale-domains/shell 0.1.7 → 0.1.9
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/domain.d.ts +54 -25
- package/dist/domain.js +21 -1
- package/dist/functions/authority/roles.js +58 -35
- package/dist/functions/group/assign-member.js +6 -3
- package/dist/functions/group/change-group-membership.js +92 -39
- package/dist/functions/group/group-membership-errors.js +1 -1
- package/dist/functions/group/index.js +2 -2
- package/dist/functions/group/legacy/index.js +1 -1
- package/dist/functions/group/legacy/membership.js +21 -17
- package/dist/functions/group/membership-laws.js +23 -28
- package/dist/functions/group/unassign-member.js +6 -3
- package/dist/functions/invitation/send-invitation.js +57 -7
- package/dist/functions/view-access/index.js +2 -2
- package/dist/functions/view-access/legacy/index.js +1 -0
- package/dist/functions/view-access/legacy/migrate-view-access.js +29 -0
- package/dist/mutations/group/index.js +1 -1
- package/dist/mutations/group/set-group-membership.js +45 -13
- package/dist/mutations/invitation/write-invitation.js +19 -2
- package/dist/mutations/view-access/legacy/index.js +1 -0
- package/dist/mutations/view-access/legacy/move-view-access.js +26 -0
- package/dist/mutations/view-access/set-view-access.js +1 -1
- package/dist/queries/composition/index.js +1 -1
- package/dist/queries/composition/read-role-nodes.js +48 -13
- package/dist/queries/group/index.js +1 -0
- package/dist/queries/group/legacy/index.js +1 -0
- package/dist/queries/group/legacy/read-members-of-group.js +27 -0
- package/dist/queries/group/read-group-members.js +73 -0
- package/dist/queries/invitation/index.js +1 -1
- package/dist/queries/invitation/read-invitation.js +33 -8
- package/dist/queries/role/index.js +1 -1
- package/dist/queries/role/read-role-node.js +8 -0
- package/dist/queries/view-access/legacy/index.js +1 -0
- package/dist/queries/view-access/legacy/read-view-access-to-move.js +40 -0
- package/dist/queries/view-access/read-view-access.js +1 -1
- package/dist/runtime.d.ts +3930 -924
- package/dist/runtime.js +6 -3
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +2 -1
- package/dist/schema/legacy/schema.js +5 -3
- package/dist/schema/modules/application/legacy/functions/list-entrypoints.js +5 -4
- package/dist/schema/modules/group/classes/group-profile-link.d.ts +1 -1
- package/dist/schema/modules/group/classes/group.d.ts +14 -0
- package/dist/schema/modules/group/classes/group.js +12 -2
- package/dist/schema/modules/group/functions/assign-members.js +17 -0
- package/dist/schema/modules/group/functions/index.js +2 -0
- package/dist/schema/modules/group/functions/unassign-members.js +17 -0
- package/dist/schema/modules/group/types/group-membership-input.js +8 -0
- package/dist/schema/modules/group/types/index.js +1 -1
- package/dist/schema/modules/invitation/classes/invitation.js +4 -2
- package/dist/schema/modules/role/core/roles.d.ts +2 -0
- package/dist/schema/modules/role/core/roles.js +1 -0
- package/dist/schema/modules/view-access/index.d.ts +1 -2
- package/dist/schema/modules/view-access/index.js +2 -2
- package/dist/schema/modules/view-access/legacy/classes/index.d.ts +1 -0
- package/dist/schema/modules/view-access/legacy/classes/index.js +1 -0
- package/dist/schema/modules/view-access/{classes → legacy/classes}/view-access.d.ts +7 -5
- package/dist/schema/modules/view-access/legacy/classes/view-access.js +25 -0
- package/dist/schema/modules/view-access/legacy/functions/assign-views.d.ts +18 -0
- package/dist/schema/modules/view-access/legacy/functions/index.d.ts +2 -0
- package/dist/schema/modules/view-access/legacy/functions/index.js +1 -0
- package/dist/schema/modules/view-access/legacy/functions/migrate-view-access.d.ts +13 -0
- package/dist/schema/modules/view-access/legacy/functions/migrate-view-access.js +22 -0
- package/dist/schema/modules/view-access/legacy/index.d.ts +4 -0
- package/dist/schema/modules/view-access/legacy/index.js +3 -1
- package/dist/schema/modules/view-access/legacy/policies/holds-view-access.d.ts +5 -0
- package/dist/schema/modules/view-access/legacy/policies/holds-view-access.js +8 -0
- package/dist/schema/modules/view-access/legacy/policies/index.d.ts +1 -0
- package/dist/schema/modules/view-access/legacy/policies/index.js +1 -0
- package/dist/schema/modules/view-access/legacy/view-assignment.d.ts +21 -0
- package/dist/schema/schema.d.ts +207 -99
- package/dist/schema/schema.js +1 -4
- package/package.json +2 -2
- package/dist/schema/modules/view-access/classes/index.d.ts +0 -1
- package/dist/schema/modules/view-access/classes/index.js +0 -1
- package/dist/schema/modules/view-access/classes/view-access.js +0 -23
- package/dist/schema/modules/view-access/policies/holds-view-access.d.ts +0 -2
- package/dist/schema/modules/view-access/policies/holds-view-access.js +0 -5
- package/dist/schema/modules/view-access/policies/index.d.ts +0 -1
- package/dist/schema/modules/view-access/policies/index.js +0 -1
package/dist/domain.d.ts
CHANGED
|
@@ -35,6 +35,28 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
|
|
|
35
35
|
readonly constraints: Record<never, never>;
|
|
36
36
|
readonly effectiveProperties: never;
|
|
37
37
|
}>;
|
|
38
|
+
readonly view_access: import("@astrale-os/sdk/schema").DirectedEdgeClass<{
|
|
39
|
+
readonly depth: 0;
|
|
40
|
+
readonly abstract: false;
|
|
41
|
+
readonly extends: readonly [];
|
|
42
|
+
readonly properties: never;
|
|
43
|
+
readonly source: {
|
|
44
|
+
readonly as: "audience";
|
|
45
|
+
readonly accepts: readonly (import("@astrale-os/sdk/schema").ClassRef<"Identity"> & {
|
|
46
|
+
readonly origin: "kernel.astrale.ai";
|
|
47
|
+
})[];
|
|
48
|
+
readonly outgoing: "0..*";
|
|
49
|
+
};
|
|
50
|
+
readonly target: {
|
|
51
|
+
readonly as: "view";
|
|
52
|
+
readonly accepts: readonly (import("@astrale-os/sdk/schema").ClassRef<"View"> & {
|
|
53
|
+
readonly origin: "kernel.astrale.ai";
|
|
54
|
+
})[];
|
|
55
|
+
readonly incoming: "0..*";
|
|
56
|
+
};
|
|
57
|
+
readonly constraints: Record<never, never>;
|
|
58
|
+
readonly effectiveProperties: never;
|
|
59
|
+
}>;
|
|
38
60
|
readonly User: import("@astrale-os/sdk/schema").AuthoredNodeClass<{
|
|
39
61
|
readonly icon: import("@astrale-os/sdk/schema").ClassIcon;
|
|
40
62
|
readonly inheritance: "open";
|
|
@@ -326,6 +348,20 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
|
|
|
326
348
|
readonly mode: 'value';
|
|
327
349
|
readonly value: Record<string, never>;
|
|
328
350
|
}, "authorized", true, false, undefined, undefined>>;
|
|
351
|
+
readonly assignMembers: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
|
|
352
|
+
members: string[];
|
|
353
|
+
group: string;
|
|
354
|
+
}, {
|
|
355
|
+
readonly mode: 'value';
|
|
356
|
+
readonly value: Record<string, never>;
|
|
357
|
+
}, "authorized", true, false, undefined, undefined>>;
|
|
358
|
+
readonly unassignMembers: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
|
|
359
|
+
members: string[];
|
|
360
|
+
group: string;
|
|
361
|
+
}, {
|
|
362
|
+
readonly mode: 'value';
|
|
363
|
+
readonly value: Record<string, never>;
|
|
364
|
+
}, "authorized", true, false, undefined, undefined>>;
|
|
329
365
|
readonly provision: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
|
|
330
366
|
directory: string;
|
|
331
367
|
externalId: string;
|
|
@@ -369,7 +405,7 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
|
|
|
369
405
|
readonly abstract: false;
|
|
370
406
|
readonly depth: 1;
|
|
371
407
|
readonly properties: "attributes" | "description" | "directory" | "externalId" | "frozen" | "icon" | "iss" | "name" | "sub";
|
|
372
|
-
readonly methods: "assignMember" | "create" | "delete" | "deprovision" | "provision" | "unassignMember" | "update";
|
|
408
|
+
readonly methods: "assignMember" | "assignMembers" | "create" | "delete" | "deprovision" | "provision" | "unassignMember" | "unassignMembers" | "update";
|
|
373
409
|
}[];
|
|
374
410
|
readonly outgoing: "0..1";
|
|
375
411
|
};
|
|
@@ -427,6 +463,13 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
|
|
|
427
463
|
readonly description?: string;
|
|
428
464
|
readonly immutable?: import("@astrale-os/sdk/schema").PropertyImmutability;
|
|
429
465
|
}>;
|
|
466
|
+
email: import("@astrale-os/sdk/schema").AuthoredProperty<{
|
|
467
|
+
readonly type: "string";
|
|
468
|
+
readonly minLength: 3;
|
|
469
|
+
readonly maxLength: 320;
|
|
470
|
+
}, {
|
|
471
|
+
readonly required: false;
|
|
472
|
+
}>;
|
|
430
473
|
};
|
|
431
474
|
readonly policies: {
|
|
432
475
|
readonly read: ({ check, ref }: import("@astrale-os/sdk/schema").ClassRuleContext) => import("@astrale-os/sdk/schema").PolicyCheckExpression<import("@astrale-os/sdk/schema").PolicyRef, import("@astrale-os/sdk/schema").ClassRuleObject>;
|
|
@@ -471,7 +514,7 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
|
|
|
471
514
|
readonly kind: 'node';
|
|
472
515
|
readonly abstract: false;
|
|
473
516
|
readonly depth: 0;
|
|
474
|
-
readonly properties: "execution" | "expiresAt" | "round" | "status";
|
|
517
|
+
readonly properties: "email" | "execution" | "expiresAt" | "round" | "status";
|
|
475
518
|
readonly methods: "accept" | "cancel" | "resend" | "send";
|
|
476
519
|
}[];
|
|
477
520
|
readonly outgoing: "0..1";
|
|
@@ -557,28 +600,6 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
|
|
|
557
600
|
readonly constraints: Record<never, never>;
|
|
558
601
|
readonly effectiveProperties: never;
|
|
559
602
|
}>;
|
|
560
|
-
readonly view_access: import("@astrale-os/sdk/schema").DirectedEdgeClass<{
|
|
561
|
-
readonly depth: 0;
|
|
562
|
-
readonly abstract: false;
|
|
563
|
-
readonly extends: readonly [];
|
|
564
|
-
readonly properties: never;
|
|
565
|
-
readonly source: {
|
|
566
|
-
readonly as: "audience";
|
|
567
|
-
readonly accepts: readonly (import("@astrale-os/sdk/schema").ClassRef<"Identity"> & {
|
|
568
|
-
readonly origin: "kernel.astrale.ai";
|
|
569
|
-
})[];
|
|
570
|
-
readonly outgoing: "0..*";
|
|
571
|
-
};
|
|
572
|
-
readonly target: {
|
|
573
|
-
readonly as: "view";
|
|
574
|
-
readonly accepts: readonly (import("@astrale-os/sdk/schema").ClassRef<"View"> & {
|
|
575
|
-
readonly origin: "kernel.astrale.ai";
|
|
576
|
-
})[];
|
|
577
|
-
readonly incoming: "0..*";
|
|
578
|
-
};
|
|
579
|
-
readonly constraints: Record<never, never>;
|
|
580
|
-
readonly effectiveProperties: never;
|
|
581
|
-
}>;
|
|
582
603
|
};
|
|
583
604
|
readonly functions: {
|
|
584
605
|
readonly listEntrypoints: import("@astrale-os/sdk/schema").Function<import("@astrale-os/sdk/schema").CallableProjection<Record<string, never>, {
|
|
@@ -612,6 +633,13 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
|
|
|
612
633
|
profiles: number;
|
|
613
634
|
};
|
|
614
635
|
}, "authorized", false, false, undefined, undefined>>;
|
|
636
|
+
readonly legacyMigrateViewAccess: import("@astrale-os/sdk/schema").Function<import("@astrale-os/sdk/schema").CallableProjection<Record<string, never>, {
|
|
637
|
+
readonly mode: 'value';
|
|
638
|
+
readonly value: {
|
|
639
|
+
copied: number;
|
|
640
|
+
removed: number;
|
|
641
|
+
};
|
|
642
|
+
}, "authorized", false, false, undefined, undefined>>;
|
|
615
643
|
readonly disconnectDirectory: import("@astrale-os/sdk/schema").Function<import("@astrale-os/sdk/schema").CallableProjection<{
|
|
616
644
|
directory: string;
|
|
617
645
|
}, {
|
|
@@ -629,8 +657,8 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
|
|
|
629
657
|
readonly usesFunction: import("@astrale-os/sdk/schema").Policy;
|
|
630
658
|
readonly isSelf: import("@astrale-os/sdk/schema").Policy;
|
|
631
659
|
readonly legacyOwnsMembershipGroup: import("@astrale-os/sdk/schema").Policy;
|
|
632
|
-
readonly hasRole: import("@astrale-os/sdk/schema").Policy;
|
|
633
660
|
readonly holdsViewAccess: import("@astrale-os/sdk/schema").Policy;
|
|
661
|
+
readonly hasRole: import("@astrale-os/sdk/schema").Policy;
|
|
634
662
|
readonly describesSubject: import("@astrale-os/sdk/schema").Policy;
|
|
635
663
|
readonly isProfileSource: import("@astrale-os/sdk/schema").Policy;
|
|
636
664
|
readonly describesGroupOfSubject: import("@astrale-os/sdk/schema").Policy;
|
|
@@ -651,6 +679,7 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
|
|
|
651
679
|
readonly 'user-profile.reader': import("@astrale-os/sdk/schema").CoreNode;
|
|
652
680
|
readonly 'user-profile.self-writer': import("@astrale-os/sdk/schema").CoreNode;
|
|
653
681
|
readonly 'group-profile.reader': import("@astrale-os/sdk/schema").CoreNode;
|
|
682
|
+
readonly 'os.viewer': import("@astrale-os/sdk/schema").CoreNode;
|
|
654
683
|
readonly admin: import("@astrale-os/sdk/schema").CoreNode;
|
|
655
684
|
readonly member: import("@astrale-os/sdk/schema").CoreNode;
|
|
656
685
|
readonly applications: import("@astrale-os/sdk/schema").CoreNode;
|
package/dist/domain.js
CHANGED
|
@@ -63,6 +63,12 @@ const domainRequirements = requirements(legacyRelease({
|
|
|
63
63
|
members: [S.core.nodes['workspace.member']],
|
|
64
64
|
granters: [S.core.nodes['workspace.admin']],
|
|
65
65
|
},
|
|
66
|
+
// Whoever holds it opens the OS view; a member holds it, and an administrator through member.
|
|
67
|
+
{
|
|
68
|
+
role: S.core.nodes['os.viewer'],
|
|
69
|
+
members: [S.core.nodes['workspace.member']],
|
|
70
|
+
granters: [S.core.nodes['workspace.admin']],
|
|
71
|
+
},
|
|
66
72
|
// Each profile holds the Kernel profile of its rank; Kernel admin is granted by Root alone.
|
|
67
73
|
// Kernel reader is no profile's: admin grants it to whoever only opens the Views it is given
|
|
68
74
|
// (AM10).
|
|
@@ -94,7 +100,21 @@ const domainRequirements = requirements(legacyRelease({
|
|
|
94
100
|
{ role: K.core.nodes['blob.reader'], granters: [S.core.nodes['workspace.admin']] },
|
|
95
101
|
{ role: K.core.nodes['blob.writer'], granters: [S.core.nodes['workspace.admin']] },
|
|
96
102
|
{ role: K.core.nodes['blob.manager'], granters: [S.core.nodes['workspace.admin']] },
|
|
97
|
-
|
|
103
|
+
// A member reads every View, so the OS view lists every application; anyone else reads only
|
|
104
|
+
// the Views given to it by `view_access`. Independent of `os.viewer`, which only shows the OS.
|
|
105
|
+
{
|
|
106
|
+
role: K.core.nodes['view.reader'],
|
|
107
|
+
members: [S.core.nodes['workspace.member']],
|
|
108
|
+
granters: [S.core.nodes['workspace.admin']],
|
|
109
|
+
},
|
|
110
|
+
// Shell writes the Kernel's view_access for assignViews and its migration (L-SHELL-12,
|
|
111
|
+
// L-SHELL-20); an administrator writes it from the console through Kernel admin. Transition
|
|
112
|
+
// only: removed with those two Functions (step 5).
|
|
113
|
+
{
|
|
114
|
+
role: K.core.nodes['view.manager'],
|
|
115
|
+
members: [requirements.domain],
|
|
116
|
+
granters: [S.core.nodes['workspace.admin']],
|
|
117
|
+
},
|
|
98
118
|
{ role: K.core.nodes['domain.reader'], granters: [S.core.nodes['workspace.admin']] },
|
|
99
119
|
{ role: K.core.nodes['execution.reader'], granters: [S.core.nodes['workspace.admin']] },
|
|
100
120
|
{ role: K.core.nodes['journal.reader'], granters: [S.core.nodes['workspace.admin']] },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readRoleKeys } from '../../queries/composition/index.js';
|
|
2
2
|
import { readIdentities } from '../../queries/identity/index.js';
|
|
3
|
-
import { readRoleNode } from '../../queries/role/index.js';
|
|
3
|
+
import { readRoleId, readRoleNode } from '../../queries/role/index.js';
|
|
4
4
|
import { walkComposition } from './composition.js';
|
|
5
5
|
/** Kernel reader and writer and their parts: every other Kernel Role administers (U4). */
|
|
6
6
|
const KERNEL_ORDINARY = new Set([
|
|
@@ -22,12 +22,16 @@ const SHELL_ADMINISTRATIVE = new Set([
|
|
|
22
22
|
'directory.provisioner',
|
|
23
23
|
]);
|
|
24
24
|
const keyOf = (ref) => `${ref.origin}:${ref.name}`;
|
|
25
|
-
/**
|
|
25
|
+
/** Identities one Role read starts from. */
|
|
26
|
+
const ROLE_STEP = 256;
|
|
27
|
+
/**
|
|
28
|
+
* One handler's view of authority. Each composition, and the Roles it holds, is read once; only the
|
|
29
|
+
* Roles a composition reaches are read, never the catalogue of every installed Role. What the
|
|
30
|
+
* caller holds is asked once per Role too, so a handler may ask early and use the answer later.
|
|
31
|
+
*/
|
|
26
32
|
export function authority(capabilities) {
|
|
27
33
|
const S = capabilities.domain;
|
|
28
34
|
const kernel = S.dependencies.kernel.origin;
|
|
29
|
-
let catalogue;
|
|
30
|
-
const roles = () => (catalogue ??= capabilities.query(readRoleNodes, {}));
|
|
31
35
|
const compositions = new Map();
|
|
32
36
|
const composition = (identity) => {
|
|
33
37
|
let walked = compositions.get(identity);
|
|
@@ -37,32 +41,58 @@ export function authority(capabilities) {
|
|
|
37
41
|
}
|
|
38
42
|
return walked;
|
|
39
43
|
};
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
const held = new Map();
|
|
45
|
+
/** The Core keys of the Roles an Identity holds, `<origin>:<key>`. */
|
|
46
|
+
const heldKeys = (identity) => {
|
|
47
|
+
let keys = held.get(identity);
|
|
48
|
+
if (keys === undefined) {
|
|
49
|
+
keys = (async () => {
|
|
50
|
+
const reached = [...(await composition(identity)).keys()];
|
|
51
|
+
const found = [];
|
|
52
|
+
for (let index = 0; index < reached.length; index += ROLE_STEP) {
|
|
53
|
+
const [first, ...others] = reached.slice(index, index + ROLE_STEP);
|
|
54
|
+
if (first === undefined)
|
|
55
|
+
continue;
|
|
56
|
+
const roles = await capabilities.query(readRoleKeys, { identities: [first, ...others] });
|
|
57
|
+
found.push(...roles.values());
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
})();
|
|
61
|
+
held.set(identity, keys);
|
|
49
62
|
}
|
|
50
|
-
return
|
|
63
|
+
return keys;
|
|
51
64
|
};
|
|
65
|
+
const nodes = new Map();
|
|
66
|
+
const roleNode = (role) => {
|
|
67
|
+
let node = nodes.get(role);
|
|
68
|
+
if (node === undefined) {
|
|
69
|
+
node = capabilities.query(readRoleId, { role: S.core.nodes[role].ref });
|
|
70
|
+
nodes.set(role, node);
|
|
71
|
+
}
|
|
72
|
+
return node;
|
|
73
|
+
};
|
|
74
|
+
const administrative = (key) => {
|
|
75
|
+
const [origin, name] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)];
|
|
76
|
+
return ((origin === S.origin && SHELL_ADMINISTRATIVE.has(name)) ||
|
|
77
|
+
(origin === kernel && !KERNEL_ORDINARY.has(name)));
|
|
78
|
+
};
|
|
79
|
+
const callerRoles = new Map();
|
|
52
80
|
return {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
roleNode,
|
|
82
|
+
callerHolds(role) {
|
|
83
|
+
let holds = callerRoles.get(role);
|
|
84
|
+
if (holds === undefined) {
|
|
85
|
+
const caller = capabilities.graph.caller;
|
|
86
|
+
holds =
|
|
87
|
+
caller === undefined
|
|
88
|
+
? Promise.resolve(false)
|
|
89
|
+
: caller.query(readRoleNode, { role: S.core.nodes[role].ref });
|
|
90
|
+
callerRoles.set(role, holds);
|
|
91
|
+
}
|
|
92
|
+
return holds;
|
|
61
93
|
},
|
|
62
94
|
async heldRole(identity) {
|
|
63
|
-
const
|
|
64
|
-
const reached = await composition(identity);
|
|
65
|
-
const held = [...byKey].filter(([, id]) => reached.has(id)).map(([key]) => key);
|
|
95
|
+
const held = await heldKeys(identity);
|
|
66
96
|
const admin = keyOf(S.core.nodes['workspace.admin'].ref);
|
|
67
97
|
const role = held.includes(admin) ? admin : held[0];
|
|
68
98
|
if (role === undefined)
|
|
@@ -70,17 +100,10 @@ export function authority(capabilities) {
|
|
|
70
100
|
return role.startsWith(`${S.origin}:`) ? role.slice(S.origin.length + 1) : role;
|
|
71
101
|
},
|
|
72
102
|
async holdsAdministration(identity) {
|
|
73
|
-
|
|
74
|
-
for (const reached of (await composition(identity)).keys())
|
|
75
|
-
if (ids.has(reached))
|
|
76
|
-
return true;
|
|
77
|
-
return false;
|
|
78
|
-
},
|
|
79
|
-
async reaches(from, to) {
|
|
80
|
-
return from === to || (await composition(from)).has(to);
|
|
103
|
+
return (await heldKeys(identity)).some(administrative);
|
|
81
104
|
},
|
|
82
105
|
async activeAdministrators() {
|
|
83
|
-
const admin =
|
|
106
|
+
const admin = await roleNode('workspace.admin');
|
|
84
107
|
if (admin === undefined)
|
|
85
108
|
return [];
|
|
86
109
|
const holders = [...(await walkComposition(capabilities, admin, 'incoming')).keys()];
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { changeGroupMembership } from './change-group-membership.js';
|
|
1
|
+
import { defineAction } from '@astrale-os/sdk/action';
|
|
2
|
+
import { changeGroupMembers, changeGroupMembership } from './change-group-membership.js';
|
|
3
3
|
import { groupMembershipErrors } from './group-membership-errors.js';
|
|
4
|
-
|
|
4
|
+
/** Make one account or group a member of a group, in one read and one write. */
|
|
5
|
+
export const assignMember = defineAction()('Group.assignMember', { errors: groupMembershipErrors }, (context) => changeGroupMembership(context, true));
|
|
6
|
+
/** Make several accounts or groups members of a group, in one commit. */
|
|
7
|
+
export const assignMembers = defineAction()('Group.assignMembers', { errors: groupMembershipErrors }, (context) => changeGroupMembers(context, context.input, true));
|
|
@@ -1,56 +1,66 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { authority } from '../authority/index.js';
|
|
2
|
+
import { setGroupMemberships } from '../../mutations/group/index.js';
|
|
3
|
+
import { readGroupMembers, readGroupMembersEndpoints } from '../../queries/group/index.js';
|
|
4
|
+
import { readIdentities } from '../../queries/identity/index.js';
|
|
3
5
|
import { canonicalNodeId } from '../../utils/canonical-node-path/index.js';
|
|
4
6
|
import { readGroup } from './group.js';
|
|
5
7
|
// Seam L-SHELL-8, L-SHELL-9: legacy groups also keep `member_of_group`; a Domain's own Group
|
|
6
8
|
// subclass changes its members under that Domain's authority.
|
|
7
|
-
import {
|
|
9
|
+
import { legacyChangeForeignMembership, legacyMembersEdges } from './legacy/index.js';
|
|
8
10
|
import { membershipRefusal } from './membership-laws.js';
|
|
9
11
|
/**
|
|
10
|
-
* Add or withdraw
|
|
11
|
-
* group (G1), under G2
|
|
12
|
-
*
|
|
12
|
+
* Add or withdraw direct members of one Shell group: Kernel `extends_with` from each member to the
|
|
13
|
+
* group (G1), under G2 and G3, checked once for the group. The Kernel refuses a cycle. All the
|
|
14
|
+
* members change in one commit, guarded by what was observed of them; a member already where it is
|
|
15
|
+
* asked to be changes nothing, so a retry is harmless. After an unknown outcome the change is
|
|
16
|
+
* observed again, and an unconfirmed outcome stays explicit.
|
|
13
17
|
*/
|
|
14
|
-
export async function
|
|
18
|
+
export async function changeGroupMembers(context, input, present, { checkMembers = true } = {}) {
|
|
15
19
|
const invalid = () => context.error('INVALID_GROUP_MEMBERSHIP_REFERENCE', {});
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
20
|
+
const group = canonicalNodeId(input.group, invalid);
|
|
21
|
+
const [first, ...others] = new Set(input.members.map((member) => canonicalNodeId(member, invalid)));
|
|
22
|
+
if (first === undefined)
|
|
23
|
+
throw invalid();
|
|
24
|
+
const members = [first, ...others];
|
|
25
|
+
const authorities = authority(context);
|
|
26
|
+
// A fixed number of reads, whatever the number of members: one per kind, never one per member.
|
|
27
|
+
// What the caller holds is asked with them, so G2 and G3 wait on no further round trip for it.
|
|
28
|
+
const [identities, observed, stored] = await Promise.all([
|
|
29
|
+
// A list of members is not a typed Path input: each must be an account or a group.
|
|
30
|
+
checkMembers ? identitiesOnly(context, members) : true,
|
|
31
|
+
observeMembers(context, { members, group }, present),
|
|
32
|
+
readGroup(context, group),
|
|
33
|
+
authorities.callerHolds('directory.provisioner'),
|
|
34
|
+
authorities.callerHolds('group.writer'),
|
|
35
|
+
authorities.callerHolds('workspace.admin'),
|
|
36
|
+
]);
|
|
37
|
+
if (!identities)
|
|
38
|
+
throw invalid();
|
|
39
|
+
if (members.some((member) => !observed.visible.has(member)) || !observed.visible.has(group)) {
|
|
22
40
|
throw context.error('GROUP_MEMBERSHIP_ENDPOINTS_UNAVAILABLE', {});
|
|
23
|
-
if (endpoints.groupClass !== context.domain.classes.Group.key) {
|
|
24
|
-
return legacyChangeForeignMembership(context, membership, present);
|
|
25
41
|
}
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return
|
|
31
|
-
}
|
|
32
|
-
if (
|
|
42
|
+
if (observed.groupClass !== context.domain.classes.Group.key) {
|
|
43
|
+
for (const member of members) {
|
|
44
|
+
await legacyChangeForeignMembership(context, { member, group }, present);
|
|
45
|
+
}
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
if (stored === undefined)
|
|
49
|
+
throw context.error('GROUP_NOT_FOUND', {});
|
|
50
|
+
const refusal = await membershipRefusal(context, stored, authorities);
|
|
51
|
+
if (refusal !== undefined) {
|
|
33
52
|
if (refusal.code === 'GROUP_ROLE_PROTECTED') {
|
|
34
53
|
throw context.error(refusal.code, { role: refusal.role });
|
|
35
54
|
}
|
|
36
55
|
throw context.error(refusal.code, {});
|
|
37
56
|
}
|
|
38
|
-
|
|
39
|
-
const current = await context.query(readGroupMembershipEndpoints, membership);
|
|
40
|
-
if (!current.visible)
|
|
41
|
-
throw context.error('GROUP_MEMBERSHIP_ENDPOINTS_UNAVAILABLE', {});
|
|
42
|
-
const authority = await context.query(readGroupMembership, membership);
|
|
43
|
-
const edges = [
|
|
44
|
-
{ edge: 'authority', current: authority.present, present, guard: authority.guard },
|
|
45
|
-
...(await legacyMembershipEdges(context, membership, present)),
|
|
46
|
-
];
|
|
47
|
-
return { endpoints: current.guard, edges };
|
|
48
|
-
};
|
|
49
|
-
const matches = (edges) => edges.every(({ current, present: wanted }) => current === wanted);
|
|
50
|
-
const current = await context.step.run('observe-membership', observe);
|
|
51
|
-
if (matches(current.edges))
|
|
57
|
+
if (observed.changes.length === 0)
|
|
52
58
|
return {};
|
|
53
|
-
const result = await context.
|
|
59
|
+
const result = await context.mutate(setGroupMemberships, {
|
|
60
|
+
group,
|
|
61
|
+
guards: observed.guards,
|
|
62
|
+
changes: observed.changes,
|
|
63
|
+
});
|
|
54
64
|
if (result.ok)
|
|
55
65
|
return {};
|
|
56
66
|
if (result.outcomeKnown) {
|
|
@@ -58,11 +68,54 @@ export async function changeGroupMembership(context, present) {
|
|
|
58
68
|
throw context.error('GROUP_MEMBERSHIP_CYCLE', {});
|
|
59
69
|
throw context.error('GROUP_MEMBERSHIP_CONFLICT', { cause: result.code });
|
|
60
70
|
}
|
|
61
|
-
const recovered = await context
|
|
62
|
-
if (
|
|
71
|
+
const recovered = await observeMembers(context, { members, group }, present);
|
|
72
|
+
if (recovered.changes.length === 0)
|
|
63
73
|
return {};
|
|
64
74
|
throw context.error('GROUP_MEMBERSHIP_UNCONFIRMED', {
|
|
65
75
|
cause: result.code,
|
|
66
76
|
outcomeKnown: false,
|
|
67
77
|
});
|
|
68
78
|
}
|
|
79
|
+
/** Add or withdraw one direct member of a Shell group. */
|
|
80
|
+
export const changeGroupMembership = (context, present) =>
|
|
81
|
+
// The member Path input already restricts its Class to an account or a group.
|
|
82
|
+
changeGroupMembers(context, { group: context.input.group, members: [context.input.member] }, present, { checkMembers: false });
|
|
83
|
+
/** Whether every member is an account or a group, a Domain's subclass included. */
|
|
84
|
+
async function identitiesOnly(context, members) {
|
|
85
|
+
const [first, ...others] = members;
|
|
86
|
+
if (first === undefined)
|
|
87
|
+
return false;
|
|
88
|
+
const ids = [first, ...others];
|
|
89
|
+
const found = await Promise.all([
|
|
90
|
+
context.query(readIdentities, { kind: 'User', ids }),
|
|
91
|
+
context.query(readIdentities, { kind: 'Group', ids }),
|
|
92
|
+
]);
|
|
93
|
+
const known = new Set(found.flat().map((node) => node.id));
|
|
94
|
+
return members.every((member) => known.has(member));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The members and their membership Edges to the group as they are, in three reads whatever their
|
|
98
|
+
* number, with the guards that keep them so and the Edges to change for the state asked for.
|
|
99
|
+
*/
|
|
100
|
+
async function observeMembers(context, selection, present) {
|
|
101
|
+
const [endpoints, authority, legacy] = await Promise.all([
|
|
102
|
+
context.query(readGroupMembersEndpoints, selection),
|
|
103
|
+
context.query(readGroupMembers, selection),
|
|
104
|
+
legacyMembersEdges(context, selection, present),
|
|
105
|
+
]);
|
|
106
|
+
const changes = [];
|
|
107
|
+
for (const member of selection.members) {
|
|
108
|
+
if (authority.present.has(member) !== present) {
|
|
109
|
+
changes.push({ member, edge: 'authority', present });
|
|
110
|
+
}
|
|
111
|
+
const wanted = legacy.wanted(member);
|
|
112
|
+
if (legacy.present.has(member) !== wanted)
|
|
113
|
+
changes.push({ member, edge: 'legacy', present: wanted });
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
visible: endpoints.visible,
|
|
117
|
+
...(endpoints.groupClass === undefined ? {} : { groupClass: endpoints.groupClass }),
|
|
118
|
+
guards: [endpoints.guard, authority.guard, legacy.guard],
|
|
119
|
+
changes,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -9,7 +9,7 @@ export const groupMembershipErrors = {
|
|
|
9
9
|
},
|
|
10
10
|
INVALID_GROUP_MEMBERSHIP_REFERENCE: {
|
|
11
11
|
family: 'VALIDATION_ERROR',
|
|
12
|
-
message: '
|
|
12
|
+
message: 'Members and group must be exact canonical @NodeId paths of accounts or groups.',
|
|
13
13
|
details: z.strictObject({}),
|
|
14
14
|
},
|
|
15
15
|
GROUP_MEMBERSHIP_CONFLICT: {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export { create } from './create-group.js';
|
|
2
2
|
export { update } from './update-group.js';
|
|
3
3
|
export { remove } from './delete-group.js';
|
|
4
|
-
export { assignMember } from './assign-member.js';
|
|
5
|
-
export { unassignMember } from './unassign-member.js';
|
|
4
|
+
export { assignMember, assignMembers } from './assign-member.js';
|
|
5
|
+
export { unassignMember, unassignMembers } from './unassign-member.js';
|
|
6
6
|
export { provision } from './provision-group.js';
|
|
7
7
|
export { deprovision } from './deprovision-group.js';
|
|
8
8
|
export { toGroup } from './group.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { legacyChangeForeignMembership,
|
|
1
|
+
export { legacyChangeForeignMembership, legacyMembersEdges } from './membership.js';
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { setGroupMembership } from '../../../mutations/group/index.js';
|
|
2
|
-
import { readGroupMembership, readGroupMembershipEndpoints } from '../../../queries/group/index.js';
|
|
3
|
-
import { legacyReadCoreGroups, legacyReadMemberOfGroup } from '../../../queries/group/legacy/index.js';
|
|
2
|
+
import { readGroupMembership, readGroupMembershipEndpoints, } from '../../../queries/group/index.js';
|
|
3
|
+
import { legacyReadCoreGroups, legacyReadMemberOfGroup, legacyReadMembersOfGroup, } from '../../../queries/group/legacy/index.js';
|
|
4
4
|
/**
|
|
5
|
-
* @deprecated The legacy `member_of_group`
|
|
6
|
-
* (L-SHELL-8): joining one of the legacy Core groups writes it
|
|
7
|
-
* that still check it; leaving any group removes it.
|
|
8
|
-
* Shell major (delivery step 5) after 1Pact step 4.
|
|
5
|
+
* @deprecated The legacy `member_of_group` Edges a change of a Shell group's members also converges
|
|
6
|
+
* (L-SHELL-8), read in one go for all the members: joining one of the legacy Core groups writes it
|
|
7
|
+
* beside `extends_with`, for the readers that still check it; leaving any group removes it.
|
|
8
|
+
* Replaced by `extends_with` alone. Removal: Shell major (delivery step 5) after 1Pact step 4.
|
|
9
9
|
*/
|
|
10
|
-
export async function
|
|
11
|
-
const observed = await
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
export async function legacyMembersEdges(context, selection, present) {
|
|
11
|
+
const [observed, legacyGroups] = await Promise.all([
|
|
12
|
+
context.query(legacyReadMembersOfGroup, selection),
|
|
13
|
+
present ? context.query(legacyReadCoreGroups, {}) : undefined,
|
|
14
|
+
]);
|
|
15
|
+
const core = legacyGroups !== undefined && Object.values(legacyGroups).includes(selection.group);
|
|
16
|
+
return {
|
|
17
|
+
present: observed.present,
|
|
18
|
+
guard: observed.guard,
|
|
19
|
+
/** Whether a member is to keep `member_of_group`: never when it leaves the group. */
|
|
20
|
+
wanted: (member) => present && (core || observed.present.has(member)),
|
|
21
|
+
};
|
|
18
22
|
}
|
|
19
23
|
/**
|
|
20
24
|
* @deprecated A Domain that extends Group changes the members of its own groups (L-SHELL-9): Shell
|
|
@@ -42,10 +46,10 @@ export async function legacyChangeForeignMembership(context, membership, present
|
|
|
42
46
|
return { endpoints: endpoints.guard, edges };
|
|
43
47
|
};
|
|
44
48
|
const matches = (edges) => edges.every(({ current, present: wanted }) => current === wanted);
|
|
45
|
-
const current = await
|
|
49
|
+
const current = await observe();
|
|
46
50
|
if (matches(current.edges))
|
|
47
51
|
return {};
|
|
48
|
-
const result = await
|
|
52
|
+
const result = await caller.mutate(setGroupMembership, { ...membership, ...current });
|
|
49
53
|
if (result.ok)
|
|
50
54
|
return {};
|
|
51
55
|
if (result.outcomeKnown) {
|
|
@@ -53,7 +57,7 @@ export async function legacyChangeForeignMembership(context, membership, present
|
|
|
53
57
|
throw context.error('GROUP_MEMBERSHIP_CYCLE', {});
|
|
54
58
|
throw context.error('GROUP_MEMBERSHIP_CONFLICT', { cause: result.code });
|
|
55
59
|
}
|
|
56
|
-
const recovered = await
|
|
60
|
+
const recovered = await observe();
|
|
57
61
|
if (matches(recovered.edges))
|
|
58
62
|
return {};
|
|
59
63
|
throw context.error('GROUP_MEMBERSHIP_UNCONFIRMED', { cause: result.code, outcomeKnown: false });
|
|
@@ -1,40 +1,35 @@
|
|
|
1
1
|
import { authority } from '../authority/index.js';
|
|
2
2
|
import { isProvidedGroup } from './group.js';
|
|
3
3
|
/**
|
|
4
|
-
* G2
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* G2 and G3 for one change of a Shell group's members. A provided group changes its members only
|
|
5
|
+
* through its directory, a local one through `group.writer`; a group that holds a Role, directly or
|
|
6
|
+
* through what it extends, only by an admin or its directory. A member that would join a group it
|
|
7
|
+
* already holds is refused by the Kernel, which keeps `extends_with` acyclic. What the caller holds
|
|
8
|
+
* is asked at once; the group's composition is walked only for a caller who is no admin.
|
|
8
9
|
*/
|
|
9
|
-
export async function membershipRefusal(capabilities, group,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return { code: 'GROUP_DIRECTORY_MANAGED' };
|
|
15
|
-
}
|
|
10
|
+
export async function membershipRefusal(capabilities, group, authorities = authority(capabilities)) {
|
|
11
|
+
if (isProvidedGroup(group)) {
|
|
12
|
+
return (await authorities.callerHolds('directory.provisioner'))
|
|
13
|
+
? undefined
|
|
14
|
+
: { code: 'GROUP_DIRECTORY_MANAGED' };
|
|
16
15
|
}
|
|
17
|
-
|
|
16
|
+
const [writer, admin] = await Promise.all([
|
|
17
|
+
authorities.callerHolds('group.writer'),
|
|
18
|
+
authorities.callerHolds('workspace.admin'),
|
|
19
|
+
]);
|
|
20
|
+
if (!writer)
|
|
18
21
|
return { code: 'GROUP_NOT_DIRECTORY_MANAGED' };
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return { code: 'GROUP_ROLE_PROTECTED', role };
|
|
24
|
-
}
|
|
25
|
-
if (joining && (await authorities.reaches(group.id, member))) {
|
|
26
|
-
return { code: 'GROUP_MEMBERSHIP_CYCLE' };
|
|
27
|
-
}
|
|
28
|
-
return undefined;
|
|
22
|
+
if (admin)
|
|
23
|
+
return undefined;
|
|
24
|
+
const role = await authorities.heldRole(group.id);
|
|
25
|
+
return role === undefined ? undefined : { code: 'GROUP_ROLE_PROTECTED', role };
|
|
29
26
|
}
|
|
30
27
|
/**
|
|
31
28
|
* G3: the Role a group holds, when the caller is no admin and so may not change who holds it,
|
|
32
|
-
* through its members or by deleting it.
|
|
29
|
+
* through its members or by deleting it. An admin is never refused, so its composition is not read.
|
|
33
30
|
*/
|
|
34
|
-
export async function roleProtected(capabilities, group) {
|
|
35
|
-
|
|
36
|
-
const role = await authorities.heldRole(group.id);
|
|
37
|
-
if (role === undefined || (await authorities.callerHolds('workspace.admin')))
|
|
31
|
+
export async function roleProtected(capabilities, group, authorities = authority(capabilities)) {
|
|
32
|
+
if (await authorities.callerHolds('workspace.admin'))
|
|
38
33
|
return undefined;
|
|
39
|
-
return
|
|
34
|
+
return authorities.heldRole(group.id);
|
|
40
35
|
}
|