@tumbaland/backend-core 1.38.0 → 1.40.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/dist/apiKeys/ApiKey.d.ts +11 -0
- package/dist/apiKeys/ApiKey.d.ts.map +1 -1
- package/dist/apiKeys/ApiKey.js +14 -1
- package/dist/apiKeys/ApiKey.js.map +1 -1
- package/dist/apiKeys/middleware.d.ts +12 -0
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +16 -4
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/apiKeys/service.d.ts.map +1 -1
- package/dist/apiKeys/service.js +1 -0
- package/dist/apiKeys/service.js.map +1 -1
- package/dist/apiKeys/types.d.ts +2 -0
- package/dist/apiKeys/types.d.ts.map +1 -1
- package/dist/audit/AuditEvent.d.ts +82 -0
- package/dist/audit/AuditEvent.d.ts.map +1 -0
- package/dist/audit/AuditEvent.js +77 -0
- package/dist/audit/AuditEvent.js.map +1 -0
- package/dist/audit/actor.d.ts +41 -0
- package/dist/audit/actor.d.ts.map +1 -0
- package/dist/audit/actor.js +39 -0
- package/dist/audit/actor.js.map +1 -0
- package/dist/audit/context.d.ts +40 -0
- package/dist/audit/context.d.ts.map +1 -0
- package/dist/audit/context.js +60 -0
- package/dist/audit/context.js.map +1 -0
- package/dist/audit/index.d.ts +12 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +22 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/audit/plugin.d.ts +23 -0
- package/dist/audit/plugin.d.ts.map +1 -0
- package/dist/audit/plugin.js +226 -0
- package/dist/audit/plugin.js.map +1 -0
- package/dist/audit/reads.d.ts +47 -0
- package/dist/audit/reads.d.ts.map +1 -0
- package/dist/audit/reads.js +94 -0
- package/dist/audit/reads.js.map +1 -0
- package/dist/audit/service.d.ts +51 -0
- package/dist/audit/service.d.ts.map +1 -0
- package/dist/audit/service.js +66 -0
- package/dist/audit/service.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth/models.d.ts.map +1 -1
- package/dist/oauth/models.js +10 -0
- package/dist/oauth/models.js.map +1 -1
- package/dist/oauth/tokens.d.ts +15 -0
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +5 -1
- package/dist/oauth/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.test.ts +25 -1
- package/src/apiKeys/ApiKey.ts +15 -0
- package/src/apiKeys/middleware.test.ts +26 -3
- package/src/apiKeys/middleware.ts +32 -5
- package/src/apiKeys/service.test.ts +2 -0
- package/src/apiKeys/service.ts +1 -0
- package/src/apiKeys/types.ts +2 -0
- package/src/audit/AuditEvent.ts +123 -0
- package/src/audit/actor.test.ts +95 -0
- package/src/audit/actor.ts +68 -0
- package/src/audit/context.test.ts +91 -0
- package/src/audit/context.ts +83 -0
- package/src/audit/index.ts +11 -0
- package/src/audit/plugin.test.ts +258 -0
- package/src/audit/plugin.ts +254 -0
- package/src/audit/reads.test.ts +164 -0
- package/src/audit/reads.ts +88 -0
- package/src/audit/service.test.ts +115 -0
- package/src/audit/service.ts +95 -0
- package/src/index.ts +1 -0
- package/src/middleware/authMiddleware.test.ts +3 -1
- package/src/oauth/models.ts +11 -0
- package/src/oauth/tokens.test.ts +2 -0
- package/src/oauth/tokens.ts +20 -1
package/dist/oauth/models.js
CHANGED
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.RefreshToken = exports.AuthorizationCode = exports.OAuthClient = void 0;
|
|
37
37
|
const mongoose_1 = __importStar(require("mongoose"));
|
|
38
|
+
const plugin_1 = require("../audit/plugin");
|
|
38
39
|
const types_1 = require("../apiKeys/types");
|
|
39
40
|
const OAuthClientSchema = new mongoose_1.Schema({
|
|
40
41
|
clientId: { type: String, required: true, unique: true, index: true },
|
|
@@ -84,6 +85,15 @@ const RefreshTokenSchema = new mongoose_1.Schema({
|
|
|
84
85
|
lastUsedAt: { type: Date }
|
|
85
86
|
}, { timestamps: true, collection: 'oauth_refresh_tokens' });
|
|
86
87
|
RefreshTokenSchema.index({ userId: 1, createdAt: -1 });
|
|
88
|
+
/**
|
|
89
|
+
* A grant appearing and being revoked is the shape of "who connected Claude,
|
|
90
|
+
* and when did it stop" — the question the connections page answers for the
|
|
91
|
+
* present and this answers for the past.
|
|
92
|
+
*
|
|
93
|
+
* `tokenHash` is redacted: it is the only thing standing between a copy of this
|
|
94
|
+
* collection and a working refresh token.
|
|
95
|
+
*/
|
|
96
|
+
RefreshTokenSchema.plugin(plugin_1.auditPlugin, { resource: 'connection', redact: ['tokenHash'] });
|
|
87
97
|
exports.RefreshToken = mongoose_1.default.models.RefreshToken
|
|
88
98
|
? mongoose_1.default.models.RefreshToken
|
|
89
99
|
: mongoose_1.default.model('RefreshToken', RefreshTokenSchema);
|
package/dist/oauth/models.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"models.js","sourceRoot":"","sources":["../../src/oauth/models.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"models.js","sourceRoot":"","sources":["../../src/oauth/models.ts"],"names":[],"mappings":"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"}
|
package/dist/oauth/tokens.d.ts
CHANGED
|
@@ -34,6 +34,17 @@ export interface AccessTokenClaims {
|
|
|
34
34
|
tenantNames: Record<Tenant, string>;
|
|
35
35
|
/** the single tenant an older token was pinned to; read by `readTenants` */
|
|
36
36
|
groupId?: string | null;
|
|
37
|
+
/**
|
|
38
|
+
* Which app this token was issued to.
|
|
39
|
+
*
|
|
40
|
+
* `jti` identifies the token and rotates every hour, so it cannot stand in for
|
|
41
|
+
* the connection — an audit trail built on it would show a different actor
|
|
42
|
+
* each time the assistant refreshed. The client id is stable for the life of
|
|
43
|
+
* the grant, and the name is snapshotted alongside it for the same reason the
|
|
44
|
+
* tenant names are: nothing downstream can look it up.
|
|
45
|
+
*/
|
|
46
|
+
clientId: string;
|
|
47
|
+
clientName: string;
|
|
37
48
|
email: string;
|
|
38
49
|
name: string;
|
|
39
50
|
typ: typeof ACCESS_TOKEN_TYPE;
|
|
@@ -52,6 +63,8 @@ export interface MintAccessTokenInput {
|
|
|
52
63
|
tenants: Tenant[];
|
|
53
64
|
defaultTenant: Tenant;
|
|
54
65
|
tenantNames: Record<Tenant, string>;
|
|
66
|
+
clientId: string;
|
|
67
|
+
clientName: string;
|
|
55
68
|
}
|
|
56
69
|
export interface MintedAccessToken {
|
|
57
70
|
accessToken: string;
|
|
@@ -68,6 +81,8 @@ export interface AccessTokenVerification {
|
|
|
68
81
|
scopes?: ApiKeyScope[];
|
|
69
82
|
tenants?: ApiKeyTenant;
|
|
70
83
|
tenantNames?: Record<Tenant, string>;
|
|
84
|
+
clientId?: string;
|
|
85
|
+
clientName?: string;
|
|
71
86
|
}
|
|
72
87
|
/**
|
|
73
88
|
* Check a bearer token presented to the MCP server.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,MAAM,EACZ,MAAM,kBAAkB,CAAC;AAE1B;;;;;;;;;GASG;AAEH,oFAAoF;AACpF,eAAO,MAAM,iBAAiB,eAAe,CAAC;AAK9C,MAAM,WAAW,iBAAiB;IAChC,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,gDAAgD;IAChD,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,OAAO,iBAAiB,CAAC;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,MAAM,EACZ,MAAM,kBAAkB,CAAC;AAE1B;;;;;;;;;GASG;AAEH,oFAAoF;AACpF,eAAO,MAAM,iBAAiB,eAAe,CAAC;AAK9C,MAAM,WAAW,iBAAiB;IAChC,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,gDAAgD;IAChD,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,OAAO,iBAAiB,CAAC;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,eAAe,GAAI,OAAO,oBAAoB,KAAG,iBAwB7D,CAAC;AAEF,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,SAAS,CAAC,EAAE,WAAW,GAAG,SAAS,GAAG,gBAAgB,GAAG,YAAY,GAAG,eAAe,CAAC;IACxF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAC5B,OAAO,MAAM,EACb,kBAAkB,MAAM,KACvB,uBAwBF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ,OAAO,KAAG,MAAM,IAAI,iBAGP,CAAC;AAE1D,qFAAqF;AACrF,eAAO,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,WAAW,EACmB,CAAC"}
|
package/dist/oauth/tokens.js
CHANGED
|
@@ -32,6 +32,8 @@ const mintAccessToken = (input) => {
|
|
|
32
32
|
tenants: input.tenants,
|
|
33
33
|
defaultTenant: input.defaultTenant,
|
|
34
34
|
tenantNames: input.tenantNames,
|
|
35
|
+
clientId: input.clientId,
|
|
36
|
+
clientName: input.clientName,
|
|
35
37
|
email: input.email,
|
|
36
38
|
name: input.name,
|
|
37
39
|
typ: exports.ACCESS_TOKEN_TYPE,
|
|
@@ -72,7 +74,9 @@ const verifyAccessToken = (token, expectedAudience) => {
|
|
|
72
74
|
name: claims.name ?? '',
|
|
73
75
|
scopes: (claims.scope ?? '').split(' ').filter(types_1.isApiKeyScope),
|
|
74
76
|
tenants: (0, types_1.readTenants)(claims),
|
|
75
|
-
tenantNames: claims.tenantNames ?? {}
|
|
77
|
+
tenantNames: claims.tenantNames ?? {},
|
|
78
|
+
clientId: claims.clientId,
|
|
79
|
+
clientName: claims.clientName
|
|
76
80
|
};
|
|
77
81
|
};
|
|
78
82
|
exports.verifyAccessToken = verifyAccessToken;
|
package/dist/oauth/tokens.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":";;;;;;AAAA,mCAAoC;AACpC,gEAA+B;AAC/B,uCAA2C;AAC3C,4CAM0B;AAE1B;;;;;;;;;GASG;AAEH,oFAAoF;AACvE,QAAA,iBAAiB,GAAG,YAAY,CAAC;AAE9C,oFAAoF;AACpF,MAAM,wBAAwB,GAAG,EAAE,GAAG,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiKey } from './ApiKey';
|
|
1
|
+
import { ApiKey, API_KEY_SECRET_FIELDS } from './ApiKey';
|
|
2
2
|
|
|
3
3
|
/** The fields the schema insists on, so each test only varies what it is about. */
|
|
4
4
|
const complete = {
|
|
@@ -116,3 +116,27 @@ describe('model registration', () => {
|
|
|
116
116
|
expect(again).toBe(ApiKey);
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
describe('what a key’s audit trail may carry', () => {
|
|
121
|
+
/**
|
|
122
|
+
* The trail is readable by the account owner and kept for a year. A snapshot
|
|
123
|
+
* carrying the hash or the sealed ciphertext would copy credential material
|
|
124
|
+
* into a second collection with a different lifetime — which is exactly what
|
|
125
|
+
* the encryption exists to prevent.
|
|
126
|
+
*/
|
|
127
|
+
it('redacts every field on the schema that holds a secret', () => {
|
|
128
|
+
const secretish = Object.keys(ApiKey.schema.paths).filter((path) =>
|
|
129
|
+
/hash|sealed|secret/i.test(path)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Compared against the schema rather than a hand-written list, so a secret
|
|
133
|
+
// field added later fails here instead of quietly reaching the trail.
|
|
134
|
+
expect(secretish.sort()).toEqual([...API_KEY_SECRET_FIELDS].sort());
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('keeps the fields that make a row worth reading', () => {
|
|
138
|
+
for (const field of ['name', 'scopes', 'tenants', 'revokedAt']) {
|
|
139
|
+
expect(API_KEY_SECRET_FIELDS).not.toContain(field);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
package/src/apiKeys/ApiKey.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import mongoose, { Document, Schema } from 'mongoose';
|
|
2
|
+
import { auditPlugin } from '../audit/plugin';
|
|
2
3
|
import { PERSONAL_TENANT, type ApiKeyScope, type Tenant } from './types';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -82,6 +83,20 @@ const ApiKeySchema = new Schema<IApiKey>(
|
|
|
82
83
|
// Every listing is one user's keys, newest first.
|
|
83
84
|
ApiKeySchema.index({ userId: 1, createdAt: -1 });
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Issuing and revoking a key is worth a record — arguably more than anything a
|
|
88
|
+
* key goes on to do, since a credential nobody remembers creating is the one
|
|
89
|
+
* that matters.
|
|
90
|
+
*
|
|
91
|
+
* Every secret-bearing field is redacted. The trail is readable by the account
|
|
92
|
+
* owner and kept for a year; a snapshot carrying `hash` or the sealed
|
|
93
|
+
* ciphertext would put credential material into a second collection with a
|
|
94
|
+
* different lifetime, which is precisely the thing the encryption is for.
|
|
95
|
+
*/
|
|
96
|
+
export const API_KEY_SECRET_FIELDS = ['hash', 'sealedCiphertext', 'sealedIv', 'sealedTag'];
|
|
97
|
+
|
|
98
|
+
ApiKeySchema.plugin(auditPlugin, { resource: 'api_key', redact: API_KEY_SECRET_FIELDS });
|
|
99
|
+
|
|
85
100
|
export const ApiKey = mongoose.models.ApiKey
|
|
86
101
|
? (mongoose.models.ApiKey as mongoose.Model<IApiKey>)
|
|
87
102
|
: mongoose.model<IApiKey>('ApiKey', ApiKeySchema);
|
|
@@ -39,6 +39,7 @@ const okVerification = (over: Record<string, unknown> = {}) => ({
|
|
|
39
39
|
keyId: 'k1',
|
|
40
40
|
scopes: ['relationship:read', 'relationship:write'],
|
|
41
41
|
tenants: grant(PERSONAL_TENANT),
|
|
42
|
+
label: 'Claude Code',
|
|
42
43
|
...over
|
|
43
44
|
});
|
|
44
45
|
|
|
@@ -116,7 +117,11 @@ describe('authenticateAgent — API keys', () => {
|
|
|
116
117
|
keyId: 'k1',
|
|
117
118
|
scopes: ['relationship:read', 'relationship:write'],
|
|
118
119
|
tenants: grant(PERSONAL_TENANT),
|
|
119
|
-
actingAs: PERSONAL_TENANT
|
|
120
|
+
actingAs: PERSONAL_TENANT,
|
|
121
|
+
kind: 'api_key',
|
|
122
|
+
// What the owner called it, so a trail reads "Claude Code" not an id.
|
|
123
|
+
label: 'Claude Code',
|
|
124
|
+
credentialId: 'k1'
|
|
120
125
|
});
|
|
121
126
|
});
|
|
122
127
|
|
|
@@ -341,7 +346,15 @@ describe('authenticateAgent — a multi-tenant grant', () => {
|
|
|
341
346
|
describe('requireScope', () => {
|
|
342
347
|
const keyReq = (scopes: string[]) =>
|
|
343
348
|
req({
|
|
344
|
-
apiKey: {
|
|
349
|
+
apiKey: {
|
|
350
|
+
keyId: 'k1',
|
|
351
|
+
scopes,
|
|
352
|
+
tenants: grant(PERSONAL_TENANT),
|
|
353
|
+
actingAs: PERSONAL_TENANT,
|
|
354
|
+
kind: 'api_key' as const,
|
|
355
|
+
label: 'A key',
|
|
356
|
+
credentialId: 'k1'
|
|
357
|
+
}
|
|
345
358
|
} as Partial<Request>);
|
|
346
359
|
|
|
347
360
|
it('lets a session through unconditionally', () => {
|
|
@@ -421,7 +434,15 @@ describe('denyApiKeys', () => {
|
|
|
421
434
|
|
|
422
435
|
denyApiKeys(
|
|
423
436
|
req({
|
|
424
|
-
apiKey: {
|
|
437
|
+
apiKey: {
|
|
438
|
+
keyId: 'k1',
|
|
439
|
+
scopes: [],
|
|
440
|
+
tenants: grant(PERSONAL_TENANT),
|
|
441
|
+
actingAs: PERSONAL_TENANT,
|
|
442
|
+
kind: 'api_key' as const,
|
|
443
|
+
label: 'A key',
|
|
444
|
+
credentialId: 'k1'
|
|
445
|
+
}
|
|
425
446
|
}) as Request,
|
|
426
447
|
res,
|
|
427
448
|
next
|
|
@@ -451,6 +472,8 @@ describe('authenticateAgent — OAuth access tokens', () => {
|
|
|
451
472
|
tenants: [PERSONAL_TENANT],
|
|
452
473
|
defaultTenant: PERSONAL_TENANT,
|
|
453
474
|
tenantNames: { [PERSONAL_TENANT]: 'My own data' },
|
|
475
|
+
clientId: 'client-1',
|
|
476
|
+
clientName: 'Claude',
|
|
454
477
|
...over
|
|
455
478
|
}).accessToken;
|
|
456
479
|
|
|
@@ -2,6 +2,7 @@ import { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
3
|
import { requireEnv } from '../config/env';
|
|
4
4
|
import logger from '../logging/logger';
|
|
5
|
+
import { beginAudit } from '../audit/context';
|
|
5
6
|
import { isAccessTokenClaims, type AccessTokenClaims } from '../oauth/tokens';
|
|
6
7
|
import type { UserPayload } from '../types/auth';
|
|
7
8
|
import { looksLikeApiKey } from './crypto';
|
|
@@ -28,6 +29,18 @@ export interface ApiKeyContext {
|
|
|
28
29
|
tenants: ApiKeyTenant;
|
|
29
30
|
/** the tenant this particular request resolved to */
|
|
30
31
|
actingAs: Tenant;
|
|
32
|
+
/** which credential this is; they are revoked and audited differently */
|
|
33
|
+
kind: 'api_key' | 'oauth';
|
|
34
|
+
/** what the owner called it — a key's name, or the connected app's */
|
|
35
|
+
label: string;
|
|
36
|
+
/**
|
|
37
|
+
* The stable identity of the credential.
|
|
38
|
+
*
|
|
39
|
+
* A key's document id, or an OAuth client id — never the access token's
|
|
40
|
+
* `jti`, which rotates hourly and would make one connection look like a new
|
|
41
|
+
* actor every time it refreshed.
|
|
42
|
+
*/
|
|
43
|
+
credentialId: string;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
declare global {
|
|
@@ -113,8 +126,11 @@ const applyTenantGrant = (req: Request, tenants: ApiKeyTenant): Tenant | null =>
|
|
|
113
126
|
|
|
114
127
|
/** A verified non-session credential, whichever kind it arrived as. */
|
|
115
128
|
interface AgentCredential {
|
|
116
|
-
/** the key
|
|
129
|
+
/** the key's document id, or the OAuth client id */
|
|
117
130
|
credentialId: string;
|
|
131
|
+
kind: 'api_key' | 'oauth';
|
|
132
|
+
/** what the owner called it, for a trail a person can read */
|
|
133
|
+
label: string;
|
|
118
134
|
userId: string;
|
|
119
135
|
email: string;
|
|
120
136
|
name: string;
|
|
@@ -165,10 +181,16 @@ function admitAgent(
|
|
|
165
181
|
keyId: credential.credentialId,
|
|
166
182
|
scopes: credential.scopes,
|
|
167
183
|
tenants: credential.tenants,
|
|
168
|
-
actingAs
|
|
184
|
+
actingAs,
|
|
185
|
+
kind: credential.kind,
|
|
186
|
+
label: credential.label,
|
|
187
|
+
credentialId: credential.credentialId
|
|
169
188
|
};
|
|
170
189
|
|
|
171
|
-
|
|
190
|
+
// Attribution comes from authentication, so it is established here rather
|
|
191
|
+
// than mounted separately — a router that authenticates per route would
|
|
192
|
+
// otherwise have no actor in scope when a router-level middleware ran.
|
|
193
|
+
beginAudit(req, res, next);
|
|
172
194
|
}
|
|
173
195
|
|
|
174
196
|
/**
|
|
@@ -204,7 +226,7 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
204
226
|
const user = claims as UserPayload;
|
|
205
227
|
req.user = user;
|
|
206
228
|
req.userGroups = user.groups ?? [];
|
|
207
|
-
next
|
|
229
|
+
beginAudit(req, res, next);
|
|
208
230
|
return;
|
|
209
231
|
}
|
|
210
232
|
|
|
@@ -219,7 +241,10 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
219
241
|
// whose scope collapsed to nothing fell through to "no user context" and
|
|
220
242
|
// answered from the whole collection.
|
|
221
243
|
admitAgent(req, res, next, requiredScopes, {
|
|
222
|
-
|
|
244
|
+
// The client, not the token: `jti` is a new value every hour.
|
|
245
|
+
credentialId: claims.clientId ?? 'unknown-client',
|
|
246
|
+
kind: 'oauth',
|
|
247
|
+
label: claims.clientName || 'A connected app',
|
|
223
248
|
userId: claims.sub,
|
|
224
249
|
email: claims.email ?? '',
|
|
225
250
|
name: claims.name ?? '',
|
|
@@ -240,6 +265,8 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
240
265
|
|
|
241
266
|
admitAgent(req, res, next, requiredScopes, {
|
|
242
267
|
credentialId: result.keyId!,
|
|
268
|
+
kind: 'api_key',
|
|
269
|
+
label: result.label || 'An API key',
|
|
243
270
|
userId: result.userId!,
|
|
244
271
|
email: result.userEmail ?? '',
|
|
245
272
|
name: result.userName ?? '',
|
|
@@ -346,6 +346,8 @@ describe('verifyApiKey', () => {
|
|
|
346
346
|
userName: 'Tester',
|
|
347
347
|
keyId: 'k1',
|
|
348
348
|
scopes: ['relationship:read', 'album:write'],
|
|
349
|
+
// The key's own name, so an audit trail reads "Claude" and not "k1".
|
|
350
|
+
label: 'Claude',
|
|
349
351
|
tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
|
|
350
352
|
});
|
|
351
353
|
});
|
package/src/apiKeys/service.ts
CHANGED
package/src/apiKeys/types.ts
CHANGED
|
@@ -117,6 +117,8 @@ export interface ApiKeyVerification {
|
|
|
117
117
|
userEmail?: string;
|
|
118
118
|
userName?: string;
|
|
119
119
|
keyId?: string;
|
|
120
|
+
/** what the owner called this key, for an audit trail a person can read */
|
|
121
|
+
label?: string;
|
|
120
122
|
scopes?: ApiKeyScope[];
|
|
121
123
|
tenants?: ApiKeyTenant;
|
|
122
124
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import mongoose, { Document, Schema } from 'mongoose';
|
|
2
|
+
import type { ActorKind } from './actor';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One thing that happened, and who did it.
|
|
6
|
+
*
|
|
7
|
+
* Lives in backend-core rather than any one service because the question it
|
|
8
|
+
* answers spans them: "what did Claude do yesterday" is not a relationship
|
|
9
|
+
* question or an album question. Every service writes into the same collection,
|
|
10
|
+
* and `service` says which one.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* What one MCP tool call cost.
|
|
14
|
+
*
|
|
15
|
+
* Kept beside the trail rather than in a metrics system because the question it
|
|
16
|
+
* answers is the same one the trail answers — what did the assistant do — with
|
|
17
|
+
* the part that turned out to matter added: how much text came back. A tool
|
|
18
|
+
* result is not a page a person closes. It stays in the conversation and is
|
|
19
|
+
* resent with every message after it, so a single answer of ten thousand tokens
|
|
20
|
+
* is paid for again on every turn that follows. Nothing in the product made
|
|
21
|
+
* that visible until a bill did.
|
|
22
|
+
*/
|
|
23
|
+
export interface ToolCall {
|
|
24
|
+
/** the tool as the model called it — 'get_net_worth', 'log_day' */
|
|
25
|
+
name: string;
|
|
26
|
+
/** how long the handler took, end to end, including the calls it made */
|
|
27
|
+
ms: number;
|
|
28
|
+
/** characters of text handed back to the model */
|
|
29
|
+
chars: number;
|
|
30
|
+
/**
|
|
31
|
+
* Roughly what those characters cost, at four to a token.
|
|
32
|
+
*
|
|
33
|
+
* An estimate on purpose: the real count depends on a tokeniser this server
|
|
34
|
+
* has no reason to carry, and the decision it informs — which tool is the
|
|
35
|
+
* expensive one — is nowhere near close enough for the difference to matter.
|
|
36
|
+
*/
|
|
37
|
+
tokens: number;
|
|
38
|
+
/** the call came back as an error, so the size is a message rather than data */
|
|
39
|
+
failed?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface IAuditEvent extends Document {
|
|
43
|
+
at: Date;
|
|
44
|
+
/** which service handled it — 'relationship-service', 'album-service' */
|
|
45
|
+
service: string;
|
|
46
|
+
action: 'create' | 'update' | 'delete' | 'read' | 'call';
|
|
47
|
+
/** what kind of thing — 'activity', 'activity_type', 'photo' */
|
|
48
|
+
resource: string;
|
|
49
|
+
resourceId?: string;
|
|
50
|
+
actorKind: ActorKind;
|
|
51
|
+
/** the account acted for; every listing is scoped to this */
|
|
52
|
+
userId: string;
|
|
53
|
+
actorLabel: string;
|
|
54
|
+
/** a key's id or an OAuth client id; absent when a person acted directly */
|
|
55
|
+
actorCredentialId?: string;
|
|
56
|
+
/** the journal it happened in */
|
|
57
|
+
tenant: string;
|
|
58
|
+
/**
|
|
59
|
+
* What changed, field by field.
|
|
60
|
+
*
|
|
61
|
+
* Present on updates. This is the difference between an audit log and a
|
|
62
|
+
* request log: "Claude edited an entry" is barely worth storing, "Claude
|
|
63
|
+
* changed hours from 3 to 2" is the thing someone actually wants to see.
|
|
64
|
+
*/
|
|
65
|
+
changes?: Record<string, { from: unknown; to: unknown }>;
|
|
66
|
+
/** what a delete removed, so it can be read back or restored by hand */
|
|
67
|
+
snapshot?: Record<string, unknown>;
|
|
68
|
+
/** how many records a read returned — the size of what left the server */
|
|
69
|
+
count?: number;
|
|
70
|
+
/** present on 'call': the tool, and what it cost */
|
|
71
|
+
tool?: ToolCall;
|
|
72
|
+
createdAt: Date;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ToolCallSchema = new Schema<ToolCall>(
|
|
76
|
+
{
|
|
77
|
+
name: { type: String, required: true },
|
|
78
|
+
ms: { type: Number, required: true },
|
|
79
|
+
chars: { type: Number, required: true },
|
|
80
|
+
tokens: { type: Number, required: true },
|
|
81
|
+
failed: { type: Boolean }
|
|
82
|
+
},
|
|
83
|
+
{ _id: false }
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const AuditEventSchema = new Schema<IAuditEvent>(
|
|
87
|
+
{
|
|
88
|
+
at: { type: Date, required: true, default: Date.now },
|
|
89
|
+
service: { type: String, required: true },
|
|
90
|
+
action: { type: String, required: true, enum: ['create', 'update', 'delete', 'read', 'call'] },
|
|
91
|
+
resource: { type: String, required: true },
|
|
92
|
+
resourceId: { type: String },
|
|
93
|
+
actorKind: { type: String, required: true, enum: ['person', 'key', 'assistant'] },
|
|
94
|
+
userId: { type: String, required: true, index: true },
|
|
95
|
+
actorLabel: { type: String, required: true },
|
|
96
|
+
actorCredentialId: { type: String },
|
|
97
|
+
tenant: { type: String, required: true },
|
|
98
|
+
changes: { type: Schema.Types.Mixed },
|
|
99
|
+
snapshot: { type: Schema.Types.Mixed },
|
|
100
|
+
count: { type: Number },
|
|
101
|
+
tool: { type: ToolCallSchema }
|
|
102
|
+
},
|
|
103
|
+
{ timestamps: { createdAt: true, updatedAt: false }, collection: 'audit_events' }
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Every listing is one account's trail, newest first.
|
|
107
|
+
AuditEventSchema.index({ userId: 1, at: -1 });
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Rows expire on their own.
|
|
111
|
+
*
|
|
112
|
+
* They carry journal content — the old value of a description, a deleted
|
|
113
|
+
* entry — so keeping them indefinitely would quietly build a second copy of the
|
|
114
|
+
* journal that nothing in the product ever deletes from. A year is long enough
|
|
115
|
+
* to answer "what happened" and short enough that the copy does not outlive the
|
|
116
|
+
* question.
|
|
117
|
+
*/
|
|
118
|
+
const RETENTION_DAYS = 365;
|
|
119
|
+
AuditEventSchema.index({ at: 1 }, { expireAfterSeconds: RETENTION_DAYS * 24 * 60 * 60 });
|
|
120
|
+
|
|
121
|
+
export const AuditEvent = mongoose.models.AuditEvent
|
|
122
|
+
? (mongoose.models.AuditEvent as mongoose.Model<IAuditEvent>)
|
|
123
|
+
: mongoose.model<IAuditEvent>('AuditEvent', AuditEventSchema);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
import { actorOf, describeActor } from './actor';
|
|
3
|
+
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
4
|
+
|
|
5
|
+
const req = (over: Partial<Request> = {}): Request =>
|
|
6
|
+
({ query: {}, body: {}, headers: {}, ...over }) as unknown as Request;
|
|
7
|
+
|
|
8
|
+
const grant = { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT };
|
|
9
|
+
|
|
10
|
+
describe('actorOf', () => {
|
|
11
|
+
it('reads a session as the person themselves', () => {
|
|
12
|
+
const actor = actorOf(req({ user: { id: 'u1', email: 'tom@example.com', name: 'Tom' } }));
|
|
13
|
+
|
|
14
|
+
expect(actor).toMatchObject({ kind: 'person', userId: 'u1', label: 'Tom' });
|
|
15
|
+
// Nothing to revoke and nothing that rotates: a person is not a credential.
|
|
16
|
+
expect(actor?.credentialId).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('falls back to the email when a session carries no name', () => {
|
|
20
|
+
// An unlabelled row is the one nobody can act on.
|
|
21
|
+
const actor = actorOf(req({ user: { id: 'u1', email: 'tom@example.com', name: '' } }));
|
|
22
|
+
expect(actor?.label).toBe('tom@example.com');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('names an API key by what its owner called it', () => {
|
|
26
|
+
const actor = actorOf(
|
|
27
|
+
req({
|
|
28
|
+
user: { id: 'u1', email: 'tom@example.com', name: 'Tom' },
|
|
29
|
+
apiKey: {
|
|
30
|
+
keyId: 'k1',
|
|
31
|
+
scopes: [],
|
|
32
|
+
tenants: grant,
|
|
33
|
+
actingAs: PERSONAL_TENANT,
|
|
34
|
+
kind: 'api_key',
|
|
35
|
+
label: 'Claude Code',
|
|
36
|
+
credentialId: 'k1'
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(actor).toMatchObject({ kind: 'key', label: 'Claude Code', credentialId: 'k1' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('names a connected assistant by its client, not its token', () => {
|
|
45
|
+
const actor = actorOf(
|
|
46
|
+
req({
|
|
47
|
+
user: { id: 'u1', email: 'tom@example.com', name: 'Tom' },
|
|
48
|
+
apiKey: {
|
|
49
|
+
keyId: 'client-1',
|
|
50
|
+
scopes: [],
|
|
51
|
+
tenants: { allowed: ['g1'], default: 'g1' },
|
|
52
|
+
actingAs: 'g1',
|
|
53
|
+
kind: 'oauth',
|
|
54
|
+
label: 'Claude',
|
|
55
|
+
credentialId: 'client-1'
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// An access token's `jti` rotates hourly; a trail built on it would show a
|
|
61
|
+
// different actor after every refresh.
|
|
62
|
+
expect(actor).toMatchObject({ kind: 'assistant', label: 'Claude', credentialId: 'client-1' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('records the journal the request is acting in', () => {
|
|
66
|
+
const actor = actorOf(
|
|
67
|
+
req({
|
|
68
|
+
user: { id: 'u1', email: 'a@b.c', name: 'Tom' },
|
|
69
|
+
apiKey: {
|
|
70
|
+
keyId: 'c1',
|
|
71
|
+
scopes: [],
|
|
72
|
+
tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' },
|
|
73
|
+
actingAs: 'g1',
|
|
74
|
+
kind: 'oauth',
|
|
75
|
+
label: 'Claude',
|
|
76
|
+
credentialId: 'c1'
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(actor?.tenant).toBe('g1');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('has nothing to attribute when nobody is authenticated', () => {
|
|
85
|
+
// A row about the transport is not a row about anyone.
|
|
86
|
+
expect(actorOf(req())).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('describes software so it is not mistaken for a person', () => {
|
|
90
|
+
expect(describeActor({ kind: 'person', userId: 'u1', label: 'Tom', tenant: PERSONAL_TENANT })).toBe('Tom');
|
|
91
|
+
expect(
|
|
92
|
+
describeActor({ kind: 'assistant', userId: 'u1', label: 'Claude', tenant: 'g1' })
|
|
93
|
+
).toBe('Claude (assistant)');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Who is acting, in the only three ways anything reaches this system.
|
|
6
|
+
*
|
|
7
|
+
* The distinction that matters is `person` against everything else. A person is
|
|
8
|
+
* at a keyboard and can see what they are doing; a key or an assistant is
|
|
9
|
+
* software acting on their behalf, and is the thing an audit trail exists to
|
|
10
|
+
* make legible. Splitting `key` from `assistant` is worth the extra case because
|
|
11
|
+
* they fail differently: a key is a secret someone pasted somewhere and may have
|
|
12
|
+
* lost, an assistant is a consent screen someone approved and can withdraw.
|
|
13
|
+
*/
|
|
14
|
+
export type ActorKind = 'person' | 'key' | 'assistant';
|
|
15
|
+
|
|
16
|
+
export interface Actor {
|
|
17
|
+
kind: ActorKind;
|
|
18
|
+
/** the account being acted for — the same whichever kind of actor it is */
|
|
19
|
+
userId: string;
|
|
20
|
+
/** what to show in a trail: the person's name, the key's name, the app's name */
|
|
21
|
+
label: string;
|
|
22
|
+
/**
|
|
23
|
+
* The credential, for software.
|
|
24
|
+
*
|
|
25
|
+
* A key's document id, or an OAuth client id. Deliberately not the access
|
|
26
|
+
* token's `jti`, which rotates hourly and would show a different actor after
|
|
27
|
+
* every refresh.
|
|
28
|
+
*/
|
|
29
|
+
credentialId?: string;
|
|
30
|
+
/** the journal this request is acting in */
|
|
31
|
+
tenant: Tenant;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the actor off a request that has already been authenticated.
|
|
36
|
+
*
|
|
37
|
+
* Returns null for an unauthenticated request rather than inventing an
|
|
38
|
+
* anonymous actor: there is nothing to attribute, and a row saying so would be
|
|
39
|
+
* a row about the transport, not about anyone.
|
|
40
|
+
*/
|
|
41
|
+
export function actorOf(req: Request): Actor | null {
|
|
42
|
+
const user = req.user;
|
|
43
|
+
if (!user?.id) return null;
|
|
44
|
+
|
|
45
|
+
const credential = req.apiKey;
|
|
46
|
+
if (!credential) {
|
|
47
|
+
return {
|
|
48
|
+
kind: 'person',
|
|
49
|
+
userId: user.id,
|
|
50
|
+
// Falling back to the email because a name is optional on a session and an
|
|
51
|
+
// unlabelled row is the one nobody can act on.
|
|
52
|
+
label: user.name || user.email || user.id,
|
|
53
|
+
tenant: (req.query?.groupId as string) || PERSONAL_TENANT
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
kind: credential.kind === 'oauth' ? 'assistant' : 'key',
|
|
59
|
+
userId: user.id,
|
|
60
|
+
label: credential.label,
|
|
61
|
+
credentialId: credential.credentialId,
|
|
62
|
+
tenant: credential.actingAs
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One line naming an actor, for a log message or an error. */
|
|
67
|
+
export const describeActor = (actor: Actor): string =>
|
|
68
|
+
actor.kind === 'person' ? actor.label : `${actor.label} (${actor.kind})`;
|