mcp-authz 0.1.0 → 0.3.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 +553 -26
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +228 -0
- package/dist/index.d.ts +31 -209
- package/dist/index.js +417 -544
- package/dist/ladder-CUzOKudC.js +407 -0
- package/dist/ladder-D18eJ7tD.d.ts +32 -0
- package/dist/openapi.d.ts +112 -0
- package/dist/openapi.js +254 -0
- package/dist/permissions-module-DxCHuE-N.d.ts +31 -0
- package/dist/policy-BBp3Jq6G.js +226 -0
- package/dist/{policy-CnQj53Hq.d.ts → policy-DuZbwrKf.d.ts} +29 -1
- package/dist/policy.d.ts +2 -2
- package/dist/policy.js +1 -202
- package/dist/proxy.d.ts +46 -0
- package/dist/proxy.js +492 -0
- package/dist/testing.d.ts +44 -0
- package/dist/testing.js +176 -0
- package/dist/tools-BQE1O-7P.d.ts +271 -0
- package/dist/verifier-D6VIAYuT.js +152 -0
- package/dist/verifier-DF6gUMQ6.d.ts +84 -0
- package/package.json +35 -11
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { a as policyDenied, i as emitDecision, o as principalLabel, r as verifierFor, s as AccessDeniedError } from "./verifier-D6VIAYuT.js";
|
|
2
|
+
import { i as reconcile } from "./policy-BBp3Jq6G.js";
|
|
3
|
+
import { getOAuthProtectedResourceMetadataUrl, oauthMetadataResponse, requireBearerAuth } from "@modelcontextprotocol/server";
|
|
4
|
+
//#region src/openapi.ts
|
|
5
|
+
/** The methods OpenAPI defines on a path item. Anything else there is not an operation. */
|
|
6
|
+
const METHODS = [
|
|
7
|
+
"get",
|
|
8
|
+
"put",
|
|
9
|
+
"post",
|
|
10
|
+
"delete",
|
|
11
|
+
"options",
|
|
12
|
+
"head",
|
|
13
|
+
"patch",
|
|
14
|
+
"trace"
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* What the document says this API can do, read off the document.
|
|
18
|
+
*
|
|
19
|
+
* The mirror of `recordCapabilities`, and it needs no running server: an
|
|
20
|
+
* OpenAPI file is the catalogue, already sitting in your repository. Feed the
|
|
21
|
+
* result to `toPermissionsModule` for a map priced `TODO:unassigned`, which no
|
|
22
|
+
* role grants and the boot refuses until somebody decides what each operation
|
|
23
|
+
* costs.
|
|
24
|
+
*/
|
|
25
|
+
function recordOperations(spec) {
|
|
26
|
+
const operations = [];
|
|
27
|
+
const seen = /* @__PURE__ */ new Map();
|
|
28
|
+
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
|
29
|
+
if (!item || typeof item !== "object") continue;
|
|
30
|
+
for (const method of METHODS) {
|
|
31
|
+
const operation = item[method];
|
|
32
|
+
if (!operation || typeof operation !== "object") continue;
|
|
33
|
+
const operationId = operation.operationId;
|
|
34
|
+
if (typeof operationId !== "string" || operationId.length === 0) throw new Error(`${method.toUpperCase()} ${path} has no operationId, and the permission map is keyed by it. Give the operation an operationId in the document.`);
|
|
35
|
+
const duplicate = seen.get(operationId);
|
|
36
|
+
if (duplicate) throw new Error(`operationId '${operationId}' is used by both ${duplicate} and ${method.toUpperCase()} ${path}. One id would price two routes, so the map could not say which.`);
|
|
37
|
+
seen.set(operationId, `${method.toUpperCase()} ${path}`);
|
|
38
|
+
operations.push({
|
|
39
|
+
operationId,
|
|
40
|
+
method,
|
|
41
|
+
path
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
names: [...seen.keys()].sort(),
|
|
47
|
+
operations
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The document as this caller should see it: their operations, and nothing else.
|
|
52
|
+
*
|
|
53
|
+
* A path item left with no operations is dropped, so the reader is not offered
|
|
54
|
+
* a route with no verbs. `components` is deliberately left whole — pruning it
|
|
55
|
+
* means walking the `$ref` graph, and a schema nobody references costs a few
|
|
56
|
+
* hundred tokens where a wrongly-pruned one breaks the document.
|
|
57
|
+
*/
|
|
58
|
+
function filterSpec(spec, principal, permissions) {
|
|
59
|
+
const paths = {};
|
|
60
|
+
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
|
61
|
+
if (!item || typeof item !== "object") continue;
|
|
62
|
+
const kept = {};
|
|
63
|
+
let any = false;
|
|
64
|
+
for (const [key, value] of Object.entries(item)) {
|
|
65
|
+
if (!METHODS.find((candidate) => candidate === key)) {
|
|
66
|
+
kept[key] = value;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const operationId = value?.operationId;
|
|
70
|
+
const permission = typeof operationId === "string" ? permissions[operationId] : void 0;
|
|
71
|
+
if (permission !== void 0 && principal.can(permission)) {
|
|
72
|
+
kept[key] = value;
|
|
73
|
+
any = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (any) paths[path] = kept;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
...spec,
|
|
80
|
+
paths
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* An OAuth 2.1 resource server in front of an API you already have.
|
|
85
|
+
*
|
|
86
|
+
* Anything the document does not describe is refused. That is the same rule as
|
|
87
|
+
* the MCP side — a capability nobody priced is reachable by everyone or by
|
|
88
|
+
* nobody, with no error to read — and it means routes you deliberately leave
|
|
89
|
+
* out of the spec (a health check, static files) belong outside this wrapper
|
|
90
|
+
* rather than behind it.
|
|
91
|
+
*/
|
|
92
|
+
function createOpenApiFetch(options) {
|
|
93
|
+
const { spec, permissions, resourceServerUrl, oauthMetadata, requiredScopes = ["api"], supportedScopes, policy, specPath = "/openapi.json", emitter, upstream } = options;
|
|
94
|
+
if (policy && options.authorize) throw new Error("Pass either `policy` or `authorize`, not both.");
|
|
95
|
+
if (!policy && !options.authorize) throw new Error("createOpenApiFetch needs a `policy` or an `authorize`.");
|
|
96
|
+
const record = recordOperations(spec);
|
|
97
|
+
const priced = /* @__PURE__ */ new Map();
|
|
98
|
+
for (const name of record.names) {
|
|
99
|
+
const permission = permissions[name];
|
|
100
|
+
if (permission === void 0) throw new Error(`The permission map puts no price on '${name}'. Every operation in the document needs an entry; recordOperations() and toPermissionsModule() generate the starting map.`);
|
|
101
|
+
priced.set(name, permission);
|
|
102
|
+
}
|
|
103
|
+
for (const name of Object.keys(permissions)) if (!priced.has(name)) throw new Error(`The permission map prices '${name}', which the document describes no operation for. A renamed operationId leaves an entry behind that stops gating anything.`);
|
|
104
|
+
if (policy) {
|
|
105
|
+
const { error, warning } = reconcile(policy.roles, priced);
|
|
106
|
+
if (warning) console.warn(warning);
|
|
107
|
+
if (error) throw new Error(error);
|
|
108
|
+
}
|
|
109
|
+
const routes = record.operations.map((operation) => ({
|
|
110
|
+
...operation,
|
|
111
|
+
match: matcher(operation.path)
|
|
112
|
+
})).sort((a, b) => templateCount(a.path) - templateCount(b.path) || b.path.length - a.path.length);
|
|
113
|
+
const { tokenVerifier, mapIdentity } = verifierFor({
|
|
114
|
+
oauthMetadata,
|
|
115
|
+
resourceServerUrl,
|
|
116
|
+
...options.verifier ? { verifier: options.verifier } : {},
|
|
117
|
+
...options.tokenVerifier ? { tokenVerifier: options.tokenVerifier } : {},
|
|
118
|
+
...options.identityFromAuth ? { identityFromAuth: options.identityFromAuth } : {}
|
|
119
|
+
});
|
|
120
|
+
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceServerUrl);
|
|
121
|
+
const metadataOptions = {
|
|
122
|
+
oauthMetadata,
|
|
123
|
+
resourceServerUrl,
|
|
124
|
+
scopesSupported: [.../* @__PURE__ */ new Set([...requiredScopes, ...supportedScopes ?? []])]
|
|
125
|
+
};
|
|
126
|
+
const basePath = resourceServerUrl.pathname.replace(/\/$/, "");
|
|
127
|
+
return async function openApiFetch(request) {
|
|
128
|
+
const metadata = oauthMetadataResponse(request, metadataOptions);
|
|
129
|
+
if (metadata) return metadata;
|
|
130
|
+
const { pathname } = new URL(request.url);
|
|
131
|
+
if (!pathname.startsWith(basePath)) return new Response(`No API at ${pathname}. This server answers under ${basePath || "/"}, which is also the audience its tokens must carry.\n`, {
|
|
132
|
+
status: 404,
|
|
133
|
+
headers: { "Content-Type": "text/plain" }
|
|
134
|
+
});
|
|
135
|
+
const route = pathname.slice(basePath.length) || "/";
|
|
136
|
+
const auth = await requireBearerAuth({
|
|
137
|
+
verifier: tokenVerifier,
|
|
138
|
+
requiredScopes,
|
|
139
|
+
resourceMetadataUrl
|
|
140
|
+
})(request);
|
|
141
|
+
if (auth instanceof Response) return auth;
|
|
142
|
+
let identity;
|
|
143
|
+
let principal;
|
|
144
|
+
try {
|
|
145
|
+
identity = mapIdentity(auth);
|
|
146
|
+
principal = policy ? policy(identity) : await options.authorize(identity);
|
|
147
|
+
if (principal.permissions.length === 0) {
|
|
148
|
+
await emitDecision(options.onDecision, principal, "deny", "not_permitted", emitter);
|
|
149
|
+
throw AccessDeniedError.notPermitted(principalLabel(principal));
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof AccessDeniedError) return policyDenied(error);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
if (route === specPath) {
|
|
156
|
+
await emitDecision(options.onDecision, principal, "allow", void 0, emitter);
|
|
157
|
+
return Response.json(filterSpec(spec, principal, permissions));
|
|
158
|
+
}
|
|
159
|
+
const method = request.method.toLowerCase();
|
|
160
|
+
const matched = routes.find((candidate) => candidate.method === method && candidate.match.test(route));
|
|
161
|
+
if (!matched) {
|
|
162
|
+
await emitDecision(options.onDecision, principal, "deny", "no_such_operation", emitter);
|
|
163
|
+
return Response.json({
|
|
164
|
+
error: "not_found",
|
|
165
|
+
error_description: `${request.method} ${route} is not in the document this server gates, so it is refused. Describe it in the OpenAPI document, or serve it outside this wrapper.`
|
|
166
|
+
}, { status: 404 });
|
|
167
|
+
}
|
|
168
|
+
const permission = priced.get(matched.operationId);
|
|
169
|
+
if (!principal.can(permission)) {
|
|
170
|
+
await emitDecision(options.onDecision, principal, "deny", "not_permitted", emitter);
|
|
171
|
+
return policyDenied(AccessDeniedError.notPermitted(principalLabel(principal), `the permission '${permission}'`));
|
|
172
|
+
}
|
|
173
|
+
await emitDecision(options.onDecision, principal, "allow", void 0, emitter);
|
|
174
|
+
const base = {
|
|
175
|
+
type: "mcp_authz.audit.v1",
|
|
176
|
+
callId: crypto.randomUUID(),
|
|
177
|
+
issuer: principal.issuer,
|
|
178
|
+
sub: principal.sub,
|
|
179
|
+
...principal.email ? { email: principal.email } : {},
|
|
180
|
+
...principal.domain ? { domain: principal.domain } : {},
|
|
181
|
+
...emitter ? { emitter } : {},
|
|
182
|
+
kind: "operation",
|
|
183
|
+
name: matched.operationId,
|
|
184
|
+
permission,
|
|
185
|
+
resource: route
|
|
186
|
+
};
|
|
187
|
+
const { onAudit, onAuditError } = options;
|
|
188
|
+
if (!onAudit) return upstream(request, principal);
|
|
189
|
+
const started = performance.now();
|
|
190
|
+
try {
|
|
191
|
+
await onAudit({
|
|
192
|
+
...base,
|
|
193
|
+
decision: "allow",
|
|
194
|
+
phase: "attempt",
|
|
195
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
196
|
+
});
|
|
197
|
+
} catch {
|
|
198
|
+
return Response.json({
|
|
199
|
+
error: "unavailable",
|
|
200
|
+
error_description: "The audit log refused the write, so the call did not run."
|
|
201
|
+
}, { status: 503 });
|
|
202
|
+
}
|
|
203
|
+
let response;
|
|
204
|
+
try {
|
|
205
|
+
response = await upstream(request, principal);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
await deliver(onAudit, onAuditError, {
|
|
208
|
+
...base,
|
|
209
|
+
decision: "allow",
|
|
210
|
+
phase: "failure",
|
|
211
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
212
|
+
durationMs: performance.now() - started,
|
|
213
|
+
error: error instanceof Error ? error.message : String(error)
|
|
214
|
+
});
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
await deliver(onAudit, onAuditError, {
|
|
218
|
+
...base,
|
|
219
|
+
decision: "allow",
|
|
220
|
+
phase: response.ok ? "success" : "failure",
|
|
221
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
222
|
+
durationMs: performance.now() - started,
|
|
223
|
+
...response.ok ? {} : { error: `HTTP ${response.status}` }
|
|
224
|
+
});
|
|
225
|
+
return response;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async function deliver(sink, onError, event) {
|
|
229
|
+
try {
|
|
230
|
+
await sink(event);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
try {
|
|
233
|
+
await onError?.({
|
|
234
|
+
error,
|
|
235
|
+
event
|
|
236
|
+
});
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function templateCount(path) {
|
|
241
|
+
return (path.match(/\{/g) ?? []).length;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* A path template as a matcher.
|
|
245
|
+
*
|
|
246
|
+
* `{id}` matches one segment and never a `/`, so `/cases/{id}` does not answer
|
|
247
|
+
* for `/cases/C1/notes` — which would hand a caller a route nobody priced.
|
|
248
|
+
*/
|
|
249
|
+
function matcher(template) {
|
|
250
|
+
const pattern = template.split("/").map((segment) => /^\{[^{}]+\}$/.test(segment) ? "[^/]+" : segment.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)).join("/");
|
|
251
|
+
return new RegExp(`^${pattern}$`);
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
export { createOpenApiFetch, filterSpec, recordOperations };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/permissions-module.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Rendering a permission map as source, shared by everything that records a
|
|
4
|
+
* catalogue.
|
|
5
|
+
*
|
|
6
|
+
* Its own module because the MCP recorder needs an optional peer dependency to
|
|
7
|
+
* talk to a server, and the OpenAPI one only needs a file it was handed. A
|
|
8
|
+
* caller after the scaffold should not have to install a client to get it.
|
|
9
|
+
*/
|
|
10
|
+
type PermissionMapRecord = {
|
|
11
|
+
/** Every capability, sorted, labelled the way the gate labels it. */
|
|
12
|
+
names: string[];
|
|
13
|
+
/** A digest per capability, when the source can produce one. */
|
|
14
|
+
fingerprints?: Record<string, string>;
|
|
15
|
+
/** `resource:` labels to the URI or template each answers on. */
|
|
16
|
+
resourceUris?: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A permission map to start from, priced so it cannot be forgotten.
|
|
20
|
+
*
|
|
21
|
+
* Every capability gets a placeholder no role grants, which `reconcile` reports
|
|
22
|
+
* as unreachable and the boot refuses. The scaffold is deliberately useless
|
|
23
|
+
* until a person has decided what each capability costs — that decision is the
|
|
24
|
+
* whole point of the file, and a default would quietly make it for them.
|
|
25
|
+
*
|
|
26
|
+
* Returned as source rather than written, so the caller chooses where it lands.
|
|
27
|
+
*/
|
|
28
|
+
declare const UNASSIGNED = "TODO:unassigned";
|
|
29
|
+
declare function toPermissionsModule(record: PermissionMapRecord): string;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { UNASSIGNED as n, toPermissionsModule as r, PermissionMapRecord as t };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
//#region src/policy.ts
|
|
2
|
+
/**
|
|
3
|
+
* A policy from a plain object.
|
|
4
|
+
*
|
|
5
|
+
* Written inline as a literal, the permission names become a string-literal
|
|
6
|
+
* union, so a tool requiring `cases:wrtie` fails to compile. Handed
|
|
7
|
+
* `JSON.parse(process.env.MCP_POLICY)` or a parsed YAML file the same call
|
|
8
|
+
* still validates its shape and still reconciles against the tools at boot —
|
|
9
|
+
* it just cannot type-check names TypeScript never sees. Nothing here reads the
|
|
10
|
+
* environment or the filesystem: how an application loads configuration is the
|
|
11
|
+
* application's business.
|
|
12
|
+
*
|
|
13
|
+
* There is deliberately no `default` setting. Matching no rule means no
|
|
14
|
+
* permissions, and that is not configurable — a default that can be widened
|
|
15
|
+
* eventually is, and the failure is silent.
|
|
16
|
+
*/
|
|
17
|
+
function definePolicy(spec) {
|
|
18
|
+
if (!isRecord(spec)) throw new Error("Policy must be an object.");
|
|
19
|
+
for (const key of Object.keys(spec)) if (![
|
|
20
|
+
"permissions",
|
|
21
|
+
"roles",
|
|
22
|
+
"rules"
|
|
23
|
+
].includes(key)) throw new Error(`Policy has unknown field '${key}'.`);
|
|
24
|
+
if (!isRecord(spec.roles)) throw new Error("Policy 'roles' must be an object.");
|
|
25
|
+
if (!Array.isArray(spec.rules)) throw new Error("Policy 'rules' must be an array.");
|
|
26
|
+
const catalogue = spec.permissions === void 0 ? void 0 : stringList(spec.permissions, "Policy 'permissions'");
|
|
27
|
+
if (catalogue?.includes("*")) throw new Error("Policy 'permissions' must name concrete permissions, not '*'.");
|
|
28
|
+
const known = catalogue ? new Set(catalogue) : void 0;
|
|
29
|
+
const roles = /* @__PURE__ */ new Map();
|
|
30
|
+
for (const [role, permissions] of Object.entries(spec.roles ?? {})) {
|
|
31
|
+
if (!role) throw new Error("Policy role names must not be empty.");
|
|
32
|
+
const validated = stringList(permissions, `Policy role '${role}'`);
|
|
33
|
+
if (validated.includes("*") && validated.length > 1) throw new Error(`Policy role '${role}' grants '*' and must not list redundant permissions.`);
|
|
34
|
+
for (const permission of validated) if (permission !== "*" && known && !known.has(permission)) throw new Error(`Policy role '${role}' grants '${permission}', which is absent from the permission catalogue.`);
|
|
35
|
+
roles.set(role, validated);
|
|
36
|
+
}
|
|
37
|
+
const rules = (spec.rules ?? []).map((rule, index) => {
|
|
38
|
+
const at = `Policy rule ${index}`;
|
|
39
|
+
if (!isRecord(rule)) throw new Error(`${at} must be an object.`);
|
|
40
|
+
for (const key of Object.keys(rule)) if (![
|
|
41
|
+
"match",
|
|
42
|
+
"role",
|
|
43
|
+
"deny"
|
|
44
|
+
].includes(key)) throw new Error(`${at} has unknown field '${key}'.`);
|
|
45
|
+
if (rule.deny !== void 0 && typeof rule.deny !== "boolean") throw new Error(`${at} 'deny' must be a boolean.`);
|
|
46
|
+
if (!rule.deny && rule.role === void 0) throw new Error(`${at} grants nothing: it needs a 'role' or 'deny: true'.`);
|
|
47
|
+
const named = roleNames(rule.role, at);
|
|
48
|
+
for (const role of named) if (!roles.has(role)) throw new Error(`${at} names role '${role}', which is not defined.`);
|
|
49
|
+
return {
|
|
50
|
+
index,
|
|
51
|
+
match: validateMatch(rule.match, at),
|
|
52
|
+
deny: rule.deny === true,
|
|
53
|
+
roles: named
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
const evaluate = (identity) => {
|
|
57
|
+
const matched = rules.filter((rule) => matches(rule.match, identity));
|
|
58
|
+
const deniedBy = matched.find((rule) => rule.deny);
|
|
59
|
+
const held = deniedBy ? [] : [...new Set(matched.flatMap((rule) => rule.roles))];
|
|
60
|
+
return {
|
|
61
|
+
matched,
|
|
62
|
+
deniedBy,
|
|
63
|
+
principal: createPrincipal(identity, held, deniedBy ? [] : [...new Set(held.flatMap((role) => [...roles.get(role) ?? []]))])
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
const policy = (identity) => evaluate(identity).principal;
|
|
67
|
+
const explain = (identity) => {
|
|
68
|
+
const { matched, deniedBy, principal } = evaluate(identity);
|
|
69
|
+
return {
|
|
70
|
+
principal,
|
|
71
|
+
matched: matched.map(toMatchedRule),
|
|
72
|
+
...deniedBy ? { deniedBy: toMatchedRule(deniedBy) } : {}
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
const concrete = catalogue ?? [...new Set([...roles.values()].flat().filter((permission) => permission !== "*"))];
|
|
76
|
+
return Object.assign(policy, {
|
|
77
|
+
roles,
|
|
78
|
+
permissions: concrete,
|
|
79
|
+
explain
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function toMatchedRule(rule) {
|
|
83
|
+
return {
|
|
84
|
+
index: rule.index,
|
|
85
|
+
match: rule.match,
|
|
86
|
+
roles: [...rule.roles],
|
|
87
|
+
deny: rule.deny
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** A typed permission vocabulary for async authorizers that do not use a static policy. */
|
|
91
|
+
function definePermissions(permissions) {
|
|
92
|
+
const catalogue = stringList(permissions, "Permission catalogue");
|
|
93
|
+
if (catalogue.includes("*")) throw new Error("Permission catalogue must name concrete permissions, not '*'.");
|
|
94
|
+
return { permissions: catalogue };
|
|
95
|
+
}
|
|
96
|
+
/** Build a sound principal from an external entitlement decision. */
|
|
97
|
+
function createPrincipal(identity, roles, permissions) {
|
|
98
|
+
if (!isRecord(identity) || typeof identity.sub !== "string" || identity.sub.length === 0) throw new Error("Principal identity needs a non-empty subject.");
|
|
99
|
+
if (typeof identity.issuer !== "string" || identity.issuer.length === 0) throw new Error("Principal identity needs a non-empty issuer.");
|
|
100
|
+
if (identity.email !== void 0 && identity.email.length === 0) throw new Error("Principal identity email must not be empty.");
|
|
101
|
+
const heldRoles = [...new Set(stringList(roles, "Principal roles"))];
|
|
102
|
+
const heldPermissions = [...new Set(stringList(permissions, "Principal permissions"))];
|
|
103
|
+
const wildcard = heldPermissions.includes("*");
|
|
104
|
+
return {
|
|
105
|
+
issuer: identity.issuer,
|
|
106
|
+
sub: identity.sub,
|
|
107
|
+
email: identity.email,
|
|
108
|
+
domain: identity.domain,
|
|
109
|
+
roles: heldRoles,
|
|
110
|
+
permissions: heldPermissions,
|
|
111
|
+
can: (permission) => wildcard || heldPermissions.includes(permission)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function isRecord(value) {
|
|
115
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
116
|
+
}
|
|
117
|
+
function stringList(value, at) {
|
|
118
|
+
if (!Array.isArray(value)) throw new Error(`${at} must be an array of permission strings.`);
|
|
119
|
+
const result = [];
|
|
120
|
+
for (const entry of value) {
|
|
121
|
+
if (typeof entry !== "string" || entry.length === 0) throw new Error(`${at} must be an array of non-empty permission strings.`);
|
|
122
|
+
if (!result.includes(entry)) result.push(entry);
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
function roleNames(value, at) {
|
|
127
|
+
if (value === void 0) return [];
|
|
128
|
+
const values = Array.isArray(value) ? value : [value];
|
|
129
|
+
if (values.length === 0 || values.some((role) => typeof role !== "string" || role.length === 0)) throw new Error(`${at} 'role' must be a non-empty role name or array of role names.`);
|
|
130
|
+
return [...new Set(values)];
|
|
131
|
+
}
|
|
132
|
+
function validateMatch(value, at) {
|
|
133
|
+
if (value === void 0) return {};
|
|
134
|
+
if (!isRecord(value)) throw new Error(`${at} 'match' must be an object.`);
|
|
135
|
+
for (const key of Object.keys(value)) if (![
|
|
136
|
+
"issuer",
|
|
137
|
+
"sub",
|
|
138
|
+
"email",
|
|
139
|
+
"domain",
|
|
140
|
+
"claim"
|
|
141
|
+
].includes(key)) throw new Error(`${at} match has unknown field '${key}'.`);
|
|
142
|
+
for (const key of [
|
|
143
|
+
"issuer",
|
|
144
|
+
"sub",
|
|
145
|
+
"email",
|
|
146
|
+
"domain"
|
|
147
|
+
]) {
|
|
148
|
+
const field = value[key];
|
|
149
|
+
if (field !== void 0 && (typeof field !== "string" || field.length === 0)) throw new Error(`${at} match '${key}' must be a non-empty string.`);
|
|
150
|
+
}
|
|
151
|
+
const claim = value.claim;
|
|
152
|
+
if (claim !== void 0) {
|
|
153
|
+
if (!isRecord(claim)) throw new Error(`${at} match 'claim' must be an object.`);
|
|
154
|
+
for (const [path, expected] of Object.entries(claim)) if (!path || typeof expected !== "string" || !expected) throw new Error(`${at} match claim entries must have non-empty string names and values.`);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
function matches(match, identity) {
|
|
159
|
+
if (match.issuer !== void 0 && match.issuer !== identity.issuer) return false;
|
|
160
|
+
if (match.sub !== void 0 && match.sub !== identity.sub) return false;
|
|
161
|
+
if (match.email !== void 0 && (!identity.emailVerified || !equalsFold(match.email, identity.email))) return false;
|
|
162
|
+
if (match.domain !== void 0 && !equalsFold(match.domain, domainOf(identity))) return false;
|
|
163
|
+
for (const [path, expected] of Object.entries(match.claim ?? {})) {
|
|
164
|
+
const actual = claimAt(identity.claims, path);
|
|
165
|
+
if (!(Array.isArray(actual) ? actual.includes(expected) : actual === expected)) return false;
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
function equalsFold(a, b) {
|
|
170
|
+
return b !== void 0 && a.toLowerCase() === b.toLowerCase();
|
|
171
|
+
}
|
|
172
|
+
/** The `hd` claim when the AS passes it, otherwise whatever follows the `@`. */
|
|
173
|
+
function domainOf(identity) {
|
|
174
|
+
if (!identity.emailVerified || !identity.email) return void 0;
|
|
175
|
+
if (identity.domain) return identity.domain;
|
|
176
|
+
const at = identity.email.lastIndexOf("@");
|
|
177
|
+
return at === -1 ? void 0 : identity.email.slice(at + 1);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A literal claim name wins over a dotted path, because the namespaced claims
|
|
181
|
+
* an IdP actually emits are full of dots — Auth0 writes group memberships to
|
|
182
|
+
* `https://acme.com/groups`, and splitting that on `.` finds nothing.
|
|
183
|
+
*/
|
|
184
|
+
function claimAt(claims, path) {
|
|
185
|
+
if (path in claims) return claims[path];
|
|
186
|
+
let value = claims;
|
|
187
|
+
for (const key of path.split(".")) {
|
|
188
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
189
|
+
value = value[key];
|
|
190
|
+
}
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Compare what the tools demand against what the roles grant.
|
|
195
|
+
*
|
|
196
|
+
* A gateway cannot run this check at all — it does not learn the tool list
|
|
197
|
+
* until traffic arrives, and its rules were never compiled alongside the
|
|
198
|
+
* implementation.
|
|
199
|
+
*/
|
|
200
|
+
function reconcile(roles, required) {
|
|
201
|
+
const grantedBy = /* @__PURE__ */ new Map();
|
|
202
|
+
for (const [role, permissions] of roles) for (const permission of permissions) grantedBy.set(permission, [...grantedBy.get(permission) ?? [], role]);
|
|
203
|
+
const unreachable = grantedBy.has("*") ? [] : [...required].filter(([, permission]) => !grantedBy.has(permission));
|
|
204
|
+
const unused = [...grantedBy].filter(([permission]) => permission !== "*").filter(([permission]) => ![...required.values()].includes(permission));
|
|
205
|
+
const drift = {};
|
|
206
|
+
if (unreachable.length > 0) {
|
|
207
|
+
const report = [
|
|
208
|
+
"MCP policy validation failed",
|
|
209
|
+
"",
|
|
210
|
+
"Unreachable capability:"
|
|
211
|
+
];
|
|
212
|
+
for (const [tool, permission] of unreachable) report.push(` ${tool}`, ` requires: ${permission}`, " granted by: no role", "");
|
|
213
|
+
drift.error = report.join("\n");
|
|
214
|
+
}
|
|
215
|
+
if (unused.length > 0) {
|
|
216
|
+
const report = ["MCP policy has unused permissions:"];
|
|
217
|
+
for (const [permission, byRoles] of unused) {
|
|
218
|
+
const by = byRoles.map((role) => `"${role}"`).join(", ");
|
|
219
|
+
report.push(` ${permission}`, ` granted by: ${by}`, " required by: no registered capability", "");
|
|
220
|
+
}
|
|
221
|
+
drift.warning = report.join("\n");
|
|
222
|
+
}
|
|
223
|
+
return drift;
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
export { reconcile as i, definePermissions as n, definePolicy as r, createPrincipal as t };
|
|
@@ -85,11 +85,39 @@ type PolicySpec = {
|
|
|
85
85
|
roles: Record<string, readonly string[]>;
|
|
86
86
|
rules: readonly Rule[];
|
|
87
87
|
};
|
|
88
|
+
/** One rule that matched, pointed back at the line an administrator edits. */
|
|
89
|
+
type MatchedRule = {
|
|
90
|
+
/** Index into the policy's own `rules` array. */
|
|
91
|
+
index: number;
|
|
92
|
+
match: Match;
|
|
93
|
+
roles: string[];
|
|
94
|
+
deny: boolean;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Why a principal came out the way it did.
|
|
98
|
+
*
|
|
99
|
+
* The decision alone answers "may Alice do this". It cannot answer "why", and
|
|
100
|
+
* that is the question asked when somebody is surprised. Deriving it a second
|
|
101
|
+
* time somewhere else would be a second matcher to keep in step, so it lives
|
|
102
|
+
* here beside the first one.
|
|
103
|
+
*/
|
|
104
|
+
type Explanation<P extends string = string> = {
|
|
105
|
+
principal: Principal<P>;
|
|
106
|
+
/** Every matching rule, in policy order. Empty means no rule matched. */
|
|
107
|
+
matched: MatchedRule[];
|
|
108
|
+
/** The deny that emptied the grant, when one did. */
|
|
109
|
+
deniedBy?: MatchedRule;
|
|
110
|
+
};
|
|
88
111
|
type Policy<P extends string = string> = ((identity: Identity) => Principal<P>) & {
|
|
89
112
|
/** Role name to the permissions it grants. Reconciled against the tools at boot. */
|
|
90
113
|
readonly roles: ReadonlyMap<string, readonly string[]>;
|
|
91
114
|
/** Concrete permission vocabulary carried for builders and diagnostics. */
|
|
92
115
|
readonly permissions: readonly P[];
|
|
116
|
+
/**
|
|
117
|
+
* The same decision, plus the rules that produced it. Off the request path:
|
|
118
|
+
* nothing in the gate calls this, so it costs a served request nothing.
|
|
119
|
+
*/
|
|
120
|
+
readonly explain: (identity: Identity) => Explanation<P>;
|
|
93
121
|
};
|
|
94
122
|
type PermissionCatalog<P extends string = string> = {
|
|
95
123
|
readonly permissions: readonly P[];
|
|
@@ -141,4 +169,4 @@ type Drift = {
|
|
|
141
169
|
*/
|
|
142
170
|
declare function reconcile(roles: ReadonlyMap<string, readonly string[]>, required: ReadonlyMap<string, string>): Drift;
|
|
143
171
|
//#endregion
|
|
144
|
-
export {
|
|
172
|
+
export { Identity as _, PermissionCatalog as a, PolicySpec as c, createPrincipal as d, definePermissions as f, DenialReason as g, AccessDeniedError as h, MatchedRule as i, Principal as l, reconcile as m, Explanation as n, PermissionOf as o, definePolicy as p, Match as r, Policy as s, Drift as t, Rule as u };
|
package/dist/policy.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { Drift, type Identity, Match, PermissionCatalog, PermissionOf, Policy, PolicySpec, Principal, Rule, createPrincipal, definePermissions, definePolicy, reconcile };
|
|
1
|
+
import { _ as Identity, a as PermissionCatalog, c as PolicySpec, d as createPrincipal, f as definePermissions, i as MatchedRule, l as Principal, m as reconcile, n as Explanation, o as PermissionOf, p as definePolicy, r as Match, s as Policy, t as Drift, u as Rule } from "./policy-DuZbwrKf.js";
|
|
2
|
+
export { Drift, Explanation, type Identity, Match, MatchedRule, PermissionCatalog, PermissionOf, Policy, PolicySpec, Principal, Rule, createPrincipal, definePermissions, definePolicy, reconcile };
|