@adobe/spacecat-shared-mac-giver-client 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # Spacecat Shared - MacGiver Client
2
+
3
+ A thin client over the MacGiver FACS `/api/facs/permissions/check` endpoint for
4
+ evaluating a single user's permissions within an IMS organization. Consumed by
5
+ `spacecat-auth-service` at login to mint the `facs_permissions` JWT claim.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @adobe/spacecat-shared-mac-giver-client
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Create from a Universal context
16
+
17
+ The wrapper attaches the client to `context.macGiverClient` (place it after the
18
+ IMS client wrapper, since it requires `context.imsClient`):
19
+
20
+ ```javascript
21
+ import { macGiverClientWrapper } from '@adobe/spacecat-shared-mac-giver-client';
22
+
23
+ export const main = wrap(run)
24
+ .with(macGiverClientWrapper);
25
+ ```
26
+
27
+ Or construct directly:
28
+
29
+ ```javascript
30
+ import { MacGiverClient } from '@adobe/spacecat-shared-mac-giver-client';
31
+
32
+ const client = MacGiverClient.createFrom(context);
33
+ ```
34
+
35
+ ### Check an explicit list of permissions
36
+
37
+ Returns the subset of the requested permissions that are allowed:
38
+
39
+ ```javascript
40
+ const allowed = await client.checkListOfPermission({
41
+ userId: '17837D23...@AdobeID',
42
+ imsOrgId: 'AAAA...CCCC@AdobeOrg',
43
+ permissions: ['llmo/can_view', 'llmo/can_manage_users'],
44
+ });
45
+ // → ['llmo/can_view']
46
+ ```
47
+
48
+ ### Check all permissions in a namespace
49
+
50
+ Evaluates every permission defined in the given namespaces and returns the
51
+ allowed subset:
52
+
53
+ ```javascript
54
+ const allowed = await client.checkAllPermission({
55
+ userId: '17837D23...@AdobeID',
56
+ imsOrgId: 'AAAA...CCCC@AdobeOrg',
57
+ namespaces: ['llmo'],
58
+ });
59
+ // → ['llmo/can_view', 'llmo/can_manage_users']
60
+ ```
61
+
62
+ ## Behavior
63
+
64
+ - **Evaluated, none granted** — a `2xx` `SUCCESS` response with no `allowed: true`
65
+ entries (or a non-`SUCCESS` status) returns `[]`.
66
+ - **Could not evaluate** — a non-`2xx` response or transport failure **throws**
67
+ (with a `tag: 'macgiver'` warning), so callers can fail-safe (e.g. omit the
68
+ `facs_permissions` claim) rather than confusing an outage with "no permissions".
69
+ - The request is bounded by `AbortSignal.timeout` (default `5000` ms, tunable via
70
+ the `MACGIVER_TIMEOUT_MS` env var) since MacGiver sits on the login critical path.
71
+
72
+ ## Configuration
73
+
74
+ | Env var | Default | Description |
75
+ |---|---|---|
76
+ | `MACGIVER_BASE_URL` | `http://localhost:8080` | MacGiver service base URL. |
77
+ | `MACGIVER_TIMEOUT_MS` | `5000` | Per-request timeout in milliseconds. |
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@adobe/spacecat-shared-mac-giver-client",
3
+ "version": "1.0.0",
4
+ "description": "Shared modules of the Spacecat Services - MacGiver Client",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.0.0 <25.0.0",
8
+ "npm": ">=10.9.0 <12.0.0"
9
+ },
10
+ "main": "src/index.js",
11
+ "scripts": {
12
+ "test": "c8 mocha",
13
+ "lint": "eslint .",
14
+ "lint:fix": "eslint --fix .",
15
+ "clean": "rm -rf package-lock.json node_modules"
16
+ },
17
+ "mocha": {
18
+ "reporter": "mocha-multi-reporters",
19
+ "reporter-options": "configFile=.mocha-multi.json",
20
+ "spec": "test/**/*.test.js"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/adobe/spacecat-shared.git"
25
+ },
26
+ "author": "",
27
+ "license": "Apache-2.0",
28
+ "bugs": {
29
+ "url": "https://github.com/adobe/spacecat-shared/issues"
30
+ },
31
+ "homepage": "https://github.com/adobe/spacecat-shared/packages/spacecat-shared-mac-giver-client/#readme",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {},
36
+ "devDependencies": {
37
+ "chai": "6.2.2",
38
+ "chai-as-promised": "8.0.2",
39
+ "nock": "14.0.11",
40
+ "sinon": "21.0.3",
41
+ "sinon-chai": "4.0.1"
42
+ }
43
+ }
package/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import MacGiverClient from './mac-giver-client.js';
14
+ import { macGiverClientWrapper } from './mac-giver-client-wrapper.js';
15
+
16
+ export {
17
+ MacGiverClient,
18
+ macGiverClientWrapper,
19
+ };
@@ -0,0 +1,29 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import MacGiverClient from './mac-giver-client.js';
14
+
15
+ /**
16
+ * Wrapper that creates a MacGiverClient and attaches it to context.macGiverClient.
17
+ * Must be placed after imsClientWrapper in the chain — it requires context.imsClient.
18
+ *
19
+ * @param {UniversalAction} fn
20
+ * @returns {function(object, UniversalContext): Promise<Response>}
21
+ */
22
+ export function macGiverClientWrapper(fn) {
23
+ return async (request, context) => {
24
+ if (!context.macGiverClient) {
25
+ context.macGiverClient = MacGiverClient.createFrom(context);
26
+ }
27
+ return fn(request, context);
28
+ };
29
+ }
@@ -0,0 +1,191 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ const DEFAULT_BASE_URL = 'http://localhost:8080';
14
+ const CHECK_PATH = '/api/facs/permissions/check';
15
+ // MacGiver sits on the login critical path; bound the request so a hung
16
+ // dependency cannot hang login indefinitely. AbortSignal.timeout is native in
17
+ // Node 22+.
18
+ const REQUEST_TIMEOUT_MS = 5000;
19
+
20
+ export default class MacGiverClient {
21
+ static createFrom(context) {
22
+ const { log = console } = context;
23
+ if (!context.imsClient) {
24
+ throw new Error('MacGiverClient.createFrom: context.imsClient is required');
25
+ }
26
+ const macGiverBaseUrl = context.env?.MACGIVER_BASE_URL || DEFAULT_BASE_URL;
27
+ const requestTimeoutMs = Number(context.env?.MACGIVER_TIMEOUT_MS) || REQUEST_TIMEOUT_MS;
28
+ return new MacGiverClient({
29
+ macGiverBaseUrl, imsClient: context.imsClient, log, requestTimeoutMs,
30
+ });
31
+ }
32
+
33
+ constructor({
34
+ macGiverBaseUrl, imsClient, log, requestTimeoutMs = REQUEST_TIMEOUT_MS,
35
+ }) {
36
+ this.macGiverBaseUrl = macGiverBaseUrl;
37
+ this.imsClient = imsClient;
38
+ this.log = log;
39
+ this.requestTimeoutMs = requestTimeoutMs;
40
+ }
41
+
42
+ /**
43
+ * Calls the MacGiver `/api/facs/permissions/check` endpoint for a single
44
+ * (user, org) pair and returns the subset of evaluated permissions where
45
+ * `allowed === true`.
46
+ *
47
+ * `permissions` and `namespaces` map directly onto the request body:
48
+ * - `permissions` — evaluate this explicit list of (possibly namespaced)
49
+ * permissions.
50
+ * - `namespaces` — evaluate every permission defined in these namespaces.
51
+ * At least one must be non-empty (MacGiver rejects a request where both are
52
+ * empty); the public methods below each supply exactly one.
53
+ *
54
+ * The `X-User-Token` header is intentionally omitted — FACS uses it only for
55
+ * logging, never for the evaluation itself, so the service token alone is
56
+ * sufficient for SpaceCat's use.
57
+ *
58
+ * Two outcomes are reported distinctly:
59
+ * - **Evaluated, none granted** — 2xx with `status: 'SUCCESS'` and no
60
+ * `allowed: true` entries (or a non-SUCCESS status). Returns `[]`.
61
+ * - **Could not evaluate** — non-2xx, or a transport-layer failure. **Throws**
62
+ * so callers (e.g. login.js) can fail-safe and omit the `facs_permissions`
63
+ * claim rather than confusing an outage with "no permissions". The non-2xx
64
+ * path is logged with a `tag: 'macgiver'` warning for independent alerting.
65
+ *
66
+ * @param {object} args
67
+ * @param {string} args.userId - IMS user id (subject).
68
+ * @param {string} args.imsOrgId - IMS org id (object).
69
+ * @param {string[]} [args.permissions] - Explicit permissions to evaluate.
70
+ * @param {string[]} [args.namespaces] - Namespaces whose permissions to evaluate.
71
+ * @returns {Promise<string[]>} Allowed permission names.
72
+ */
73
+ async #check({
74
+ userId, imsOrgId, permissions = [], namespaces = [],
75
+ }) {
76
+ const url = `${this.macGiverBaseUrl}${CHECK_PATH}`;
77
+ // FACS resolves permissions via `<object_type>#<permission>` relations.
78
+ // The canonical IMS-org subject type in FACS is `org`, not `organization`
79
+ // — sending `organization` here makes FACS fail to resolve the relation
80
+ // and mac-giver surfaces it as a 500.
81
+ const subject = { type: 'user', id: userId };
82
+ const object = { type: 'org', id: imsOrgId };
83
+ // Include only the non-empty list. MacGiver requires at least one of
84
+ // `permissions` / `namespaces`; the public methods each supply exactly one,
85
+ // so omitting the empty one keeps the body symmetric and future-proof
86
+ // against stricter MacGiver validation.
87
+ const requestBody = { subject, object };
88
+ if (permissions.length > 0) {
89
+ requestBody.permissions = permissions;
90
+ }
91
+ if (namespaces.length > 0) {
92
+ requestBody.namespaces = namespaces;
93
+ }
94
+
95
+ // ImsClient.getServiceAccessToken() resolves to
96
+ // { access_token, expires_in, token_type } — the bearer is in `access_token`.
97
+ // Templating the whole object would send `Bearer [object Object]` and FACS
98
+ // would reject it with 401.
99
+ const { access_token: serviceToken } = await this.imsClient.getServiceAccessToken();
100
+ if (!serviceToken) {
101
+ throw new Error('MacGiver check: IMS service token is missing access_token');
102
+ }
103
+
104
+ const res = await fetch(url, {
105
+ method: 'POST',
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ Authorization: `Bearer ${serviceToken}`,
109
+ },
110
+ body: JSON.stringify(requestBody),
111
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
112
+ });
113
+
114
+ if (!res.ok) {
115
+ // Distinguish "could not evaluate" from "evaluated, none". Login wraps
116
+ // this call in a try/catch that logs and omits facs_permissions, so a
117
+ // throw here is the right shape: the user still logs in, but without
118
+ // FACS perms, and ops gets a tagged signal that MacGiver is degraded.
119
+ this.log.warn(
120
+ {
121
+ tag: 'macgiver', status: res.status, userId, imsOrgId,
122
+ },
123
+ 'MacGiver permission lookup returned non-2xx',
124
+ );
125
+ throw new Error(`MacGiver returned ${res.status}`);
126
+ }
127
+
128
+ const json = await res.json();
129
+ if (json.status !== 'SUCCESS' || !json.results) {
130
+ // A non-SUCCESS status is an unexpected MacGiver condition, not "the user
131
+ // has no permissions" — log it (errors-only) so operators can tell them
132
+ // apart. A SUCCESS-with-no-results is genuinely empty and stays quiet.
133
+ if (json.status !== 'SUCCESS') {
134
+ this.log.warn(
135
+ {
136
+ tag: 'macgiver', status: json.status, userId, imsOrgId,
137
+ },
138
+ 'MacGiver returned a non-SUCCESS status',
139
+ );
140
+ }
141
+ return [];
142
+ }
143
+
144
+ return Object.entries(json.results)
145
+ .filter(([, v]) => v?.allowed === true)
146
+ .map(([permission]) => permission);
147
+ }
148
+
149
+ /**
150
+ * Checks an explicit list of FACS permissions for a (user, org) pair in one
151
+ * round-trip. Returns the subset of the requested permissions where
152
+ * `allowed === true`. See `#check` for the evaluated-vs-unavailable contract.
153
+ *
154
+ * @param {object} args
155
+ * @param {string} args.userId
156
+ * @param {string} args.imsOrgId
157
+ * @param {string[]} args.permissions - The permissions to evaluate.
158
+ * @returns {Promise<string[]>} Allowed subset of `permissions`.
159
+ */
160
+ async checkListOfPermission({ userId, imsOrgId, permissions }) {
161
+ if (!userId || !imsOrgId) {
162
+ throw new Error('MacGiver checkListOfPermission: userId and imsOrgId are required');
163
+ }
164
+ if (!Array.isArray(permissions) || permissions.length === 0) {
165
+ throw new Error('MacGiver checkListOfPermission: permissions must be a non-empty array');
166
+ }
167
+ return this.#check({ userId, imsOrgId, permissions });
168
+ }
169
+
170
+ /**
171
+ * Returns every FACS permission the (user, org) pair holds across the given
172
+ * namespaces — MacGiver evaluates all permissions defined in each namespace
173
+ * and this returns the allowed subset. See `#check` for the
174
+ * evaluated-vs-unavailable contract.
175
+ *
176
+ * @param {object} args
177
+ * @param {string} args.userId
178
+ * @param {string} args.imsOrgId
179
+ * @param {string[]} args.namespaces - The namespaces whose permissions to evaluate.
180
+ * @returns {Promise<string[]>} All allowed permissions across `namespaces`.
181
+ */
182
+ async checkAllPermission({ userId, imsOrgId, namespaces }) {
183
+ if (!userId || !imsOrgId) {
184
+ throw new Error('MacGiver checkAllPermission: userId and imsOrgId are required');
185
+ }
186
+ if (!Array.isArray(namespaces) || namespaces.length === 0) {
187
+ throw new Error('MacGiver checkAllPermission: namespaces must be a non-empty array');
188
+ }
189
+ return this.#check({ userId, imsOrgId, namespaces });
190
+ }
191
+ }