@stigmer/server 3.15.2 → 3.15.3-dev.20260914121148
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/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +103 -9
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/iampolicy/access-lists.d.ts +27 -0
- package/dist/domain/iampolicy/access-lists.d.ts.map +1 -0
- package/dist/domain/iampolicy/access-lists.js +124 -0
- package/dist/domain/iampolicy/access-lists.js.map +1 -0
- package/dist/domain/iampolicy/constants.d.ts +135 -0
- package/dist/domain/iampolicy/constants.d.ts.map +1 -0
- package/dist/domain/iampolicy/constants.js +203 -0
- package/dist/domain/iampolicy/constants.js.map +1 -0
- package/dist/domain/iampolicy/controller.d.ts +29 -0
- package/dist/domain/iampolicy/controller.d.ts.map +1 -0
- package/dist/domain/iampolicy/controller.js +451 -0
- package/dist/domain/iampolicy/controller.js.map +1 -0
- package/dist/domain/iampolicy/display-resolver.d.ts +6 -0
- package/dist/domain/iampolicy/display-resolver.d.ts.map +1 -0
- package/dist/domain/iampolicy/display-resolver.js +90 -0
- package/dist/domain/iampolicy/display-resolver.js.map +1 -0
- package/dist/domain/iampolicy/grant-path.d.ts +42 -0
- package/dist/domain/iampolicy/grant-path.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-path.js +183 -0
- package/dist/domain/iampolicy/grant-path.js.map +1 -0
- package/dist/domain/iampolicy/grant-scope.d.ts +4 -0
- package/dist/domain/iampolicy/grant-scope.d.ts.map +1 -0
- package/dist/domain/iampolicy/grant-scope.js +34 -0
- package/dist/domain/iampolicy/grant-scope.js.map +1 -0
- package/dist/domain/iampolicy/membership.d.ts +111 -0
- package/dist/domain/iampolicy/membership.d.ts.map +1 -0
- package/dist/domain/iampolicy/membership.js +152 -0
- package/dist/domain/iampolicy/membership.js.map +1 -0
- package/dist/domain/iampolicy/permissions.d.ts +19 -0
- package/dist/domain/iampolicy/permissions.d.ts.map +1 -0
- package/dist/domain/iampolicy/permissions.js +27 -0
- package/dist/domain/iampolicy/permissions.js.map +1 -0
- package/dist/domain/iampolicy/resource-store.d.ts +4 -0
- package/dist/domain/iampolicy/resource-store.d.ts.map +1 -0
- package/dist/domain/iampolicy/resource-store.js +135 -0
- package/dist/domain/iampolicy/resource-store.js.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts +9 -0
- package/dist/domain/iampolicy/role-lifecycle.d.ts.map +1 -0
- package/dist/domain/iampolicy/role-lifecycle.js +106 -0
- package/dist/domain/iampolicy/role-lifecycle.js.map +1 -0
- package/dist/domain/iampolicy/roles.d.ts +11 -0
- package/dist/domain/iampolicy/roles.d.ts.map +1 -0
- package/dist/domain/iampolicy/roles.js +78 -0
- package/dist/domain/iampolicy/roles.js.map +1 -0
- package/dist/domain/iampolicy/specs.d.ts +7 -0
- package/dist/domain/iampolicy/specs.d.ts.map +1 -0
- package/dist/domain/iampolicy/specs.js +45 -0
- package/dist/domain/iampolicy/specs.js.map +1 -0
- package/dist/domain/iampolicy/steps.d.ts +18 -0
- package/dist/domain/iampolicy/steps.d.ts.map +1 -0
- package/dist/domain/iampolicy/steps.js +117 -0
- package/dist/domain/iampolicy/steps.js.map +1 -0
- package/dist/domain/iampolicy/store-contract.d.ts +8 -0
- package/dist/domain/iampolicy/store-contract.d.ts.map +1 -0
- package/dist/domain/iampolicy/store-contract.js +258 -0
- package/dist/domain/iampolicy/store-contract.js.map +1 -0
- package/dist/domain/iampolicy/store.d.ts +78 -0
- package/dist/domain/iampolicy/store.d.ts.map +1 -0
- package/dist/domain/iampolicy/store.js +13 -0
- package/dist/domain/iampolicy/store.js.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts +25 -0
- package/dist/domain/iampolicy/wire-refusals.d.ts.map +1 -0
- package/dist/domain/iampolicy/wire-refusals.js +76 -0
- package/dist/domain/iampolicy/wire-refusals.js.map +1 -0
- package/dist/domain/identityaccount/constants.d.ts +33 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -1
- package/dist/domain/identityaccount/constants.js +2 -48
- package/dist/domain/identityaccount/constants.js.map +1 -1
- package/dist/domain/identityaccount/controller.d.ts +8 -1
- package/dist/domain/identityaccount/controller.d.ts.map +1 -1
- package/dist/domain/identityaccount/controller.js +54 -29
- package/dist/domain/identityaccount/controller.js.map +1 -1
- package/dist/domain/identityaccount/operator.d.ts.map +1 -1
- package/dist/domain/identityaccount/operator.js +2 -4
- package/dist/domain/identityaccount/operator.js.map +1 -1
- package/dist/domain/identityaccount/provisioning.d.ts +20 -1
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
- package/dist/domain/identityaccount/provisioning.js +17 -3
- package/dist/domain/identityaccount/provisioning.js.map +1 -1
- package/dist/domain/identityaccount/resolve.d.ts +23 -0
- package/dist/domain/identityaccount/resolve.d.ts.map +1 -1
- package/dist/domain/identityaccount/resolve.js +17 -0
- package/dist/domain/identityaccount/resolve.js.map +1 -1
- package/dist/domain/identityaccount/store-contract.d.ts +4 -18
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
- package/dist/domain/identityaccount/store-contract.js +12 -47
- package/dist/domain/identityaccount/store-contract.js.map +1 -1
- package/dist/extensions/authorization-queries.d.ts +81 -0
- package/dist/extensions/authorization-queries.d.ts.map +1 -0
- package/dist/extensions/authorization-queries.js +2 -0
- package/dist/extensions/authorization-queries.js.map +1 -0
- package/dist/extensions/drivers.d.ts +49 -2
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/identity.d.ts +14 -0
- package/dist/extensions/identity.d.ts.map +1 -1
- package/dist/extensions/identity.js +18 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/extensions/list-read-scope.d.ts +41 -4
- package/dist/extensions/list-read-scope.d.ts.map +1 -1
- package/dist/extensions/list-read-scope.js +44 -7
- package/dist/extensions/list-read-scope.js.map +1 -1
- package/dist/extensions/policy-grant-scope.d.ts +50 -0
- package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
- package/dist/extensions/policy-grant-scope.js +2 -0
- package/dist/extensions/policy-grant-scope.js.map +1 -0
- package/dist/extensions/registry.d.ts +23 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +30 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/extensions/resource-authorization.d.ts +63 -0
- package/dist/extensions/resource-authorization.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/apiresource-meta.d.ts +77 -1
- package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
- package/dist/pipeline/apiresource-meta.js +183 -1
- package/dist/pipeline/apiresource-meta.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +12 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +13 -1
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.js +7 -26
- package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
- package/dist/pipeline/steps/authorize.d.ts +20 -5
- package/dist/pipeline/steps/authorize.d.ts.map +1 -1
- package/dist/pipeline/steps/authorize.js +37 -17
- package/dist/pipeline/steps/authorize.js.map +1 -1
- package/dist/pipeline/steps/defaults.d.ts +10 -0
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +39 -0
- package/dist/pipeline/steps/defaults.js.map +1 -1
- package/dist/pipeline/steps/shapes.d.ts +18 -0
- package/dist/pipeline/steps/shapes.d.ts.map +1 -1
- package/dist/pipeline/steps/shapes.js +29 -0
- package/dist/pipeline/steps/shapes.js.map +1 -1
- package/dist/store/port-contract.d.ts +61 -0
- package/dist/store/port-contract.d.ts.map +1 -0
- package/dist/store/port-contract.js +67 -0
- package/dist/store/port-contract.js.map +1 -0
- package/package.json +4 -4
- package/src/boot/compose.ts +107 -10
- package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
- package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
- package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
- package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
- package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
- package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
- package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
- package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
- package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
- package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
- package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
- package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
- package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
- package/src/domain/iampolicy/__tests__/support.ts +195 -0
- package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
- package/src/domain/iampolicy/access-lists.ts +191 -0
- package/src/domain/iampolicy/constants.ts +243 -0
- package/src/domain/iampolicy/controller.ts +798 -0
- package/src/domain/iampolicy/display-resolver.ts +123 -0
- package/src/domain/iampolicy/grant-path.ts +270 -0
- package/src/domain/iampolicy/grant-scope.ts +36 -0
- package/src/domain/iampolicy/membership.ts +298 -0
- package/src/domain/iampolicy/permissions.ts +35 -0
- package/src/domain/iampolicy/resource-store.ts +186 -0
- package/src/domain/iampolicy/role-lifecycle.ts +132 -0
- package/src/domain/iampolicy/roles.ts +95 -0
- package/src/domain/iampolicy/specs.ts +53 -0
- package/src/domain/iampolicy/steps.ts +178 -0
- package/src/domain/iampolicy/store-contract.ts +450 -0
- package/src/domain/iampolicy/store.ts +103 -0
- package/src/domain/iampolicy/wire-refusals.ts +110 -0
- package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
- package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
- package/src/domain/identityaccount/constants.ts +16 -31
- package/src/domain/identityaccount/controller.ts +65 -35
- package/src/domain/identityaccount/operator.ts +5 -5
- package/src/domain/identityaccount/provisioning.ts +43 -5
- package/src/domain/identityaccount/resolve.ts +43 -0
- package/src/domain/identityaccount/store-contract.ts +22 -70
- package/src/extensions/__tests__/composed-support.ts +87 -0
- package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
- package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
- package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
- package/src/extensions/__tests__/identity.test.ts +62 -0
- package/src/extensions/__tests__/list-read-scope.test.ts +138 -0
- package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
- package/src/extensions/authorization-queries.ts +97 -0
- package/src/extensions/drivers.ts +49 -2
- package/src/extensions/identity.ts +21 -0
- package/src/extensions/list-read-scope.ts +87 -9
- package/src/extensions/policy-grant-scope.ts +50 -0
- package/src/extensions/registry.ts +62 -1
- package/src/extensions/resource-authorization.ts +65 -0
- package/src/index.ts +20 -0
- package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
- package/src/pipeline/__tests__/inherited-authorization-parent.test.ts +51 -0
- package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
- package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
- package/src/pipeline/apiresource-meta.ts +218 -1
- package/src/pipeline/interceptors/auth.ts +14 -1
- package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
- package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
- package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
- package/src/pipeline/steps/authorization-tuples.ts +7 -28
- package/src/pipeline/steps/authorize.ts +46 -21
- package/src/pipeline/steps/defaults.ts +42 -0
- package/src/pipeline/steps/shapes.ts +31 -0
- package/src/store/__tests__/port-contract.test.ts +123 -0
- package/src/store/port-contract.ts +102 -0
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins controller.ts (20260913.01 slice 5) at the handler level, over a
|
|
3
|
+
* router transport with fakes — the query/search controller.test.ts
|
|
4
|
+
* shape — so every arm of the fourteen RPCs is proven without booting a
|
|
5
|
+
* server; the composed proof over real boots is iampolicy.test.ts.
|
|
6
|
+
*
|
|
7
|
+
* Fakes: the domain's in-memory IamPolicyStore (support.ts) with a real
|
|
8
|
+
* grant path over it; the identity-account fake store for
|
|
9
|
+
* `accountForCaller` and display enrichment; a RECORDING Authorizer whose
|
|
10
|
+
* decision each arm sets; a recording AuthorizationQueryEngine; the
|
|
11
|
+
* open-source grant scope unless an arm installs another; a stamping
|
|
12
|
+
* interceptor that sets the caller the chassis would have stamped (the
|
|
13
|
+
* verifier chain is the chassis's business, proven in its own tests).
|
|
14
|
+
*
|
|
15
|
+
* What the arms pin (T01_1_review.md Q-OR-7, Q-OR-8; Q-S5-1..3, Q-S5-10):
|
|
16
|
+
* - the three system RPCs admit machine and internal callers and refuse
|
|
17
|
+
* a wire user PERMISSION_DENIED with the annotation's own copy;
|
|
18
|
+
* - checkMyPermission's full order, incl. the arms the Authorizer is
|
|
19
|
+
* NEVER consulted on, and the account id (not the stamped email) the
|
|
20
|
+
* contextual arm hands the engine;
|
|
21
|
+
* - checkAuthorization's and listAuthorizedResourceIds's principal-trust
|
|
22
|
+
* rule against the caller's ACCOUNT; listAuthorizedPrincipalIds's
|
|
23
|
+
* annotation-driven check; the wire-kind refusals on every lane;
|
|
24
|
+
* - get loads before it authorizes (NOT_FOUND with no Authorizer call;
|
|
25
|
+
* the row's kind and id as the target); delete authorizes before it
|
|
26
|
+
* loads; create validates the role before it writes.
|
|
27
|
+
*/
|
|
28
|
+
import { create } from "@bufbuild/protobuf";
|
|
29
|
+
import {
|
|
30
|
+
Code,
|
|
31
|
+
ConnectError,
|
|
32
|
+
createClient,
|
|
33
|
+
createRouterTransport,
|
|
34
|
+
} from "@connectrpc/connect";
|
|
35
|
+
import type { Client, Interceptor, Transport } from "@connectrpc/connect";
|
|
36
|
+
import { describe, expect, it } from "vitest";
|
|
37
|
+
|
|
38
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
39
|
+
import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
40
|
+
import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
|
|
41
|
+
import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
42
|
+
import { IamPolicyCommandController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/command_pb";
|
|
43
|
+
import { IamPolicyQueryController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/query_pb";
|
|
44
|
+
import { ApiResourceRefSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
|
|
45
|
+
import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
|
|
46
|
+
import {
|
|
47
|
+
IamPermission,
|
|
48
|
+
IamRole,
|
|
49
|
+
} from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
50
|
+
import { ServerEdition } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
|
|
51
|
+
|
|
52
|
+
import { createLogger } from "../../../boot/logger.js";
|
|
53
|
+
import type { AuthorizationQueryEngine } from "../../../extensions/authorization-queries.js";
|
|
54
|
+
import type {
|
|
55
|
+
Authorizer,
|
|
56
|
+
AuthzCheck,
|
|
57
|
+
AuthzDecision,
|
|
58
|
+
} from "../../../extensions/authorizer.js";
|
|
59
|
+
import type { CallerIdentity } from "../../../extensions/identity.js";
|
|
60
|
+
import type { PolicyGrantScope } from "../../../extensions/policy-grant-scope.js";
|
|
61
|
+
import { createApiResourceInterceptor } from "../../../pipeline/interceptors/apiresource.js";
|
|
62
|
+
import {
|
|
63
|
+
callerIdentityKey,
|
|
64
|
+
trustedLocalIdentityFor,
|
|
65
|
+
} from "../../../pipeline/interceptors/auth.js";
|
|
66
|
+
import { AUTHORIZATION_UNAVAILABLE_MESSAGE } from "../../../pipeline/steps/authorize.js";
|
|
67
|
+
import { fakeIdentityAccountStore } from "../../identityaccount/__tests__/support.js";
|
|
68
|
+
import {
|
|
69
|
+
accountIdFor,
|
|
70
|
+
localIdpIdFor,
|
|
71
|
+
} from "../../identityaccount/constants.js";
|
|
72
|
+
import {
|
|
73
|
+
AUTHENTICATION_REQUIRED_MESSAGE,
|
|
74
|
+
AUTHORIZATION_QUERIES_UNIMPLEMENTED_MESSAGE,
|
|
75
|
+
policyIdFor,
|
|
76
|
+
policyNotFoundMessage,
|
|
77
|
+
principalNotGrantableMessage,
|
|
78
|
+
roleNotGrantableMessage,
|
|
79
|
+
unknownPermissionMessage,
|
|
80
|
+
unknownPrincipalKindMessage,
|
|
81
|
+
unknownResourceKindMessage,
|
|
82
|
+
} from "../constants.js";
|
|
83
|
+
import { registerIamPolicyServices } from "../controller.js";
|
|
84
|
+
import { newIamPolicyGrantPath } from "../grant-path.js";
|
|
85
|
+
import { newOrganizationOnlyGrantScope } from "../grant-scope.js";
|
|
86
|
+
import { fakeIamPolicyStore, orgRole, triple } from "./support.js";
|
|
87
|
+
|
|
88
|
+
const silent = createLogger({ level: "error", pretty: false, write: () => {} });
|
|
89
|
+
|
|
90
|
+
const OPERATOR_EMAIL = "operator@example.com";
|
|
91
|
+
const OPERATOR_ID = accountIdFor(localIdpIdFor(OPERATOR_EMAIL));
|
|
92
|
+
const ALICE_ID = "ida_alice";
|
|
93
|
+
|
|
94
|
+
/** The trusted-local operator: stamped by EMAIL, resolves to an account (the Q-S4-1 fact). */
|
|
95
|
+
const operator: CallerIdentity = trustedLocalIdentityFor({
|
|
96
|
+
email: OPERATOR_EMAIL,
|
|
97
|
+
displayName: "The Operator",
|
|
98
|
+
});
|
|
99
|
+
/** A provisioned user whose verifier re-stamped the account id (the cloud's shape). */
|
|
100
|
+
const alice: CallerIdentity = {
|
|
101
|
+
identityId: ALICE_ID,
|
|
102
|
+
callerClass: "user",
|
|
103
|
+
issuer: "https://issuer.example",
|
|
104
|
+
rawToken: "opaque",
|
|
105
|
+
};
|
|
106
|
+
const machine: CallerIdentity = {
|
|
107
|
+
identityId: "ida_platform",
|
|
108
|
+
callerClass: "machine",
|
|
109
|
+
issuer: "https://issuer.example",
|
|
110
|
+
rawToken: "opaque",
|
|
111
|
+
};
|
|
112
|
+
const internal: CallerIdentity = {
|
|
113
|
+
...operator,
|
|
114
|
+
callerClass: "internal",
|
|
115
|
+
origin: "in-process",
|
|
116
|
+
};
|
|
117
|
+
const anonymous: CallerIdentity = { ...alice, identityId: "" };
|
|
118
|
+
|
|
119
|
+
function stampCaller(identity: CallerIdentity): Interceptor {
|
|
120
|
+
return (next) => (request) => {
|
|
121
|
+
request.contextValues.set(callerIdentityKey, identity);
|
|
122
|
+
return next(request);
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface RecordingAuthorizer extends Authorizer {
|
|
127
|
+
readonly checks: AuthzCheck[];
|
|
128
|
+
decision: AuthzDecision | (() => never);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function recordingAuthorizer(): RecordingAuthorizer {
|
|
132
|
+
const checks: AuthzCheck[] = [];
|
|
133
|
+
const authorizer: RecordingAuthorizer = {
|
|
134
|
+
checks,
|
|
135
|
+
decision: { kind: "allow" },
|
|
136
|
+
authorize(_caller, check) {
|
|
137
|
+
checks.push(check);
|
|
138
|
+
if (typeof authorizer.decision === "function") {
|
|
139
|
+
return authorizer.decision();
|
|
140
|
+
}
|
|
141
|
+
return Promise.resolve(authorizer.decision);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
return authorizer;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface RecordingEngine extends AuthorizationQueryEngine {
|
|
148
|
+
readonly calls: Array<{ method: string; args: unknown[] }>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function recordingEngine(): RecordingEngine {
|
|
152
|
+
const calls: Array<{ method: string; args: unknown[] }> = [];
|
|
153
|
+
return {
|
|
154
|
+
calls,
|
|
155
|
+
check(policy, contextual) {
|
|
156
|
+
calls.push({ method: "check", args: [policy, contextual] });
|
|
157
|
+
return Promise.resolve(true);
|
|
158
|
+
},
|
|
159
|
+
listResourceIds(principal, relation, resourceKind, contextual) {
|
|
160
|
+
calls.push({
|
|
161
|
+
method: "listResourceIds",
|
|
162
|
+
args: [principal, relation, resourceKind, contextual],
|
|
163
|
+
});
|
|
164
|
+
return Promise.resolve(["r1"]);
|
|
165
|
+
},
|
|
166
|
+
listPrincipalIds(resource, relation, principalKind, contextual) {
|
|
167
|
+
calls.push({
|
|
168
|
+
method: "listPrincipalIds",
|
|
169
|
+
args: [resource, relation, principalKind, contextual],
|
|
170
|
+
});
|
|
171
|
+
return Promise.resolve(["p1"]);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface Harness {
|
|
177
|
+
readonly command: Client<typeof IamPolicyCommandController>;
|
|
178
|
+
readonly query: Client<typeof IamPolicyQueryController>;
|
|
179
|
+
readonly authorizer: RecordingAuthorizer;
|
|
180
|
+
readonly engine: RecordingEngine | undefined;
|
|
181
|
+
readonly policies: ReturnType<typeof fakeIamPolicyStore>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function harness(options: {
|
|
185
|
+
caller: CallerIdentity;
|
|
186
|
+
engine?: boolean;
|
|
187
|
+
edition?: ServerEdition;
|
|
188
|
+
scope?: PolicyGrantScope;
|
|
189
|
+
}): Promise<Harness> {
|
|
190
|
+
const policies = fakeIamPolicyStore();
|
|
191
|
+
const accounts = fakeIdentityAccountStore();
|
|
192
|
+
await accounts.save(
|
|
193
|
+
create(IdentityAccountSchema, {
|
|
194
|
+
metadata: { id: OPERATOR_ID, name: "The Operator" },
|
|
195
|
+
spec: {
|
|
196
|
+
idpId: localIdpIdFor(OPERATOR_EMAIL),
|
|
197
|
+
email: OPERATOR_EMAIL,
|
|
198
|
+
provisioningMode: IdentityAccountProvisioningMode.direct,
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
await accounts.save(
|
|
203
|
+
create(IdentityAccountSchema, {
|
|
204
|
+
metadata: { id: ALICE_ID, name: "alice@example.com" },
|
|
205
|
+
spec: {
|
|
206
|
+
idpId: "auth0|alice",
|
|
207
|
+
email: "alice@example.com",
|
|
208
|
+
firstName: "Alice",
|
|
209
|
+
provisioningMode: IdentityAccountProvisioningMode.direct,
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
const authorizer = recordingAuthorizer();
|
|
214
|
+
const engine = options.engine === true ? recordingEngine() : undefined;
|
|
215
|
+
const grantPath = newIamPolicyGrantPath({
|
|
216
|
+
policies,
|
|
217
|
+
lifecycle: undefined,
|
|
218
|
+
logger: silent,
|
|
219
|
+
});
|
|
220
|
+
const transport: Transport = createRouterTransport(
|
|
221
|
+
(router) => {
|
|
222
|
+
registerIamPolicyServices(router, {
|
|
223
|
+
grantPath,
|
|
224
|
+
policies,
|
|
225
|
+
accounts,
|
|
226
|
+
authorizer,
|
|
227
|
+
grantScope: options.scope ?? newOrganizationOnlyGrantScope(),
|
|
228
|
+
queries: engine,
|
|
229
|
+
edition: options.edition ?? ServerEdition.oss,
|
|
230
|
+
logger: silent,
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
router: {
|
|
235
|
+
interceptors: [
|
|
236
|
+
createApiResourceInterceptor(),
|
|
237
|
+
stampCaller(options.caller),
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
return {
|
|
243
|
+
command: createClient(IamPolicyCommandController, transport),
|
|
244
|
+
query: createClient(IamPolicyQueryController, transport),
|
|
245
|
+
authorizer,
|
|
246
|
+
engine,
|
|
247
|
+
policies,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function refusal(run: () => Promise<unknown>): Promise<ConnectError> {
|
|
252
|
+
try {
|
|
253
|
+
await run();
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error instanceof ConnectError) return error;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
throw new Error("expected the call to fail");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const ref = (kind: string, id: string) =>
|
|
262
|
+
create(ApiResourceRefSchema, { kind, id });
|
|
263
|
+
const structural: IamPolicySpec = triple(
|
|
264
|
+
{ kind: "organization", id: "acme" },
|
|
265
|
+
"organization",
|
|
266
|
+
{ kind: "agent", id: "agt_1" },
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
describe("the three system RPCs (Q-OR-7, Q-S5-10)", () => {
|
|
270
|
+
const cases: ReadonlyArray<
|
|
271
|
+
[name: string, copy: string, call: (h: Harness) => Promise<unknown>]
|
|
272
|
+
> = [
|
|
273
|
+
[
|
|
274
|
+
"bootstrapPolicy",
|
|
275
|
+
"unauthorized to bootstrap policy - can_bootstrap_iam permission required",
|
|
276
|
+
(h) => h.command.bootstrapPolicy(structural),
|
|
277
|
+
],
|
|
278
|
+
[
|
|
279
|
+
"cleanupResourcePolicies",
|
|
280
|
+
"unauthorized to cleanup resource policies - can_bootstrap_iam permission required",
|
|
281
|
+
(h) => h.command.cleanupResourcePolicies(ref("agent", "agt_1")),
|
|
282
|
+
],
|
|
283
|
+
[
|
|
284
|
+
"bootstrapRevokeOrgAccess",
|
|
285
|
+
"unauthorized to revoke organization access - can_bootstrap_iam permission required",
|
|
286
|
+
(h) =>
|
|
287
|
+
h.command.bootstrapRevokeOrgAccess({
|
|
288
|
+
identityAccountId: ALICE_ID,
|
|
289
|
+
organizationId: "acme",
|
|
290
|
+
}),
|
|
291
|
+
],
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
for (const [name, copy, call] of cases) {
|
|
295
|
+
it(`${name}: a wire user is PERMISSION_DENIED with the annotation's copy before the Authorizer is asked`, async () => {
|
|
296
|
+
const h = await harness({ caller: alice });
|
|
297
|
+
const error = await refusal(() => call(h));
|
|
298
|
+
expect(error.code).toBe(Code.PermissionDenied);
|
|
299
|
+
expect(error.rawMessage).toBe(copy);
|
|
300
|
+
expect(h.authorizer.checks).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it(`${name}: a machine caller is admitted and the annotation's static platform check still runs`, async () => {
|
|
304
|
+
const h = await harness({ caller: machine });
|
|
305
|
+
await call(h);
|
|
306
|
+
expect(h.authorizer.checks).toEqual([
|
|
307
|
+
{
|
|
308
|
+
permission: IamPermission.can_bootstrap_iam,
|
|
309
|
+
resourceKind: ApiResourceKind.platform,
|
|
310
|
+
resourceId: "stigmer",
|
|
311
|
+
},
|
|
312
|
+
]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it(`${name}: an internal caller is admitted and skips position 1`, async () => {
|
|
316
|
+
const h = await harness({ caller: internal });
|
|
317
|
+
await call(h);
|
|
318
|
+
expect(h.authorizer.checks).toEqual([]);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
it("bootstrapPolicy writes structural relations no role validation would admit", async () => {
|
|
323
|
+
const h = await harness({ caller: machine });
|
|
324
|
+
const seeded = await h.command.bootstrapPolicy(structural);
|
|
325
|
+
expect(seeded.metadata?.id).toBe(policyIdFor(structural));
|
|
326
|
+
expect(h.policies.rows.size).toBe(1);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("checkMyPermission (Q-OR-8, Q-S5-3)", () => {
|
|
331
|
+
const platform = ref("platform", "stigmer");
|
|
332
|
+
const acme = ref("organization", "acme");
|
|
333
|
+
|
|
334
|
+
it("an empty identity is UNAUTHENTICATED with the cloud's copy", async () => {
|
|
335
|
+
const h = await harness({ caller: anonymous });
|
|
336
|
+
const error = await refusal(() =>
|
|
337
|
+
h.query.checkMyPermission({ resource: acme, relation: "can_view" }),
|
|
338
|
+
);
|
|
339
|
+
expect(error.code).toBe(Code.Unauthenticated);
|
|
340
|
+
expect(error.rawMessage).toBe(AUTHENTICATION_REQUIRED_MESSAGE);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("a relation that is no IamPermission name is INVALID_ARGUMENT, quoted, with nothing consulted", async () => {
|
|
344
|
+
const h = await harness({ caller: alice });
|
|
345
|
+
const error = await refusal(() =>
|
|
346
|
+
h.query.checkMyPermission({ resource: acme, relation: "admin" }),
|
|
347
|
+
);
|
|
348
|
+
expect(error.code).toBe(Code.InvalidArgument);
|
|
349
|
+
expect(error.rawMessage).toBe(unknownPermissionMessage("admin"));
|
|
350
|
+
expect(h.authorizer.checks).toEqual([]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("an unknown resource kind is INVALID_ARGUMENT with the wire copy (Q-S5-2)", async () => {
|
|
354
|
+
const h = await harness({ caller: alice });
|
|
355
|
+
const error = await refusal(() =>
|
|
356
|
+
h.query.checkMyPermission({
|
|
357
|
+
resource: ref("Organization", "acme"),
|
|
358
|
+
relation: "can_view",
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
expect(error.code).toBe(Code.InvalidArgument);
|
|
362
|
+
expect(error.rawMessage).toBe(unknownResourceKindMessage("Organization"));
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("contextual policies without a composed engine are UNIMPLEMENTED with the edition named", async () => {
|
|
366
|
+
const h = await harness({ caller: alice });
|
|
367
|
+
const error = await refusal(() =>
|
|
368
|
+
h.query.checkMyPermission({
|
|
369
|
+
resource: acme,
|
|
370
|
+
relation: "can_view",
|
|
371
|
+
contextualPolicies: [orgRole(ALICE_ID, "viewer", "acme")],
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
expect(error.code).toBe(Code.Unimplemented);
|
|
375
|
+
expect(error.rawMessage).toContain(
|
|
376
|
+
AUTHORIZATION_QUERIES_UNIMPLEMENTED_MESSAGE,
|
|
377
|
+
);
|
|
378
|
+
expect(h.authorizer.checks).toEqual([]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("contextual policies ride the engine with the caller's ACCOUNT as the principal — even when the caller is stamped by email", async () => {
|
|
382
|
+
const h = await harness({ caller: operator, engine: true });
|
|
383
|
+
const contextual = [orgRole(OPERATOR_ID, "viewer", "acme")];
|
|
384
|
+
const result = await h.query.checkMyPermission({
|
|
385
|
+
resource: acme,
|
|
386
|
+
relation: "can_view",
|
|
387
|
+
contextualPolicies: contextual,
|
|
388
|
+
});
|
|
389
|
+
expect(result.isAuthorized).toBe(true);
|
|
390
|
+
const [call] = h.engine?.calls ?? [];
|
|
391
|
+
expect(call?.method).toBe("check");
|
|
392
|
+
const [policy, passed] = call?.args as [IamPolicySpec, IamPolicySpec[]];
|
|
393
|
+
expect(policy.principal?.kind).toBe("identity_account");
|
|
394
|
+
expect(policy.principal?.id).toBe(OPERATOR_ID);
|
|
395
|
+
expect(policy.relation).toBe("can_view");
|
|
396
|
+
expect(policy.resource?.id).toBe("acme");
|
|
397
|
+
expect(passed).toHaveLength(1);
|
|
398
|
+
expect(h.authorizer.checks).toEqual([]);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("arm 1: a kind this edition does not serve is false with the Authorizer never consulted (the settings navigation)", async () => {
|
|
402
|
+
const h = await harness({ caller: alice });
|
|
403
|
+
const result = await h.query.checkMyPermission({
|
|
404
|
+
resource: platform,
|
|
405
|
+
relation: "can_manage_model_pricing",
|
|
406
|
+
});
|
|
407
|
+
expect(result.isAuthorized).toBe(false);
|
|
408
|
+
expect(h.authorizer.checks).toEqual([]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("arm 1 does not fire on an edition that serves the kind — enterprise asks its Authorizer about platform", async () => {
|
|
412
|
+
const h = await harness({
|
|
413
|
+
caller: alice,
|
|
414
|
+
edition: ServerEdition.enterprise,
|
|
415
|
+
});
|
|
416
|
+
const result = await h.query.checkMyPermission({
|
|
417
|
+
resource: platform,
|
|
418
|
+
relation: "can_manage_model_pricing",
|
|
419
|
+
});
|
|
420
|
+
expect(result.isAuthorized).toBe(true);
|
|
421
|
+
expect(h.authorizer.checks).toEqual([
|
|
422
|
+
{
|
|
423
|
+
permission: IamPermission.can_manage_model_pricing,
|
|
424
|
+
resourceKind: ApiResourceKind.platform,
|
|
425
|
+
resourceId: "stigmer",
|
|
426
|
+
},
|
|
427
|
+
]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("arm 2: can_grant_access on a kind outside the scope is false with the Authorizer never consulted; on the organization it is the Authorizer's", async () => {
|
|
431
|
+
const h = await harness({ caller: alice });
|
|
432
|
+
const onAgent = await h.query.checkMyPermission({
|
|
433
|
+
resource: ref("agent", "agt_1"),
|
|
434
|
+
relation: "can_grant_access",
|
|
435
|
+
});
|
|
436
|
+
expect(onAgent.isAuthorized).toBe(false);
|
|
437
|
+
expect(h.authorizer.checks).toEqual([]);
|
|
438
|
+
const onOrg = await h.query.checkMyPermission({
|
|
439
|
+
resource: acme,
|
|
440
|
+
relation: "can_grant_access",
|
|
441
|
+
});
|
|
442
|
+
expect(onOrg.isAuthorized).toBe(true);
|
|
443
|
+
expect(h.authorizer.checks).toEqual([
|
|
444
|
+
{
|
|
445
|
+
permission: IamPermission.can_grant_access,
|
|
446
|
+
resourceKind: ApiResourceKind.organization,
|
|
447
|
+
resourceId: "acme",
|
|
448
|
+
},
|
|
449
|
+
]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("arm 2 asks only about can_grant_access — can_view_access on an agent reaches the Authorizer", async () => {
|
|
453
|
+
const h = await harness({ caller: alice });
|
|
454
|
+
await h.query.checkMyPermission({
|
|
455
|
+
resource: ref("agent", "agt_1"),
|
|
456
|
+
relation: "can_view_access",
|
|
457
|
+
});
|
|
458
|
+
expect(h.authorizer.checks).toHaveLength(1);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("arm 3: deny and not-found are false; unavailable and a throwing Authorizer are INTERNAL, never false", async () => {
|
|
462
|
+
const h = await harness({ caller: alice });
|
|
463
|
+
h.authorizer.decision = { kind: "deny", reason: "" };
|
|
464
|
+
expect(
|
|
465
|
+
(
|
|
466
|
+
await h.query.checkMyPermission({
|
|
467
|
+
resource: acme,
|
|
468
|
+
relation: "can_view",
|
|
469
|
+
})
|
|
470
|
+
).isAuthorized,
|
|
471
|
+
).toBe(false);
|
|
472
|
+
h.authorizer.decision = { kind: "not-found" };
|
|
473
|
+
expect(
|
|
474
|
+
(
|
|
475
|
+
await h.query.checkMyPermission({
|
|
476
|
+
resource: acme,
|
|
477
|
+
relation: "can_view",
|
|
478
|
+
})
|
|
479
|
+
).isAuthorized,
|
|
480
|
+
).toBe(false);
|
|
481
|
+
h.authorizer.decision = {
|
|
482
|
+
kind: "unavailable",
|
|
483
|
+
cause: new Error("fga down"),
|
|
484
|
+
};
|
|
485
|
+
const unavailable = await refusal(() =>
|
|
486
|
+
h.query.checkMyPermission({ resource: acme, relation: "can_view" }),
|
|
487
|
+
);
|
|
488
|
+
expect(unavailable.code).toBe(Code.Internal);
|
|
489
|
+
expect(unavailable.rawMessage).toBe(AUTHORIZATION_UNAVAILABLE_MESSAGE);
|
|
490
|
+
h.authorizer.decision = () => {
|
|
491
|
+
throw new Error("boom");
|
|
492
|
+
};
|
|
493
|
+
const thrown = await refusal(() =>
|
|
494
|
+
h.query.checkMyPermission({ resource: acme, relation: "can_view" }),
|
|
495
|
+
);
|
|
496
|
+
expect(thrown.code).toBe(Code.Internal);
|
|
497
|
+
expect(thrown.rawMessage).toBe(AUTHORIZATION_UNAVAILABLE_MESSAGE);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("checkAuthorization's principal-trust rule (the cloud's three refusals verbatim)", () => {
|
|
502
|
+
const acmeAdmin = (principal: { kind: string; id: string }) =>
|
|
503
|
+
triple(principal, "admin", { kind: "organization", id: "acme" });
|
|
504
|
+
|
|
505
|
+
it("an empty identity is UNAUTHENTICATED", async () => {
|
|
506
|
+
const h = await harness({ caller: anonymous, engine: true });
|
|
507
|
+
const error = await refusal(() =>
|
|
508
|
+
h.query.checkAuthorization({
|
|
509
|
+
policy: acmeAdmin({ kind: "identity_account", id: ALICE_ID }),
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
expect(error.code).toBe(Code.Unauthenticated);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("a user may only ask about an identity account", async () => {
|
|
516
|
+
const h = await harness({ caller: alice, engine: true });
|
|
517
|
+
const error = await refusal(() =>
|
|
518
|
+
h.query.checkAuthorization({
|
|
519
|
+
policy: acmeAdmin({ kind: "team", id: "tm_1" }),
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
expect(error.code).toBe(Code.InvalidArgument);
|
|
523
|
+
expect(error.rawMessage).toBe(
|
|
524
|
+
"Only self identity_account permission checks are supported",
|
|
525
|
+
);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("the _self alias is refused with the pointer at checkMyPermission", async () => {
|
|
529
|
+
const h = await harness({ caller: alice, engine: true });
|
|
530
|
+
const error = await refusal(() =>
|
|
531
|
+
h.query.checkAuthorization({
|
|
532
|
+
policy: acmeAdmin({ kind: "identity_account", id: "_self" }),
|
|
533
|
+
}),
|
|
534
|
+
);
|
|
535
|
+
expect(error.code).toBe(Code.InvalidArgument);
|
|
536
|
+
expect(error.rawMessage).toBe(
|
|
537
|
+
"The '_self' principal alias is not supported — use checkMyPermission for self permission checks",
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("a user asking about another principal is PERMISSION_DENIED", async () => {
|
|
542
|
+
const h = await harness({ caller: alice, engine: true });
|
|
543
|
+
const error = await refusal(() =>
|
|
544
|
+
h.query.checkAuthorization({
|
|
545
|
+
policy: acmeAdmin({ kind: "identity_account", id: "ida_bob" }),
|
|
546
|
+
}),
|
|
547
|
+
);
|
|
548
|
+
expect(error.code).toBe(Code.PermissionDenied);
|
|
549
|
+
expect(error.rawMessage).toBe("Principal must be the authenticated caller");
|
|
550
|
+
expect(h.engine?.calls).toEqual([]);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("a user stamped by EMAIL may ask about their own ACCOUNT id — the trust rule compares accounts, not stamps", async () => {
|
|
554
|
+
const h = await harness({ caller: operator, engine: true });
|
|
555
|
+
const policy = acmeAdmin({ kind: "identity_account", id: OPERATOR_ID });
|
|
556
|
+
const result = await h.query.checkAuthorization({ policy });
|
|
557
|
+
expect(result.isAuthorized).toBe(true);
|
|
558
|
+
expect(h.engine?.calls[0]?.method).toBe("check");
|
|
559
|
+
expect((h.engine?.calls[0]?.args[0] as IamPolicySpec).principal?.id).toBe(
|
|
560
|
+
OPERATOR_ID,
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("a machine caller may ask about anyone", async () => {
|
|
565
|
+
const h = await harness({ caller: machine, engine: true });
|
|
566
|
+
await h.query.checkAuthorization({
|
|
567
|
+
policy: acmeAdmin({ kind: "identity_account", id: "ida_bob" }),
|
|
568
|
+
});
|
|
569
|
+
expect(h.engine?.calls).toHaveLength(1);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("a garbage kind in the policy is INVALID_ARGUMENT before any engine call", async () => {
|
|
573
|
+
const h = await harness({ caller: machine, engine: true });
|
|
574
|
+
const error = await refusal(() =>
|
|
575
|
+
h.query.checkAuthorization({
|
|
576
|
+
policy: acmeAdmin({ kind: "person", id: "x" }),
|
|
577
|
+
}),
|
|
578
|
+
);
|
|
579
|
+
expect(error.code).toBe(Code.InvalidArgument);
|
|
580
|
+
expect(error.rawMessage).toBe(unknownPrincipalKindMessage("person"));
|
|
581
|
+
expect(h.engine?.calls).toEqual([]);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("without a composed engine the trusted question is UNIMPLEMENTED with the edition named", async () => {
|
|
585
|
+
const h = await harness({ caller: alice });
|
|
586
|
+
const error = await refusal(() =>
|
|
587
|
+
h.query.checkAuthorization({
|
|
588
|
+
policy: acmeAdmin({ kind: "identity_account", id: ALICE_ID }),
|
|
589
|
+
}),
|
|
590
|
+
);
|
|
591
|
+
expect(error.code).toBe(Code.Unimplemented);
|
|
592
|
+
expect(error.rawMessage).toContain(
|
|
593
|
+
AUTHORIZATION_QUERIES_UNIMPLEMENTED_MESSAGE,
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
describe("the two listings", () => {
|
|
599
|
+
it("listAuthorizedResourceIds: a user may list only for their own account; the engine receives the proto's argument order", async () => {
|
|
600
|
+
const h = await harness({ caller: alice, engine: true });
|
|
601
|
+
const denied = await refusal(() =>
|
|
602
|
+
h.query.listAuthorizedResourceIds({
|
|
603
|
+
principal: ref("identity_account", "ida_bob"),
|
|
604
|
+
resourceKind: "agent",
|
|
605
|
+
relation: "can_view",
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
expect(denied.code).toBe(Code.PermissionDenied);
|
|
609
|
+
const own = await h.query.listAuthorizedResourceIds({
|
|
610
|
+
principal: ref("identity_account", ALICE_ID),
|
|
611
|
+
resourceKind: "agent",
|
|
612
|
+
relation: "can_view",
|
|
613
|
+
contextualPolicies: [orgRole(ALICE_ID, "viewer", "acme")],
|
|
614
|
+
});
|
|
615
|
+
expect(own.resourceIds).toEqual(["r1"]);
|
|
616
|
+
const [call] = h.engine?.calls ?? [];
|
|
617
|
+
expect(call?.method).toBe("listResourceIds");
|
|
618
|
+
expect((call?.args[0] as { id: string }).id).toBe(ALICE_ID);
|
|
619
|
+
expect(call?.args[1]).toBe("can_view");
|
|
620
|
+
expect(call?.args[2]).toBe("agent");
|
|
621
|
+
expect(call?.args[3]).toHaveLength(1);
|
|
622
|
+
expect(h.authorizer.checks).toEqual([]);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("listAuthorizedResourceIds: a garbage resource_kind is INVALID_ARGUMENT; no engine, UNIMPLEMENTED", async () => {
|
|
626
|
+
const withEngine = await harness({ caller: alice, engine: true });
|
|
627
|
+
const garbage = await refusal(() =>
|
|
628
|
+
withEngine.query.listAuthorizedResourceIds({
|
|
629
|
+
principal: ref("identity_account", ALICE_ID),
|
|
630
|
+
resourceKind: "Agent",
|
|
631
|
+
relation: "can_view",
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
expect(garbage.code).toBe(Code.InvalidArgument);
|
|
635
|
+
expect(garbage.rawMessage).toBe(unknownResourceKindMessage("Agent"));
|
|
636
|
+
const without = await harness({ caller: alice });
|
|
637
|
+
const missing = await refusal(() =>
|
|
638
|
+
without.query.listAuthorizedResourceIds({
|
|
639
|
+
principal: ref("identity_account", ALICE_ID),
|
|
640
|
+
resourceKind: "agent",
|
|
641
|
+
relation: "can_view",
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
644
|
+
expect(missing.code).toBe(Code.Unimplemented);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("listAuthorizedPrincipalIds: the annotation drives the check on the resource; the engine receives the proto's order", async () => {
|
|
648
|
+
const h = await harness({ caller: alice, engine: true });
|
|
649
|
+
const result = await h.query.listAuthorizedPrincipalIds({
|
|
650
|
+
resource: ref("agent", "agt_1"),
|
|
651
|
+
principalKind: "identity_account",
|
|
652
|
+
relation: "can_view",
|
|
653
|
+
});
|
|
654
|
+
expect(result.principalIds).toEqual(["p1"]);
|
|
655
|
+
expect(h.authorizer.checks).toEqual([
|
|
656
|
+
{
|
|
657
|
+
permission: IamPermission.can_view_access,
|
|
658
|
+
resourceKind: ApiResourceKind.agent,
|
|
659
|
+
resourceId: "agt_1",
|
|
660
|
+
},
|
|
661
|
+
]);
|
|
662
|
+
const [call] = h.engine?.calls ?? [];
|
|
663
|
+
expect(call?.method).toBe("listPrincipalIds");
|
|
664
|
+
expect((call?.args[0] as { id: string }).id).toBe("agt_1");
|
|
665
|
+
expect(call?.args[1]).toBe("can_view");
|
|
666
|
+
expect(call?.args[2]).toBe("identity_account");
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("listAuthorizedPrincipalIds: a garbage principal_kind is INVALID_ARGUMENT with the principal sentence", async () => {
|
|
670
|
+
const h = await harness({ caller: alice, engine: true });
|
|
671
|
+
const error = await refusal(() =>
|
|
672
|
+
h.query.listAuthorizedPrincipalIds({
|
|
673
|
+
resource: ref("agent", "agt_1"),
|
|
674
|
+
principalKind: "people",
|
|
675
|
+
relation: "can_view",
|
|
676
|
+
}),
|
|
677
|
+
);
|
|
678
|
+
expect(error.code).toBe(Code.InvalidArgument);
|
|
679
|
+
expect(error.rawMessage).toBe(unknownPrincipalKindMessage("people"));
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
describe("the row reads", () => {
|
|
684
|
+
it("get loads THEN authorizes: NOT_FOUND with the pinned copy and no Authorizer call; a found row's resource is the target", async () => {
|
|
685
|
+
const h = await harness({ caller: alice });
|
|
686
|
+
const missing = await refusal(() =>
|
|
687
|
+
h.query.get({ value: "iamp_00000000000000000000000000" }),
|
|
688
|
+
);
|
|
689
|
+
expect(missing.code).toBe(Code.NotFound);
|
|
690
|
+
expect(missing.rawMessage).toBe(
|
|
691
|
+
policyNotFoundMessage("iamp_00000000000000000000000000"),
|
|
692
|
+
);
|
|
693
|
+
expect(h.authorizer.checks).toEqual([]);
|
|
694
|
+
|
|
695
|
+
const spec = orgRole(ALICE_ID, "member", "acme");
|
|
696
|
+
await h.command.create(spec);
|
|
697
|
+
h.authorizer.checks.length = 0;
|
|
698
|
+
const got = await h.query.get({ value: policyIdFor(spec) });
|
|
699
|
+
expect(got.spec?.relation).toBe("member");
|
|
700
|
+
expect(h.authorizer.checks).toEqual([
|
|
701
|
+
{
|
|
702
|
+
permission: IamPermission.can_view_access,
|
|
703
|
+
resourceKind: ApiResourceKind.organization,
|
|
704
|
+
resourceId: "acme",
|
|
705
|
+
},
|
|
706
|
+
]);
|
|
707
|
+
h.authorizer.decision = { kind: "deny", reason: "" };
|
|
708
|
+
const denied = await refusal(() =>
|
|
709
|
+
h.query.get({ value: policyIdFor(spec) }),
|
|
710
|
+
);
|
|
711
|
+
expect(denied.code).toBe(Code.PermissionDenied);
|
|
712
|
+
expect(denied.rawMessage).toBe("unauthorized to view access policies");
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("listResourceAccessByPrincipal, getPrincipalResourceRoles and getPrincipalsCount refuse a garbage kind", async () => {
|
|
716
|
+
const h = await harness({ caller: alice });
|
|
717
|
+
expect(
|
|
718
|
+
(
|
|
719
|
+
await refusal(() =>
|
|
720
|
+
h.query.listResourceAccessByPrincipal({
|
|
721
|
+
resource: ref("Org", "acme"),
|
|
722
|
+
}),
|
|
723
|
+
)
|
|
724
|
+
).rawMessage,
|
|
725
|
+
).toBe(unknownResourceKindMessage("Org"));
|
|
726
|
+
expect(
|
|
727
|
+
(
|
|
728
|
+
await refusal(() =>
|
|
729
|
+
h.query.getPrincipalResourceRoles({
|
|
730
|
+
principal: ref("identity_account", ALICE_ID),
|
|
731
|
+
resource: ref("Org", "acme"),
|
|
732
|
+
}),
|
|
733
|
+
)
|
|
734
|
+
).rawMessage,
|
|
735
|
+
).toBe(unknownResourceKindMessage("Org"));
|
|
736
|
+
expect(
|
|
737
|
+
(
|
|
738
|
+
await refusal(() =>
|
|
739
|
+
h.query.getPrincipalsCount({
|
|
740
|
+
orgId: "acme",
|
|
741
|
+
principalKind: "People",
|
|
742
|
+
}),
|
|
743
|
+
)
|
|
744
|
+
).rawMessage,
|
|
745
|
+
).toBe(unknownPrincipalKindMessage("People"));
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("getPrincipalsCount counts distinct principals over the assignable roles and is authorized on the organization", async () => {
|
|
749
|
+
const h = await harness({ caller: alice });
|
|
750
|
+
await h.command.create(orgRole(ALICE_ID, "admin", "acme"));
|
|
751
|
+
await h.command.create(orgRole(ALICE_ID, "viewer", "acme"));
|
|
752
|
+
await h.command.create(orgRole("ida_bob", "member", "acme"));
|
|
753
|
+
h.authorizer.checks.length = 0;
|
|
754
|
+
const count = await h.query.getPrincipalsCount({
|
|
755
|
+
orgId: "acme",
|
|
756
|
+
principalKind: "identity_account",
|
|
757
|
+
});
|
|
758
|
+
expect(count.count).toBe(2);
|
|
759
|
+
expect(h.authorizer.checks).toEqual([
|
|
760
|
+
{
|
|
761
|
+
permission: IamPermission.can_view_access,
|
|
762
|
+
resourceKind: ApiResourceKind.organization,
|
|
763
|
+
resourceId: "acme",
|
|
764
|
+
},
|
|
765
|
+
]);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("listResourceAccessByPrincipal enriches the operator's row through the account port with the Q-S5-4 name", async () => {
|
|
769
|
+
const h = await harness({ caller: machine });
|
|
770
|
+
await h.command.create(orgRole(OPERATOR_ID, "owner", "acme"));
|
|
771
|
+
const access = await h.query.listResourceAccessByPrincipal({
|
|
772
|
+
resource: ref("organization", "acme"),
|
|
773
|
+
});
|
|
774
|
+
expect(access.entries).toHaveLength(1);
|
|
775
|
+
expect(access.entries[0]?.principal?.name).toBe("The Operator");
|
|
776
|
+
expect(access.entries[0]?.principal?.email).toBe(OPERATOR_EMAIL);
|
|
777
|
+
expect(access.entries[0]?.roles.map((g) => g.role?.code)).toEqual([
|
|
778
|
+
"owner",
|
|
779
|
+
]);
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
describe("the write lanes' order", () => {
|
|
784
|
+
it("delete authorizes BEFORE it loads: a denied caller hears PERMISSION_DENIED even for an absent triple (Q-OR-2)", async () => {
|
|
785
|
+
const h = await harness({ caller: alice });
|
|
786
|
+
h.authorizer.decision = { kind: "deny", reason: "" };
|
|
787
|
+
const error = await refusal(() =>
|
|
788
|
+
h.command.delete(orgRole("ida_nobody", "member", "acme")),
|
|
789
|
+
);
|
|
790
|
+
expect(error.code).toBe(Code.PermissionDenied);
|
|
791
|
+
expect(error.rawMessage).toBe("unauthorized to revoke access");
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it("create validates the role BEFORE the write: a held row under a non-grantable role is INVALID_ARGUMENT, not the duplicate (the cloud's order, Q-S5-1)", async () => {
|
|
795
|
+
const h = await harness({ caller: alice });
|
|
796
|
+
const legacy = orgRole(ALICE_ID, "editor", "acme");
|
|
797
|
+
await h.policies.save(
|
|
798
|
+
create(IamPolicySchema, {
|
|
799
|
+
metadata: { id: policyIdFor(legacy) },
|
|
800
|
+
spec: legacy,
|
|
801
|
+
}),
|
|
802
|
+
);
|
|
803
|
+
const error = await refusal(() => h.command.create(legacy));
|
|
804
|
+
expect(error.code).toBe(Code.InvalidArgument);
|
|
805
|
+
expect(error.rawMessage).toBe(
|
|
806
|
+
roleNotGrantableMessage("editor", "organization", [
|
|
807
|
+
"owner",
|
|
808
|
+
"admin",
|
|
809
|
+
"member",
|
|
810
|
+
"viewer",
|
|
811
|
+
]),
|
|
812
|
+
);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("create's target is the spec's resource, read by the annotation; revokeOrgAccess's is the organization", async () => {
|
|
816
|
+
const h = await harness({ caller: alice });
|
|
817
|
+
await h.command.create(orgRole(ALICE_ID, "member", "acme"));
|
|
818
|
+
await h.command.revokeOrgAccess({
|
|
819
|
+
identityAccountId: ALICE_ID,
|
|
820
|
+
organizationId: "acme",
|
|
821
|
+
});
|
|
822
|
+
expect(h.authorizer.checks).toEqual([
|
|
823
|
+
{
|
|
824
|
+
permission: IamPermission.can_grant_access,
|
|
825
|
+
resourceKind: ApiResourceKind.organization,
|
|
826
|
+
resourceId: "acme",
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
permission: IamPermission.can_grant_access,
|
|
830
|
+
resourceKind: ApiResourceKind.organization,
|
|
831
|
+
resourceId: "acme",
|
|
832
|
+
},
|
|
833
|
+
]);
|
|
834
|
+
expect(h.policies.rows.size).toBe(0);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it("create grants to people only: an organization as the principal is INVALID_ARGUMENT after position 1, and no row is written (Q-S9-2)", async () => {
|
|
838
|
+
// The row a person could otherwise write — organization:other holds
|
|
839
|
+
// `member` on organization:acme — is exactly what findScopeTuple reads
|
|
840
|
+
// as acme's structural parent, so the hierarchy walk would have listed
|
|
841
|
+
// the other organization's members on acme's Members page. The
|
|
842
|
+
// Authorizer IS consulted (the caller's right on acme is real); the
|
|
843
|
+
// refusal is the step's, after it.
|
|
844
|
+
const h = await harness({ caller: alice });
|
|
845
|
+
const link = triple({ kind: "organization", id: "other" }, "member", {
|
|
846
|
+
kind: "organization",
|
|
847
|
+
id: "acme",
|
|
848
|
+
});
|
|
849
|
+
const error = await refusal(() => h.command.create(link));
|
|
850
|
+
expect(error.code).toBe(Code.InvalidArgument);
|
|
851
|
+
expect(error.rawMessage).toBe(principalNotGrantableMessage("organization"));
|
|
852
|
+
expect(h.authorizer.checks).toHaveLength(1);
|
|
853
|
+
expect(h.policies.rows.size).toBe(0);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("the same shape through bootstrapPolicy is a structural link and is admitted — the two lanes split on who the principal is", async () => {
|
|
857
|
+
const h = await harness({ caller: internal });
|
|
858
|
+
const link = triple({ kind: "organization", id: "other" }, "member", {
|
|
859
|
+
kind: "organization",
|
|
860
|
+
id: "acme",
|
|
861
|
+
});
|
|
862
|
+
const seeded = await h.command.bootstrapPolicy(link);
|
|
863
|
+
expect(seeded.metadata?.id).toBe(policyIdFor(link));
|
|
864
|
+
expect(h.policies.rows.size).toBe(1);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it("a scope that narrows the organization's roles is honoured by create", async () => {
|
|
868
|
+
const viewerOnly: PolicyGrantScope = {
|
|
869
|
+
grantableRoles: (kind) =>
|
|
870
|
+
kind === ApiResourceKind.organization ? [IamRole.viewer] : [],
|
|
871
|
+
};
|
|
872
|
+
const h = await harness({ caller: alice, scope: viewerOnly });
|
|
873
|
+
const error = await refusal(() =>
|
|
874
|
+
h.command.create(orgRole(ALICE_ID, "admin", "acme")),
|
|
875
|
+
);
|
|
876
|
+
expect(error.rawMessage).toBe(
|
|
877
|
+
roleNotGrantableMessage("admin", "organization", ["viewer"]),
|
|
878
|
+
);
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
describe("a wire kind string is refused BEFORE position 1 on every annotated lane (Q-S6-1)", () => {
|
|
883
|
+
// A kind string that names no kind names no authorization target, so
|
|
884
|
+
// there is nothing to ask the Authorizer — the same reason
|
|
885
|
+
// checkMyPermission refuses an unknown permission name first, and the
|
|
886
|
+
// order the cloud's handlers kept (kindFromSpecString before
|
|
887
|
+
// authorizeRpc). The Authorizer here DENIES everything: were it asked,
|
|
888
|
+
// the answer would be PERMISSION_DENIED, so the INVALID_ARGUMENT and the
|
|
889
|
+
// empty check log together pin the order.
|
|
890
|
+
const cases: ReadonlyArray<
|
|
891
|
+
[name: string, copy: string, call: (h: Harness) => Promise<unknown>]
|
|
892
|
+
> = [
|
|
893
|
+
[
|
|
894
|
+
"create",
|
|
895
|
+
unknownResourceKindMessage("Organization"),
|
|
896
|
+
(h) =>
|
|
897
|
+
h.command.create(
|
|
898
|
+
triple({ kind: "identity_account", id: ALICE_ID }, "member", {
|
|
899
|
+
kind: "Organization",
|
|
900
|
+
id: "acme",
|
|
901
|
+
}),
|
|
902
|
+
),
|
|
903
|
+
],
|
|
904
|
+
[
|
|
905
|
+
"delete",
|
|
906
|
+
unknownResourceKindMessage("Organization"),
|
|
907
|
+
(h) =>
|
|
908
|
+
h.command.delete(
|
|
909
|
+
triple({ kind: "identity_account", id: ALICE_ID }, "member", {
|
|
910
|
+
kind: "Organization",
|
|
911
|
+
id: "acme",
|
|
912
|
+
}),
|
|
913
|
+
),
|
|
914
|
+
],
|
|
915
|
+
[
|
|
916
|
+
"listAuthorizedPrincipalIds",
|
|
917
|
+
unknownResourceKindMessage("Organization"),
|
|
918
|
+
(h) =>
|
|
919
|
+
h.query.listAuthorizedPrincipalIds({
|
|
920
|
+
resource: ref("Organization", "acme"),
|
|
921
|
+
principalKind: "identity_account",
|
|
922
|
+
relation: "can_view",
|
|
923
|
+
}),
|
|
924
|
+
],
|
|
925
|
+
[
|
|
926
|
+
"listResourceAccessByPrincipal",
|
|
927
|
+
unknownResourceKindMessage("Organization"),
|
|
928
|
+
(h) =>
|
|
929
|
+
h.query.listResourceAccessByPrincipal({
|
|
930
|
+
resource: ref("Organization", "acme"),
|
|
931
|
+
}),
|
|
932
|
+
],
|
|
933
|
+
[
|
|
934
|
+
"getPrincipalResourceRoles",
|
|
935
|
+
unknownResourceKindMessage("Organization"),
|
|
936
|
+
(h) =>
|
|
937
|
+
h.query.getPrincipalResourceRoles({
|
|
938
|
+
principal: ref("identity_account", ALICE_ID),
|
|
939
|
+
resource: ref("Organization", "acme"),
|
|
940
|
+
}),
|
|
941
|
+
],
|
|
942
|
+
[
|
|
943
|
+
"getPrincipalsCount",
|
|
944
|
+
unknownPrincipalKindMessage("People"),
|
|
945
|
+
(h) =>
|
|
946
|
+
h.query.getPrincipalsCount({ orgId: "acme", principalKind: "People" }),
|
|
947
|
+
],
|
|
948
|
+
];
|
|
949
|
+
|
|
950
|
+
for (const [name, copy, call] of cases) {
|
|
951
|
+
it(`${name}: INVALID_ARGUMENT with the pinned copy and the Authorizer never consulted`, async () => {
|
|
952
|
+
const h = await harness({ caller: alice });
|
|
953
|
+
h.authorizer.decision = { kind: "deny", reason: "" };
|
|
954
|
+
const error = await refusal(() => call(h));
|
|
955
|
+
expect(error.code).toBe(Code.InvalidArgument);
|
|
956
|
+
expect(error.rawMessage).toBe(copy);
|
|
957
|
+
expect(h.authorizer.checks).toEqual([]);
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
});
|