@ory/argus 0.1.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 +134 -0
- package/assets/commands/local-down.md +19 -0
- package/assets/commands/local-up.md +27 -0
- package/assets/skills/auth-setup/SKILL.md +279 -0
- package/assets/skills/local-dev/SKILL.md +206 -0
- package/assets/skills/login-flow/SKILL.md +383 -0
- package/assets/skills/social-login/SKILL.md +312 -0
- package/dist/agent-auth.d.ts +204 -0
- package/dist/agent-auth.js +553 -0
- package/dist/auth-gate.d.ts +71 -0
- package/dist/auth-gate.js +308 -0
- package/dist/auth-store.d.ts +75 -0
- package/dist/auth-store.js +261 -0
- package/dist/auth.d.ts +93 -0
- package/dist/auth.js +323 -0
- package/dist/cli.d.ts +73 -0
- package/dist/cli.js +484 -0
- package/dist/client.d.ts +158 -0
- package/dist/client.js +679 -0
- package/dist/config.d.ts +135 -0
- package/dist/config.js +344 -0
- package/dist/denial.d.ts +79 -0
- package/dist/denial.js +103 -0
- package/dist/dev.d.ts +95 -0
- package/dist/dev.js +514 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +137 -0
- package/dist/local/cli.d.ts +12 -0
- package/dist/local/cli.js +95 -0
- package/dist/local/configs.d.ts +89 -0
- package/dist/local/configs.js +634 -0
- package/dist/local/health.d.ts +32 -0
- package/dist/local/health.js +65 -0
- package/dist/local/index.d.ts +6 -0
- package/dist/local/index.js +38 -0
- package/dist/local/jaeger-main.d.ts +13 -0
- package/dist/local/jaeger-main.js +85 -0
- package/dist/local/jaeger.d.ts +50 -0
- package/dist/local/jaeger.js +162 -0
- package/dist/local/main.d.ts +7 -0
- package/dist/local/main.js +14 -0
- package/dist/local/manager.d.ts +45 -0
- package/dist/local/manager.js +676 -0
- package/dist/local/seed.d.ts +71 -0
- package/dist/local/seed.js +237 -0
- package/dist/logger.d.ts +29 -0
- package/dist/logger.js +139 -0
- package/dist/mcp.d.ts +76 -0
- package/dist/mcp.js +122 -0
- package/dist/otel/exporter.d.ts +17 -0
- package/dist/otel/exporter.js +12 -0
- package/dist/otel/index.d.ts +2 -0
- package/dist/otel/index.js +8 -0
- package/dist/otel/otlp-http.d.ts +116 -0
- package/dist/otel/otlp-http.js +322 -0
- package/dist/registry/cli.d.ts +12 -0
- package/dist/registry/cli.js +76 -0
- package/dist/registry/config.d.ts +23 -0
- package/dist/registry/config.js +80 -0
- package/dist/registry/index.d.ts +3 -0
- package/dist/registry/index.js +21 -0
- package/dist/registry/main.d.ts +7 -0
- package/dist/registry/main.js +14 -0
- package/dist/registry/manager.d.ts +38 -0
- package/dist/registry/manager.js +674 -0
- package/dist/setup.d.ts +118 -0
- package/dist/setup.js +398 -0
- package/dist/skills.d.ts +78 -0
- package/dist/skills.js +264 -0
- package/dist/subject.d.ts +43 -0
- package/dist/subject.js +55 -0
- package/dist/tool-metadata.d.ts +41 -0
- package/dist/tool-metadata.js +127 -0
- package/dist/tracer.d.ts +172 -0
- package/dist/tracer.js +452 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.js +3 -0
- package/dist/watch-sandbox.d.ts +9 -0
- package/dist/watch-sandbox.js +81 -0
- package/package.json +79 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OryAgentClient = void 0;
|
|
4
|
+
exports.extractOryRequestId = extractOryRequestId;
|
|
5
|
+
const client_1 = require("@ory/client");
|
|
6
|
+
const logger_js_1 = require("./logger.js");
|
|
7
|
+
const tracer_js_1 = require("./tracer.js");
|
|
8
|
+
const config_js_1 = require("./config.js");
|
|
9
|
+
const otlp_http_js_1 = require("./otel/otlp-http.js");
|
|
10
|
+
class OryAgentClient {
|
|
11
|
+
frontend;
|
|
12
|
+
oauth2;
|
|
13
|
+
permission;
|
|
14
|
+
relationship;
|
|
15
|
+
config;
|
|
16
|
+
logger;
|
|
17
|
+
tracer;
|
|
18
|
+
sessionCache = new Map();
|
|
19
|
+
sessionCacheTtlMs;
|
|
20
|
+
/**
|
|
21
|
+
* The human user — the principal whose permissions we check.
|
|
22
|
+
* Populated by `ensureUserAuthenticated` on session start.
|
|
23
|
+
*/
|
|
24
|
+
_userPrincipal = {};
|
|
25
|
+
/**
|
|
26
|
+
* The AI agent process — the principal that authenticates outgoing
|
|
27
|
+
* Ory API calls. Populated by `ensureAgentIdentity`.
|
|
28
|
+
*/
|
|
29
|
+
_agentPrincipal = {};
|
|
30
|
+
constructor(config) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.sessionCacheTtlMs = config.sessionCacheTtlMs ?? 60_000;
|
|
33
|
+
this.logger = new logger_js_1.DebugLogger(config.harness, {
|
|
34
|
+
logFile: config.logFile,
|
|
35
|
+
});
|
|
36
|
+
this.tracer = new tracer_js_1.Tracer({
|
|
37
|
+
harness: config.harness,
|
|
38
|
+
traceFile: config.traceFile,
|
|
39
|
+
...(config.exporter ? { exporter: config.exporter } : {}),
|
|
40
|
+
});
|
|
41
|
+
// Seed agent principal from the constructor `apiKey` so existing
|
|
42
|
+
// call sites keep working until they migrate to ensureAgentIdentity.
|
|
43
|
+
if (config.apiKey)
|
|
44
|
+
this._agentPrincipal.token = config.apiKey;
|
|
45
|
+
const { frontend, oauth2, permission, relationship } = this.buildApis();
|
|
46
|
+
this.frontend = frontend;
|
|
47
|
+
this.oauth2 = oauth2;
|
|
48
|
+
this.permission = permission;
|
|
49
|
+
this.relationship = relationship;
|
|
50
|
+
}
|
|
51
|
+
/** Build a fresh set of Ory API instances using the current agent token. */
|
|
52
|
+
buildApis() {
|
|
53
|
+
const accessToken = this._agentPrincipal.token;
|
|
54
|
+
const apiConfig = new client_1.Configuration({
|
|
55
|
+
basePath: this.config.projectUrl,
|
|
56
|
+
...(accessToken ? { accessToken } : {}),
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
frontend: new client_1.FrontendApi(apiConfig),
|
|
60
|
+
oauth2: new client_1.OAuth2Api(apiConfig),
|
|
61
|
+
permission: new client_1.PermissionApi(apiConfig),
|
|
62
|
+
relationship: new client_1.RelationshipApi(apiConfig),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Snapshot of the current user principal. */
|
|
66
|
+
get userPrincipal() {
|
|
67
|
+
return { ...this._userPrincipal };
|
|
68
|
+
}
|
|
69
|
+
/** Snapshot of the current agent principal. */
|
|
70
|
+
get agentPrincipal() {
|
|
71
|
+
return { ...this._agentPrincipal };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Set or update the human user principal. The user is the subject of
|
|
75
|
+
* permission checks but does not authenticate outgoing API calls.
|
|
76
|
+
* Pass `{}` (or fields set to undefined) to clear.
|
|
77
|
+
*/
|
|
78
|
+
setUserPrincipal(principal) {
|
|
79
|
+
this._userPrincipal = { ...principal };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Set or update the AI agent principal. The agent's token (when
|
|
83
|
+
* present) is used in the Authorization header for all outgoing Ory
|
|
84
|
+
* API calls — so the audit log shows "agent X acting on behalf of
|
|
85
|
+
* user Y". Rebuilds the underlying Ory API instances when the token
|
|
86
|
+
* actually changes so subsequent calls pick it up; otherwise leaves
|
|
87
|
+
* the API instances alone (so test stubs survive a no-op update).
|
|
88
|
+
*/
|
|
89
|
+
setAgentPrincipal(principal) {
|
|
90
|
+
const tokenChanged = this._agentPrincipal.token !== principal.token;
|
|
91
|
+
this._agentPrincipal = { ...principal };
|
|
92
|
+
if (!tokenChanged)
|
|
93
|
+
return;
|
|
94
|
+
const { frontend, oauth2, permission, relationship } = this.buildApis();
|
|
95
|
+
this.frontend = frontend;
|
|
96
|
+
this.oauth2 = oauth2;
|
|
97
|
+
this.permission = permission;
|
|
98
|
+
this.relationship = relationship;
|
|
99
|
+
}
|
|
100
|
+
// ─── Session (Ory Identities / Kratos) ──────────────────────────
|
|
101
|
+
/**
|
|
102
|
+
* Verify an existing session token. Returns cached result if available.
|
|
103
|
+
*/
|
|
104
|
+
async verifySession(sessionToken) {
|
|
105
|
+
this.logger.debug("session.verify.start", { hasToken: !!sessionToken });
|
|
106
|
+
// Check cache
|
|
107
|
+
const cached = this.sessionCache.get(sessionToken);
|
|
108
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
109
|
+
this.logger.debug("session.verify.cached", {
|
|
110
|
+
sessionId: cached.info.sessionId,
|
|
111
|
+
});
|
|
112
|
+
return cached.info;
|
|
113
|
+
}
|
|
114
|
+
const span = this.tracer.startSpan("session.verify");
|
|
115
|
+
try {
|
|
116
|
+
const response = await this.frontend.toSession({
|
|
117
|
+
xSessionToken: sessionToken,
|
|
118
|
+
});
|
|
119
|
+
const session = response.data;
|
|
120
|
+
const oryRequestId = extractOryRequestId(response.headers);
|
|
121
|
+
const info = {
|
|
122
|
+
sessionId: session.id,
|
|
123
|
+
identityId: session.identity.id,
|
|
124
|
+
active: session.active ?? false,
|
|
125
|
+
authenticatedAt: session.authenticated_at ?? "",
|
|
126
|
+
expiresAt: session.expires_at,
|
|
127
|
+
authenticatorAssuranceLevel: session.authenticator_assurance_level,
|
|
128
|
+
authenticationMethods: session.authentication_methods?.map((m) => ({
|
|
129
|
+
method: m.method ?? "unknown",
|
|
130
|
+
completedAt: m.completed_at,
|
|
131
|
+
})),
|
|
132
|
+
traits: session.identity.traits ?? {},
|
|
133
|
+
};
|
|
134
|
+
// Cache the result
|
|
135
|
+
if (this.sessionCacheTtlMs > 0) {
|
|
136
|
+
this.sessionCache.set(sessionToken, {
|
|
137
|
+
info,
|
|
138
|
+
expiresAt: Date.now() + this.sessionCacheTtlMs,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
this.logger.info("session.verify.success", {
|
|
142
|
+
sessionId: info.sessionId,
|
|
143
|
+
identityId: info.identityId,
|
|
144
|
+
active: info.active,
|
|
145
|
+
aal: info.authenticatorAssuranceLevel,
|
|
146
|
+
});
|
|
147
|
+
span.end("ok", {
|
|
148
|
+
sessionId: info.sessionId,
|
|
149
|
+
identityId: info.identityId,
|
|
150
|
+
active: info.active,
|
|
151
|
+
...(oryRequestId ? { oryRequestId } : {}),
|
|
152
|
+
});
|
|
153
|
+
return info;
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
const oryErr = this.classifyError(err);
|
|
157
|
+
this.logger.error("session.verify.failed", {
|
|
158
|
+
code: oryErr.code,
|
|
159
|
+
status: oryErr.status,
|
|
160
|
+
message: oryErr.message,
|
|
161
|
+
});
|
|
162
|
+
span.end("error", {
|
|
163
|
+
error: oryErr.code,
|
|
164
|
+
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
165
|
+
});
|
|
166
|
+
throw oryErr;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// ─── OAuth2 Token (Ory OAuth2 / Hydra) ──────────────────────────
|
|
170
|
+
/**
|
|
171
|
+
* Introspect an OAuth2 access token. Use this when agents authenticate
|
|
172
|
+
* via client credentials flow instead of session tokens.
|
|
173
|
+
*/
|
|
174
|
+
async introspectToken(token, scope) {
|
|
175
|
+
this.logger.debug("oauth2.introspect.start", { hasToken: !!token });
|
|
176
|
+
const span = this.tracer.startSpan("oauth2.introspect");
|
|
177
|
+
try {
|
|
178
|
+
const response = await this.oauth2.introspectOAuth2Token({
|
|
179
|
+
token,
|
|
180
|
+
scope,
|
|
181
|
+
});
|
|
182
|
+
const data = response.data;
|
|
183
|
+
const oryRequestId = extractOryRequestId(response.headers);
|
|
184
|
+
const info = {
|
|
185
|
+
active: data.active,
|
|
186
|
+
clientId: data.client_id,
|
|
187
|
+
scope: data.scope,
|
|
188
|
+
subject: data.sub,
|
|
189
|
+
audience: data.aud,
|
|
190
|
+
expiresAt: data.exp,
|
|
191
|
+
issuedAt: data.iat,
|
|
192
|
+
tokenUse: data.token_use,
|
|
193
|
+
extra: data.ext,
|
|
194
|
+
};
|
|
195
|
+
this.logger.info("oauth2.introspect.result", {
|
|
196
|
+
active: info.active,
|
|
197
|
+
clientId: info.clientId,
|
|
198
|
+
subject: info.subject,
|
|
199
|
+
});
|
|
200
|
+
span.end("ok", {
|
|
201
|
+
active: info.active,
|
|
202
|
+
clientId: info.clientId,
|
|
203
|
+
subject: info.subject,
|
|
204
|
+
...(oryRequestId ? { oryRequestId } : {}),
|
|
205
|
+
});
|
|
206
|
+
return info;
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
const oryErr = this.classifyError(err);
|
|
210
|
+
this.logger.error("oauth2.introspect.failed", {
|
|
211
|
+
code: oryErr.code,
|
|
212
|
+
status: oryErr.status,
|
|
213
|
+
message: oryErr.message,
|
|
214
|
+
});
|
|
215
|
+
span.end("error", {
|
|
216
|
+
error: oryErr.code,
|
|
217
|
+
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
218
|
+
});
|
|
219
|
+
throw oryErr;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// ─── Permissions (Ory Permissions / Keto) ────────────────────────
|
|
223
|
+
/**
|
|
224
|
+
* Check if a subject has permission to perform an action.
|
|
225
|
+
*
|
|
226
|
+
* `options.spanAttributes` are merged into the `permission.check` trace
|
|
227
|
+
* span so callers can attach harness-side context (toolName, mcpServer,
|
|
228
|
+
* etc.) that the wire-level `PermissionCheck` shape doesn't carry.
|
|
229
|
+
*/
|
|
230
|
+
async checkPermission(check, options) {
|
|
231
|
+
this.logger.debug("permission.check.start", { ...check });
|
|
232
|
+
const span = this.tracer.startSpan("permission.check", {
|
|
233
|
+
attributes: {
|
|
234
|
+
object: check.object,
|
|
235
|
+
relation: check.relation,
|
|
236
|
+
...this.principalSpanAttributes(),
|
|
237
|
+
...options?.spanAttributes,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
try {
|
|
241
|
+
const response = await this.permission.checkPermission({
|
|
242
|
+
namespace: check.namespace,
|
|
243
|
+
object: check.object,
|
|
244
|
+
relation: check.relation,
|
|
245
|
+
subjectId: check.subjectId,
|
|
246
|
+
subjectSetNamespace: check.subjectSet?.namespace,
|
|
247
|
+
subjectSetObject: check.subjectSet?.object,
|
|
248
|
+
subjectSetRelation: check.subjectSet?.relation,
|
|
249
|
+
});
|
|
250
|
+
const data = response.data;
|
|
251
|
+
const oryRequestId = extractOryRequestId(response.headers);
|
|
252
|
+
const result = {
|
|
253
|
+
allowed: data.allowed,
|
|
254
|
+
checkedAt: new Date().toISOString(),
|
|
255
|
+
check,
|
|
256
|
+
};
|
|
257
|
+
this.logger.info("permission.check.result", {
|
|
258
|
+
allowed: result.allowed,
|
|
259
|
+
namespace: check.namespace,
|
|
260
|
+
object: check.object,
|
|
261
|
+
relation: check.relation,
|
|
262
|
+
subjectId: check.subjectId,
|
|
263
|
+
subjectSet: check.subjectSet,
|
|
264
|
+
});
|
|
265
|
+
span.end(result.allowed ? "ok" : "denied", {
|
|
266
|
+
allowed: result.allowed,
|
|
267
|
+
...(oryRequestId ? { oryRequestId } : {}),
|
|
268
|
+
});
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
const oryErr = this.classifyError(err);
|
|
273
|
+
this.logger.error("permission.check.failed", {
|
|
274
|
+
code: oryErr.code,
|
|
275
|
+
status: oryErr.status,
|
|
276
|
+
message: oryErr.message,
|
|
277
|
+
...check,
|
|
278
|
+
});
|
|
279
|
+
span.end("error", {
|
|
280
|
+
error: oryErr.code,
|
|
281
|
+
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
282
|
+
});
|
|
283
|
+
throw oryErr;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Check multiple permissions in a single request.
|
|
288
|
+
*
|
|
289
|
+
* `options.spanAttributes` are merged into the `permission.batch_check`
|
|
290
|
+
* span — same purpose as `checkPermission`'s spanAttributes.
|
|
291
|
+
*/
|
|
292
|
+
async batchCheckPermissions(checks, options) {
|
|
293
|
+
this.logger.debug("permission.batch_check.start", {
|
|
294
|
+
count: checks.length,
|
|
295
|
+
});
|
|
296
|
+
const span = this.tracer.startSpan("permission.batch_check", {
|
|
297
|
+
attributes: {
|
|
298
|
+
count: checks.length,
|
|
299
|
+
...this.principalSpanAttributes(),
|
|
300
|
+
...options?.spanAttributes,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
try {
|
|
304
|
+
const response = await this.permission.batchCheckPermission({
|
|
305
|
+
batchCheckPermissionBody: {
|
|
306
|
+
tuples: checks.map((c) => ({
|
|
307
|
+
namespace: c.namespace,
|
|
308
|
+
object: c.object,
|
|
309
|
+
relation: c.relation,
|
|
310
|
+
subject_id: c.subjectId,
|
|
311
|
+
subject_set: c.subjectSet
|
|
312
|
+
? {
|
|
313
|
+
namespace: c.subjectSet.namespace,
|
|
314
|
+
object: c.subjectSet.object,
|
|
315
|
+
relation: c.subjectSet.relation,
|
|
316
|
+
}
|
|
317
|
+
: undefined,
|
|
318
|
+
})),
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
const data = response.data;
|
|
322
|
+
const oryRequestId = extractOryRequestId(response.headers);
|
|
323
|
+
const result = {
|
|
324
|
+
results: data.results.map((r) => ({
|
|
325
|
+
allowed: r.allowed,
|
|
326
|
+
error: r.error,
|
|
327
|
+
})),
|
|
328
|
+
checks,
|
|
329
|
+
checkedAt: new Date().toISOString(),
|
|
330
|
+
};
|
|
331
|
+
const allowed = result.results.filter((r) => r.allowed).length;
|
|
332
|
+
const denied = result.results.filter((r) => !r.allowed).length;
|
|
333
|
+
this.logger.info("permission.batch_check.result", {
|
|
334
|
+
count: checks.length,
|
|
335
|
+
allowed,
|
|
336
|
+
denied,
|
|
337
|
+
});
|
|
338
|
+
span.end("ok", {
|
|
339
|
+
allowed,
|
|
340
|
+
denied,
|
|
341
|
+
...(oryRequestId ? { oryRequestId } : {}),
|
|
342
|
+
});
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
const oryErr = this.classifyError(err);
|
|
347
|
+
this.logger.error("permission.batch_check.failed", {
|
|
348
|
+
code: oryErr.code,
|
|
349
|
+
status: oryErr.status,
|
|
350
|
+
message: oryErr.message,
|
|
351
|
+
});
|
|
352
|
+
span.end("error", {
|
|
353
|
+
error: oryErr.code,
|
|
354
|
+
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
355
|
+
});
|
|
356
|
+
throw oryErr;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// ─── Relationships (Ory Permissions / Keto write API) ────────────
|
|
360
|
+
/**
|
|
361
|
+
* Create a relation tuple in Keto. Idempotent: a 409 (tuple already
|
|
362
|
+
* exists) is swallowed and reported as success. All other errors are
|
|
363
|
+
* classified and re-thrown — callers wrap in fail-open semantics if
|
|
364
|
+
* the tuple is non-critical (e.g. audit-only delegation tracking).
|
|
365
|
+
*
|
|
366
|
+
* `options.spanAttributes` are merged into the `relationship.create`
|
|
367
|
+
* trace span so callers can attach context like `delegation: "user→agent"`.
|
|
368
|
+
*/
|
|
369
|
+
async createRelationship(check, options) {
|
|
370
|
+
this.logger.debug("relationship.create.start", { ...check });
|
|
371
|
+
const span = this.tracer.startSpan("relationship.create", {
|
|
372
|
+
attributes: {
|
|
373
|
+
namespace: check.namespace,
|
|
374
|
+
object: check.object,
|
|
375
|
+
relation: check.relation,
|
|
376
|
+
...this.principalSpanAttributes(),
|
|
377
|
+
...options?.spanAttributes,
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
try {
|
|
381
|
+
const response = await this.relationship.createRelationship({
|
|
382
|
+
createRelationshipBody: {
|
|
383
|
+
namespace: check.namespace,
|
|
384
|
+
object: check.object,
|
|
385
|
+
relation: check.relation,
|
|
386
|
+
subject_id: check.subjectId,
|
|
387
|
+
...(check.subjectSet
|
|
388
|
+
? {
|
|
389
|
+
subject_set: {
|
|
390
|
+
namespace: check.subjectSet.namespace,
|
|
391
|
+
object: check.subjectSet.object,
|
|
392
|
+
relation: check.subjectSet.relation,
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
: {}),
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
const oryRequestId = extractOryRequestId(response.headers);
|
|
399
|
+
this.logger.info("relationship.create.result", {
|
|
400
|
+
namespace: check.namespace,
|
|
401
|
+
object: check.object,
|
|
402
|
+
relation: check.relation,
|
|
403
|
+
subjectId: check.subjectId,
|
|
404
|
+
});
|
|
405
|
+
span.end("ok", {
|
|
406
|
+
created: true,
|
|
407
|
+
...(oryRequestId ? { oryRequestId } : {}),
|
|
408
|
+
});
|
|
409
|
+
return { created: true, alreadyExisted: false };
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
const oryErr = this.classifyError(err);
|
|
413
|
+
// Keto returns 409 when the tuple already exists; treat as success
|
|
414
|
+
// so callers can call this on every session start without dedup.
|
|
415
|
+
if (oryErr.status === 409) {
|
|
416
|
+
this.logger.debug("relationship.create.exists", {
|
|
417
|
+
namespace: check.namespace,
|
|
418
|
+
object: check.object,
|
|
419
|
+
relation: check.relation,
|
|
420
|
+
});
|
|
421
|
+
span.end("ok", {
|
|
422
|
+
created: false,
|
|
423
|
+
alreadyExisted: true,
|
|
424
|
+
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
425
|
+
});
|
|
426
|
+
return { created: false, alreadyExisted: true };
|
|
427
|
+
}
|
|
428
|
+
this.logger.error("relationship.create.failed", {
|
|
429
|
+
code: oryErr.code,
|
|
430
|
+
status: oryErr.status,
|
|
431
|
+
message: oryErr.message,
|
|
432
|
+
...check,
|
|
433
|
+
});
|
|
434
|
+
span.end("error", {
|
|
435
|
+
error: oryErr.code,
|
|
436
|
+
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
437
|
+
});
|
|
438
|
+
throw oryErr;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Delete a relation tuple in Keto. Missing tuples (404) are treated as
|
|
443
|
+
* success so callers can call this idempotently when unwinding state.
|
|
444
|
+
*/
|
|
445
|
+
async deleteRelationship(check, options) {
|
|
446
|
+
this.logger.debug("relationship.delete.start", { ...check });
|
|
447
|
+
const span = this.tracer.startSpan("relationship.delete", {
|
|
448
|
+
attributes: {
|
|
449
|
+
namespace: check.namespace,
|
|
450
|
+
object: check.object,
|
|
451
|
+
relation: check.relation,
|
|
452
|
+
...this.principalSpanAttributes(),
|
|
453
|
+
...options?.spanAttributes,
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
const response = await this.relationship.deleteRelationships({
|
|
458
|
+
namespace: check.namespace,
|
|
459
|
+
object: check.object,
|
|
460
|
+
relation: check.relation,
|
|
461
|
+
subjectId: check.subjectId,
|
|
462
|
+
...(check.subjectSet
|
|
463
|
+
? {
|
|
464
|
+
subjectSetNamespace: check.subjectSet.namespace,
|
|
465
|
+
subjectSetObject: check.subjectSet.object,
|
|
466
|
+
subjectSetRelation: check.subjectSet.relation,
|
|
467
|
+
}
|
|
468
|
+
: {}),
|
|
469
|
+
});
|
|
470
|
+
const oryRequestId = extractOryRequestId(response.headers);
|
|
471
|
+
span.end("ok", {
|
|
472
|
+
deleted: true,
|
|
473
|
+
...(oryRequestId ? { oryRequestId } : {}),
|
|
474
|
+
});
|
|
475
|
+
return { deleted: true, notFound: false };
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
const oryErr = this.classifyError(err);
|
|
479
|
+
if (oryErr.code === "not_found") {
|
|
480
|
+
span.end("ok", {
|
|
481
|
+
deleted: false,
|
|
482
|
+
notFound: true,
|
|
483
|
+
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
484
|
+
});
|
|
485
|
+
return { deleted: false, notFound: true };
|
|
486
|
+
}
|
|
487
|
+
this.logger.error("relationship.delete.failed", {
|
|
488
|
+
code: oryErr.code,
|
|
489
|
+
status: oryErr.status,
|
|
490
|
+
message: oryErr.message,
|
|
491
|
+
...check,
|
|
492
|
+
});
|
|
493
|
+
span.end("error", {
|
|
494
|
+
error: oryErr.code,
|
|
495
|
+
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
496
|
+
});
|
|
497
|
+
throw oryErr;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Span attributes describing which principals were attached to the
|
|
502
|
+
* client when the span was started. Useful for correlating audit logs
|
|
503
|
+
* across the user/agent split.
|
|
504
|
+
*/
|
|
505
|
+
principalSpanAttributes() {
|
|
506
|
+
const out = {};
|
|
507
|
+
if (this._userPrincipal.subject)
|
|
508
|
+
out.userSubject = this._userPrincipal.subject;
|
|
509
|
+
if (this._agentPrincipal.subject)
|
|
510
|
+
out.agentSubject = this._agentPrincipal.subject;
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
// ─── Error Classification ────────────────────────────────────────
|
|
514
|
+
/**
|
|
515
|
+
* Classify an error from any Ory API call into a structured OryError.
|
|
516
|
+
*/
|
|
517
|
+
classifyError(err) {
|
|
518
|
+
// Axios errors have a `response` property with status + data
|
|
519
|
+
if (this.isAxiosError(err)) {
|
|
520
|
+
const status = err.response?.status;
|
|
521
|
+
const body = err.response?.data;
|
|
522
|
+
const errorId = body?.error?.id;
|
|
523
|
+
const message = body?.error?.message ?? err.message ?? "Unknown Ory error";
|
|
524
|
+
const requestId = extractOryRequestId(err.response?.headers);
|
|
525
|
+
let code;
|
|
526
|
+
switch (status) {
|
|
527
|
+
case 401:
|
|
528
|
+
code =
|
|
529
|
+
errorId === "session_aal2_required"
|
|
530
|
+
? "session_aal2_required"
|
|
531
|
+
: "session_inactive";
|
|
532
|
+
break;
|
|
533
|
+
case 403:
|
|
534
|
+
code =
|
|
535
|
+
errorId === "session_aal2_required"
|
|
536
|
+
? "session_aal2_required"
|
|
537
|
+
: "forbidden";
|
|
538
|
+
break;
|
|
539
|
+
case 404:
|
|
540
|
+
code = "not_found";
|
|
541
|
+
break;
|
|
542
|
+
case 429:
|
|
543
|
+
code = "rate_limited";
|
|
544
|
+
break;
|
|
545
|
+
default:
|
|
546
|
+
code = "unknown";
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
code,
|
|
550
|
+
status,
|
|
551
|
+
message,
|
|
552
|
+
...(requestId ? { requestId } : {}),
|
|
553
|
+
cause: err,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
// Network errors (no response received)
|
|
557
|
+
if (err instanceof Error && "code" in err) {
|
|
558
|
+
const netErr = err;
|
|
559
|
+
if (netErr.code === "ECONNREFUSED" ||
|
|
560
|
+
netErr.code === "ECONNRESET" ||
|
|
561
|
+
netErr.code === "ENOTFOUND" ||
|
|
562
|
+
netErr.code === "ETIMEDOUT") {
|
|
563
|
+
return {
|
|
564
|
+
code: "network_error",
|
|
565
|
+
message: `Network error: ${netErr.code} — ${netErr.message}`,
|
|
566
|
+
cause: err,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
code: "unknown",
|
|
572
|
+
message: err instanceof Error ? err.message : String(err),
|
|
573
|
+
cause: err,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
isAxiosError(err) {
|
|
577
|
+
return (typeof err === "object" &&
|
|
578
|
+
err !== null &&
|
|
579
|
+
"isAxiosError" in err &&
|
|
580
|
+
err.isAxiosError === true);
|
|
581
|
+
}
|
|
582
|
+
// ─── Factory ─────────────────────────────────────────────────────
|
|
583
|
+
/**
|
|
584
|
+
* Create a client from environment variables.
|
|
585
|
+
*/
|
|
586
|
+
/**
|
|
587
|
+
* Create a client from environment variables, falling back to the
|
|
588
|
+
* shared config file at ~/.config/ory-agent-plugins/config.json.
|
|
589
|
+
*
|
|
590
|
+
* Resolution order for projectUrl and apiKey:
|
|
591
|
+
* 1. Environment variables (ORY_PROJECT_URL, ORY_API_KEY)
|
|
592
|
+
* 2. Config file (~/.config/ory-agent-plugins/config.json)
|
|
593
|
+
* 3. Placeholder (fail-open pass-through mode)
|
|
594
|
+
*/
|
|
595
|
+
static fromEnv(harness) {
|
|
596
|
+
const resolved = (0, config_js_1.resolveConfig)();
|
|
597
|
+
const projectUrl = resolved.projectUrl;
|
|
598
|
+
if (!projectUrl) {
|
|
599
|
+
// Use a placeholder URL so the client can be constructed.
|
|
600
|
+
// API calls will fail with network_error which is handled as fail-open.
|
|
601
|
+
const client = new OryAgentClient({
|
|
602
|
+
projectUrl: "http://localhost:0",
|
|
603
|
+
apiKey: resolved.apiKey,
|
|
604
|
+
harness,
|
|
605
|
+
logFile: process.env.ORY_AGENT_LOG_FILE,
|
|
606
|
+
traceFile: process.env.ORY_AGENT_TRACE_FILE,
|
|
607
|
+
sessionCacheTtlMs: process.env.ORY_SESSION_CACHE_TTL_MS
|
|
608
|
+
? parseInt(process.env.ORY_SESSION_CACHE_TTL_MS, 10)
|
|
609
|
+
: undefined,
|
|
610
|
+
});
|
|
611
|
+
client.logger.warn("config.missing_project_url", {
|
|
612
|
+
message: "ORY_PROJECT_URL is not set and no config file found. " +
|
|
613
|
+
"Ory integration is disabled — all hooks will pass through. " +
|
|
614
|
+
"Set ORY_PROJECT_URL or run the plugin's configure command.",
|
|
615
|
+
});
|
|
616
|
+
attachOtlpExporterFromEnv(client, harness);
|
|
617
|
+
return client;
|
|
618
|
+
}
|
|
619
|
+
const client = new OryAgentClient({
|
|
620
|
+
projectUrl,
|
|
621
|
+
apiKey: resolved.apiKey,
|
|
622
|
+
harness,
|
|
623
|
+
logFile: process.env.ORY_AGENT_LOG_FILE,
|
|
624
|
+
traceFile: process.env.ORY_AGENT_TRACE_FILE,
|
|
625
|
+
sessionCacheTtlMs: process.env.ORY_SESSION_CACHE_TTL_MS
|
|
626
|
+
? parseInt(process.env.ORY_SESSION_CACHE_TTL_MS, 10)
|
|
627
|
+
: undefined,
|
|
628
|
+
});
|
|
629
|
+
client.logger.debug("config.resolved", {
|
|
630
|
+
projectUrlSource: resolved.projectUrlSource,
|
|
631
|
+
apiKeySource: resolved.apiKeySource,
|
|
632
|
+
});
|
|
633
|
+
attachOtlpExporterFromEnv(client, harness);
|
|
634
|
+
return client;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
exports.OryAgentClient = OryAgentClient;
|
|
638
|
+
function attachOtlpExporterFromEnv(client, harness) {
|
|
639
|
+
const meta = client.tracer.processMetadata;
|
|
640
|
+
const exporter = (0, otlp_http_js_1.otlpExporterFromEnv)({
|
|
641
|
+
harness,
|
|
642
|
+
hostname: meta.hostname,
|
|
643
|
+
user: meta.user,
|
|
644
|
+
pluginVersion: meta.pluginVersion,
|
|
645
|
+
cwd: meta.cwd,
|
|
646
|
+
gitBranch: meta.gitBranch,
|
|
647
|
+
gitCommit: meta.gitCommit,
|
|
648
|
+
onError: (err) => client.logger.warn("otel.export.failed", {
|
|
649
|
+
message: err instanceof Error ? err.message : String(err),
|
|
650
|
+
}),
|
|
651
|
+
});
|
|
652
|
+
if (!exporter)
|
|
653
|
+
return;
|
|
654
|
+
client.tracer.setExporter(exporter);
|
|
655
|
+
client.logger.info("otel.export.enabled", {
|
|
656
|
+
endpoint: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ??
|
|
657
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??
|
|
658
|
+
process.env.ORY_OTLP_ENDPOINT,
|
|
659
|
+
serviceName: process.env.OTEL_SERVICE_NAME ?? `ory-agent-plugin-${harness}`,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Pull the Ory backend request ID from a response headers bag (axios returns
|
|
664
|
+
* lowercase header names). Returns undefined when not present.
|
|
665
|
+
*/
|
|
666
|
+
function extractOryRequestId(headers) {
|
|
667
|
+
if (!headers || typeof headers !== "object")
|
|
668
|
+
return undefined;
|
|
669
|
+
const bag = headers;
|
|
670
|
+
const value = bag["x-request-id"] ??
|
|
671
|
+
bag["X-Request-Id"] ??
|
|
672
|
+
bag["X-Request-ID"] ??
|
|
673
|
+
bag["ory-request-id"];
|
|
674
|
+
if (typeof value === "string" && value.length > 0)
|
|
675
|
+
return value;
|
|
676
|
+
if (Array.isArray(value) && typeof value[0] === "string")
|
|
677
|
+
return value[0];
|
|
678
|
+
return undefined;
|
|
679
|
+
}
|