@3sln/trove 0.0.10 → 0.0.12
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/package.json +1 -1
- package/packages/core/src/apiKeys.js +1 -13
- package/packages/core/src/collections/index.js +24 -0
- package/packages/core/src/identity/jwt.js +48 -0
- package/packages/core/src/index.js +2 -1
- package/packages/core/src/signedUrls.js +1 -13
- package/packages/core/src/timingSafe.js +30 -0
- package/packages/server/src/access/externalEvaluation.js +169 -0
- package/packages/server/src/index.js +43 -2
- package/packages/server/src/routes.js +21 -0
- package/packages/web/dist/assets/main-c9dnnnc6.js +356 -0
- package/packages/web/dist/assets/{main-jg5vmp8f.js.map → main-c9dnnnc6.js.map} +10 -10
- package/packages/web/dist/assets/styles-hskanqc0.css +1 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +61 -0
- package/packages/web/src/bl/index.js +3 -1
- package/packages/web/src/bl/queries.js +42 -2
- package/packages/web/src/bl/services.js +38 -0
- package/packages/web/src/bl/state.js +14 -0
- package/packages/web/src/platform/api.js +15 -0
- package/packages/web/src/styles.css +11 -0
- package/packages/web/src/ui/components/adminView.js +144 -2
- package/packages/web/src/ui/compositions/workbench.js +1 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js +0 -356
- package/packages/web/dist/assets/styles-e5gk19rn.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@3sln/trove",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
|
|
6
6
|
"repository": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// nothing replayable. It is shown to the minter exactly once.
|
|
27
27
|
|
|
28
28
|
import { TroveError } from './errors.js';
|
|
29
|
+
import { timingSafeEqual } from './timingSafe.js';
|
|
29
30
|
import { CAPABILITIES, expand } from './collections/index.js';
|
|
30
31
|
|
|
31
32
|
const NS = 'api-keys';
|
|
@@ -72,19 +73,6 @@ async function sha256(text) {
|
|
|
72
73
|
return b64url(new Uint8Array(digest));
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/**
|
|
76
|
-
* Compare without leaking where two strings diverge.
|
|
77
|
-
*
|
|
78
|
-
* The hash of a presented secret against the stored hash. A `===` here would return
|
|
79
|
-
* faster the earlier it finds a difference, which over enough attempts is a way to learn
|
|
80
|
-
* a prefix — the classic reason credential comparison is not string equality.
|
|
81
|
-
*/
|
|
82
|
-
function timingSafeEqual(a, b) {
|
|
83
|
-
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
|
|
84
|
-
let diff = 0;
|
|
85
|
-
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
86
|
-
return diff === 0;
|
|
87
|
-
}
|
|
88
76
|
|
|
89
77
|
/** Normalise and validate one `{ collectionId, capabilities }` entry. */
|
|
90
78
|
function normalizeScope(scope) {
|
|
@@ -324,6 +324,30 @@ export class CollectionService {
|
|
|
324
324
|
return all.every((c) => this.can(principal, c, 'read') && this.can(principal, c, 'write'));
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
/**
|
|
328
|
+
* What this principal may reach across the whole drive, and whether that is anything.
|
|
329
|
+
*
|
|
330
|
+
* The ACL as a DECISION rather than as a record, so something outside this library can
|
|
331
|
+
* ask it — an edge policy deciding whether an email gets through the front door at all,
|
|
332
|
+
* for instance. Everything it needs is already here; what was missing was a way to ask
|
|
333
|
+
* without reimplementing `can()` against the grant shape, which is exactly how two
|
|
334
|
+
* copies of an authorization rule start.
|
|
335
|
+
*
|
|
336
|
+
* "Allowed" means: a named admin, or read on at least one collection. Read on nothing is
|
|
337
|
+
* the honest definition of someone with no business here — note that on a `defaultOpen`
|
|
338
|
+
* drive the `anyone` grant makes that true for everybody, which is correct, because the
|
|
339
|
+
* ACL is what says so.
|
|
340
|
+
*/
|
|
341
|
+
async accessFor(principal) {
|
|
342
|
+
const admin = this.isAdmin(principal);
|
|
343
|
+
const collections = await this.list(principal);
|
|
344
|
+
return {
|
|
345
|
+
allowed: admin || collections.length > 0,
|
|
346
|
+
admin,
|
|
347
|
+
collections: collections.map((c) => ({ id: c.id, name: c.name, capabilities: c.capabilities })),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
327
351
|
/** Global admin (can do anything, incl. grant admin-only plugin capabilities). */
|
|
328
352
|
isAdmin(principal) {
|
|
329
353
|
return this.#isNamedAdmin(principal);
|
|
@@ -141,6 +141,54 @@ async function importVerifyKey(alg, key) {
|
|
|
141
141
|
* @param {number} [opts.clockToleranceSec]
|
|
142
142
|
* @param {number|null} [opts.now] ms epoch; pass null to say there is no clock
|
|
143
143
|
*/
|
|
144
|
+
/** base64url WITHOUT padding, which is what a JWT wants everywhere. */
|
|
145
|
+
function bytesToBase64url(bytes) {
|
|
146
|
+
let bin = '';
|
|
147
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
148
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Sign a JWT with a private JWK.
|
|
153
|
+
*
|
|
154
|
+
* The mirror of `verifyJwt`, and it exists for one caller: answering an external policy
|
|
155
|
+
* evaluation, where the asker fetches OUR public key to check the answer. That is why it is
|
|
156
|
+
* asymmetric where `signedUrls.js` is happy with HMAC — a shared secret would mean handing
|
|
157
|
+
* the verifier the ability to mint answers.
|
|
158
|
+
*
|
|
159
|
+
* RS256 by default, and that is not a preference. Cloudflare Access verifies these, its
|
|
160
|
+
* documentation says an RSA key pair and that "other key formats are not supported", and
|
|
161
|
+
* its own reference implementation signs with RSASSA-PKCS1-v1_5 over SHA-256. ES256 is a
|
|
162
|
+
* better curve and would have been silently rejected by the only thing that reads these.
|
|
163
|
+
*/
|
|
164
|
+
export async function signJwt(payload, { privateJwk, kid, alg = 'RS256', expiresInSec = 60, now = Date.now() } = {}) {
|
|
165
|
+
if (!privateJwk) throw TroveError.invalid('Signing a JWT needs a private JWK');
|
|
166
|
+
const spec = ALGS[alg];
|
|
167
|
+
if (!spec || alg === 'HS256') throw TroveError.unsupported(`Cannot sign a JWT with alg ${alg}`);
|
|
168
|
+
const iat = Math.floor(now / 1000);
|
|
169
|
+
const body = { iat, exp: iat + expiresInSec, ...payload };
|
|
170
|
+
const header = { alg, typ: 'JWT', ...(kid ? { kid } : {}) };
|
|
171
|
+
const signingInput = `${bytesToBase64url(enc.encode(JSON.stringify(header)))}.`
|
|
172
|
+
+ `${bytesToBase64url(enc.encode(JSON.stringify(body)))}`;
|
|
173
|
+
const key = await crypto.subtle.importKey('jwk', { ...privateJwk, alg: undefined, key_ops: undefined }, spec.import, false, ['sign']);
|
|
174
|
+
const sig = new Uint8Array(await crypto.subtle.sign(spec.verify, key, enc.encode(signingInput)));
|
|
175
|
+
return `${signingInput}.${bytesToBase64url(sig)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The public half of a private JWK, as a JWKS entry.
|
|
180
|
+
*
|
|
181
|
+
* Strips `d` — the private scalar — and everything else a signer needs and a verifier must
|
|
182
|
+
* not have. Written as a subtraction rather than a copy of the public fields so a JWK that
|
|
183
|
+
* grows a field cannot silently start publishing it.
|
|
184
|
+
*/
|
|
185
|
+
export function publicJwkOf(privateJwk, { kid, alg = 'RS256' } = {}) {
|
|
186
|
+
const { d, p, q, dp, dq, qi, ...pub } = privateJwk || {};
|
|
187
|
+
// `alg` is published because Cloudflare's own keys endpoint publishes it, and a verifier
|
|
188
|
+
// that trusts the JWK's alg over the token header's is the safer of the two.
|
|
189
|
+
return { ...pub, alg, key_ops: ['verify'], use: 'sig', ...(kid ? { kid } : {}) };
|
|
190
|
+
}
|
|
191
|
+
|
|
144
192
|
export async function verifyJwt(token, opts = {}) {
|
|
145
193
|
const { header, payload, parts } = decodeJwt(token);
|
|
146
194
|
const alg = header.alg;
|
|
@@ -83,7 +83,8 @@ export {
|
|
|
83
83
|
AnonymousIdentityProvider, principalFromClaims,
|
|
84
84
|
cloudflareAccess, accessHost,
|
|
85
85
|
} from './identity/index.js';
|
|
86
|
-
export { verifyJwt, decodeJwt, JwksClient, StaticJwks } from './identity/jwt.js';
|
|
86
|
+
export { verifyJwt, signJwt, publicJwkOf, decodeJwt, JwksClient, StaticJwks } from './identity/jwt.js';
|
|
87
|
+
export { timingSafeEqual } from './timingSafe.js';
|
|
87
88
|
// Where an unauthenticated client is told to go — one answer for the whole drive.
|
|
88
89
|
export {
|
|
89
90
|
protectedResourceMetadata, challengeHeaders, metadataUrl, publicOrigin,
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// may see must not be editable into one for a file you may not.
|
|
19
19
|
|
|
20
20
|
import { TroveError } from './errors.js';
|
|
21
|
+
import { timingSafeEqual } from './timingSafe.js';
|
|
21
22
|
|
|
22
23
|
const MINUTES = 60;
|
|
23
24
|
const HOURS = 60 * MINUTES;
|
|
@@ -132,19 +133,6 @@ export class SignedUrls {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
/**
|
|
136
|
-
* Constant-time string compare.
|
|
137
|
-
*
|
|
138
|
-
* `a === b` on a signature leaks where the first differing byte is, which over enough
|
|
139
|
-
* requests is a forgery oracle. The cost of not caring is small and the cost of caring is
|
|
140
|
-
* nothing.
|
|
141
|
-
*/
|
|
142
|
-
function timingSafeEqual(a, b) {
|
|
143
|
-
if (a.length !== b.length) return false;
|
|
144
|
-
let diff = 0;
|
|
145
|
-
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
146
|
-
return diff === 0;
|
|
147
|
-
}
|
|
148
136
|
|
|
149
137
|
/**
|
|
150
138
|
* A secret that survives a restart and is shared between instances.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Comparing a credential without saying how nearly you got it right.
|
|
2
|
+
//
|
|
3
|
+
// `a === b` returns as soon as it finds a difference, so it is fast for a wrong first byte
|
|
4
|
+
// and slower for a wrong last one. Over enough attempts that difference is measurable, and
|
|
5
|
+
// measuring it recovers the secret one byte at a time — which is why credential comparison
|
|
6
|
+
// is never string equality.
|
|
7
|
+
//
|
|
8
|
+
// One copy, and this file exists because there were two: `signedUrls.js` and `apiKeys.js`
|
|
9
|
+
// each grew their own, and the two had already drifted — one guarded its argument types and
|
|
10
|
+
// the other trusted the call site. That is the cheapest possible version of the bug where
|
|
11
|
+
// two copies of a security rule disagree, and it is worth not having.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Do these two strings match, in time that does not depend on where they differ?
|
|
15
|
+
*
|
|
16
|
+
* Length is NOT hidden: returning early on a length mismatch leaks how long the secret is,
|
|
17
|
+
* which for a fixed-width hash or signature is public anyway. Hiding it would mean hashing
|
|
18
|
+
* both sides first, and every caller here compares values that are already digests.
|
|
19
|
+
*
|
|
20
|
+
* A non-string is false rather than a throw. The inputs come off the wire, `undefined` is a
|
|
21
|
+
* perfectly ordinary thing for a missing header to be, and a comparison that throws on it
|
|
22
|
+
* turns a failed auth attempt into a 500.
|
|
23
|
+
*/
|
|
24
|
+
export function timingSafeEqual(a, b) {
|
|
25
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
26
|
+
if (a.length !== b.length) return false;
|
|
27
|
+
let diff = 0;
|
|
28
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
29
|
+
return diff === 0;
|
|
30
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// External policy evaluation — the drive's ACL, answering an identity provider.
|
|
2
|
+
//
|
|
3
|
+
// WHAT THIS IS FOR
|
|
4
|
+
//
|
|
5
|
+
// Cloudflare Access can call out to a service mid-login and ask "should this person be let
|
|
6
|
+
// in?". Point it here and the answer comes from the collection ACLs: whoever has read on
|
|
7
|
+
// at least one collection gets through the front door, and nobody else reaches the drive at
|
|
8
|
+
// all. Access stays the single place you edit — the administration screen — and it now
|
|
9
|
+
// governs two things instead of one:
|
|
10
|
+
//
|
|
11
|
+
// the EDGE who gets past Cloudflare, decided here
|
|
12
|
+
// the BUCKET what they may do once inside, decided by the same ACL, as it always was
|
|
13
|
+
//
|
|
14
|
+
// The second half is unchanged and still authoritative. This does not replace an internal
|
|
15
|
+
// check anywhere; letting someone through the door does not grant them a collection. If
|
|
16
|
+
// this component is switched off, nothing about the drive's own guarding changes.
|
|
17
|
+
//
|
|
18
|
+
// OPTIONAL, and off unless configured. Mounted exactly like a notification channel — the
|
|
19
|
+
// component contributes routes and `createServer` adds them — so a drive that has not
|
|
20
|
+
// configured it has no `/api/access/*` at all rather than endpoints that answer "no".
|
|
21
|
+
//
|
|
22
|
+
// SETTING IT UP
|
|
23
|
+
//
|
|
24
|
+
// 1. Make a key pair. It is asymmetric because Cloudflare must be able to CHECK our
|
|
25
|
+
// answer without being able to MINT one — and it is RSA because Access says so:
|
|
26
|
+
// "other key formats are not supported", and its reference implementation signs
|
|
27
|
+
// RS256. An EC key here is silently rejected by the only thing that reads these.
|
|
28
|
+
//
|
|
29
|
+
// node -e "crypto.subtle.generateKey({name:'RSASSA-PKCS1-v1_5',modulusLength:2048,
|
|
30
|
+
// publicExponent:new Uint8Array([1,0,1]),hash:'SHA-256'},true,['sign','verify'])
|
|
31
|
+
// .then(k=>crypto.subtle.exportKey('jwk',k.privateKey)).then(j=>console.log(JSON.stringify(j)))"
|
|
32
|
+
//
|
|
33
|
+
// 2. TROVE_ACCESS_EVAL_KEY=<that JSON> (and TROVE_CF_ACCESS_TEAM if not already set)
|
|
34
|
+
// 3. In the Access policy, add an External Evaluation rule:
|
|
35
|
+
// Evaluate URL https://<your drive>/api/access/evaluate
|
|
36
|
+
// Keys URL https://<your drive>/api/access/keys
|
|
37
|
+
//
|
|
38
|
+
// 4. AND let Access reach them. If the drive sits behind Access — which is the whole
|
|
39
|
+
// point — then by default these two paths do as well, and Access would have to
|
|
40
|
+
// authenticate to itself to call them. It cannot, so it fails with nothing useful to
|
|
41
|
+
// say. Add a Bypass policy for `/api/access/*`.
|
|
42
|
+
//
|
|
43
|
+
// Bypassing is safe here rather than a concession, and that is by construction: the
|
|
44
|
+
// keys endpoint serves a public key, and the evaluate endpoint authenticates its
|
|
45
|
+
// caller itself by verifying the assertion. Neither ever depended on Access for its
|
|
46
|
+
// own protection. They are also `public: true`, so this drive's OWN identity
|
|
47
|
+
// requirement does not apply to them either — Access holds no Trove session and
|
|
48
|
+
// never will.
|
|
49
|
+
//
|
|
50
|
+
// WHY IT REFUSES TO RUN UNVERIFIED
|
|
51
|
+
//
|
|
52
|
+
// This endpoint answers "does this email have access to this drive". That is a question
|
|
53
|
+
// worth lying to strangers about, so it will not answer one it cannot attribute: the
|
|
54
|
+
// caller's assertion must verify against the configured team's JWKS. Without a team
|
|
55
|
+
// configured the component declines to mount rather than mounting an open oracle that
|
|
56
|
+
// enumerates your users to anyone who can reach it.
|
|
57
|
+
//
|
|
58
|
+
// THE TRANSPORT, CHECKED AGAINST CLOUDFLARE (Aug 2026)
|
|
59
|
+
//
|
|
60
|
+
// Verified against the External Evaluation docs and their reference Worker:
|
|
61
|
+
//
|
|
62
|
+
// in POST, JSON body `{ token }`, the JWT signed by the Access account key
|
|
63
|
+
// out JSON body `{ token }`, a JWT of `{ success, iat, exp, nonce }`, RS256
|
|
64
|
+
// keys `{ keys: [ <public JWK, with kid> ] }`
|
|
65
|
+
//
|
|
66
|
+
// The identity lands at `claims.identity.email` in their example rather than at the top
|
|
67
|
+
// level, which is why `principalOf` looks in both places. `parseAssertion` stays generous
|
|
68
|
+
// about where it finds the incoming JWT: the token is self-authenticating, so accepting it
|
|
69
|
+
// from more than one place costs nothing and survives a small change on their side.
|
|
70
|
+
|
|
71
|
+
import { TroveError, verifyJwt, signJwt, publicJwkOf, JwksClient } from '@3sln/trove/core';
|
|
72
|
+
|
|
73
|
+
/** Where Cloudflare publishes the keys for a team's own assertions. */
|
|
74
|
+
const teamJwksUrl = (team) => `https://${team}.cloudflareaccess.com/cdn-cgi/access/certs`;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Find the assertion in whatever shape it arrived.
|
|
78
|
+
*
|
|
79
|
+
* Generous on purpose — see the header. The JWT is the only thing that matters and it is
|
|
80
|
+
* self-authenticating, so accepting it from several places costs nothing: a forgery is
|
|
81
|
+
* still a forgery wherever it was found.
|
|
82
|
+
*/
|
|
83
|
+
export async function parseAssertion(req) {
|
|
84
|
+
const header = req.headers?.get?.('cf-access-jwt-assertion');
|
|
85
|
+
if (header) return header;
|
|
86
|
+
const text = await req.text();
|
|
87
|
+
if (!text) return null;
|
|
88
|
+
try {
|
|
89
|
+
const body = JSON.parse(text);
|
|
90
|
+
return body.token || body.jwt || body.assertion || null;
|
|
91
|
+
} catch {
|
|
92
|
+
// Not JSON: some callers post the bare token.
|
|
93
|
+
return text.trim() || null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The identity Cloudflare is asking about, as a principal this drive understands. */
|
|
98
|
+
export function principalOf(claims) {
|
|
99
|
+
const email = claims?.email || claims?.identity?.email || claims?.sub || null;
|
|
100
|
+
if (!email) return null;
|
|
101
|
+
return { id: email, email, name: claims?.name || email, roles: claims?.groups || claims?.roles || [] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* An external-evaluation component, or null when this drive has not configured one.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} cfg
|
|
108
|
+
* @param {object} cfg.privateJwk EC P-256 private key, the one whose public half we publish
|
|
109
|
+
* @param {string} cfg.team Cloudflare Access team, for verifying the caller
|
|
110
|
+
* @param {string} [cfg.kid] key id, so the key can be rotated without an outage
|
|
111
|
+
*/
|
|
112
|
+
export function externalEvaluation({ privateJwk, team, kid = 'trove-access', jwks, now = Date.now } = {}) {
|
|
113
|
+
if (!privateJwk) return null;
|
|
114
|
+
// Refusing rather than warning: see the header. An oracle nobody authenticated is worse
|
|
115
|
+
// than no oracle.
|
|
116
|
+
if (!team && !jwks) {
|
|
117
|
+
throw TroveError.invalid(
|
|
118
|
+
'External evaluation needs a Cloudflare Access team to verify callers against — set TROVE_CF_ACCESS_TEAM',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const keys = jwks || new JwksClient(teamJwksUrl(team));
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
name: 'cloudflare-external-evaluation',
|
|
125
|
+
routes() {
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
method: 'POST',
|
|
129
|
+
path: '/api/access/evaluate',
|
|
130
|
+
deps: ['collections'],
|
|
131
|
+
// No Trove identity: the caller is Cloudflare, not a user of this drive, and it
|
|
132
|
+
// authenticates by signing the assertion — which `verifyJwt` below checks. A
|
|
133
|
+
// session requirement here would be asking Access to log in as somebody.
|
|
134
|
+
public: true,
|
|
135
|
+
async handler(ctx) {
|
|
136
|
+
const token = await parseAssertion(ctx.req);
|
|
137
|
+
if (!token) throw TroveError.invalid('No access assertion in the request');
|
|
138
|
+
// Verified before it is read. Everything below trusts these claims, so this
|
|
139
|
+
// line is the whole security of the component.
|
|
140
|
+
const claims = await verifyJwt(token, { jwks: keys, now: now() });
|
|
141
|
+
const principal = principalOf(claims);
|
|
142
|
+
|
|
143
|
+
// No identity is a "no", not an error: Access is asking about somebody and we
|
|
144
|
+
// cannot say yes about somebody we cannot name.
|
|
145
|
+
const decision = principal && ctx.collections
|
|
146
|
+
? await ctx.collections.accessFor(principal)
|
|
147
|
+
: { allowed: false, admin: false, collections: [] };
|
|
148
|
+
|
|
149
|
+
const answer = await signJwt(
|
|
150
|
+
{ success: !!decision.allowed, nonce: claims.nonce, email: principal?.email || null },
|
|
151
|
+
{ privateJwk, kid, now: now() },
|
|
152
|
+
);
|
|
153
|
+
return { token: answer };
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
method: 'GET',
|
|
158
|
+
path: '/api/access/keys',
|
|
159
|
+
deps: [],
|
|
160
|
+
public: true,
|
|
161
|
+
// Public by design: it is a public key, and Cloudflare fetches it unauthenticated.
|
|
162
|
+
handler() {
|
|
163
|
+
return { keys: [publicJwkOf(privateJwk, { kid })] };
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -23,6 +23,7 @@ import { storageRegistry } from './engine/providers/core.js';
|
|
|
23
23
|
import { createMcpHandler } from './mcp/index.js';
|
|
24
24
|
import { cacheControlFor } from './cachePolicy.js';
|
|
25
25
|
import { MANIFEST_PATH, webManifest, manifestFromEnv } from './manifest.js';
|
|
26
|
+
import { externalEvaluation } from './access/externalEvaluation.js';
|
|
26
27
|
|
|
27
28
|
// Every backend is pluggable. Each field of `config` accepts EITHER a ready
|
|
28
29
|
// provider instance (pass your own class) OR a `{ driver, ... }` config object
|
|
@@ -325,6 +326,24 @@ export async function createServer(config = {}) {
|
|
|
325
326
|
}
|
|
326
327
|
}
|
|
327
328
|
|
|
329
|
+
// The external policy component, on the same terms: it contributes routes or it does not
|
|
330
|
+
// exist. A drive that has not configured one has no `/api/access/*` at all, rather than
|
|
331
|
+
// endpoints that exist to answer "no" — which is the difference between a feature that is
|
|
332
|
+
// off and a feature that is broken.
|
|
333
|
+
// Paths a contributed component declared as needing no Trove identity. Exact matches
|
|
334
|
+
// only, and that is deliberate: the check below runs BEFORE routing, so it cannot know
|
|
335
|
+
// which parameterised route would have matched. A component that wants a public route
|
|
336
|
+
// gives it a fixed path.
|
|
337
|
+
const publicPaths = new Set();
|
|
338
|
+
|
|
339
|
+
const accessPolicy = config.accessEvaluation
|
|
340
|
+
? externalEvaluation({ ...config.accessEvaluation, team: config.accessEvaluation.team || config.identity?.access?.team })
|
|
341
|
+
: null;
|
|
342
|
+
for (const route of accessPolicy?.routes?.(routeHelpers) || []) {
|
|
343
|
+
router.add(route.method, route.path, route.deps || [], route.handler);
|
|
344
|
+
if (route.public) publicPaths.add(route.path);
|
|
345
|
+
}
|
|
346
|
+
|
|
328
347
|
// Said at boot, because that is when someone is looking and can still fix it. The
|
|
329
348
|
// alternative is discovering it from a client that can't sign in and a 401 that
|
|
330
349
|
// doesn't say why.
|
|
@@ -397,8 +416,14 @@ export async function createServer(config = {}) {
|
|
|
397
416
|
let principal = null;
|
|
398
417
|
let grant = null;
|
|
399
418
|
try {
|
|
400
|
-
|
|
401
|
-
|
|
419
|
+
// A public route answers to something other than this drive's identity — the
|
|
420
|
+
// external policy endpoints verify a Cloudflare-signed assertion themselves, and
|
|
421
|
+
// the keys endpoint serves a public key. Requiring a session on those is asking
|
|
422
|
+
// the caller to authenticate as a user it is not and does not have.
|
|
423
|
+
if (!publicPaths.has(url.pathname)) {
|
|
424
|
+
grant = await capabilities.resolve(req);
|
|
425
|
+
if (!grant) principal = await identity.authenticate(req);
|
|
426
|
+
}
|
|
402
427
|
} catch (err) {
|
|
403
428
|
const e = err instanceof TroveError ? err : TroveError.unauthorized('Authentication failed');
|
|
404
429
|
return withChallenge(new Response(JSON.stringify(e.toJSON()), { status: e.status, headers: { 'content-type': 'application/json', 'x-content-type-options': 'nosniff' } }), req);
|
|
@@ -850,6 +875,22 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
|
|
|
850
875
|
}
|
|
851
876
|
|
|
852
877
|
// Cross-origin API access is off unless an origin (or '*') is configured.
|
|
878
|
+
// External policy evaluation: present only when a signing key is. See
|
|
879
|
+
// access/externalEvaluation.js for how to make one.
|
|
880
|
+
if (env.TROVE_ACCESS_EVAL_KEY) {
|
|
881
|
+
let privateJwk;
|
|
882
|
+
try {
|
|
883
|
+
privateJwk = JSON.parse(env.TROVE_ACCESS_EVAL_KEY);
|
|
884
|
+
} catch {
|
|
885
|
+
throw TroveError.invalid('TROVE_ACCESS_EVAL_KEY must be a private JWK as JSON');
|
|
886
|
+
}
|
|
887
|
+
config.accessEvaluation = {
|
|
888
|
+
privateJwk,
|
|
889
|
+
team: env.TROVE_CF_ACCESS_TEAM || null,
|
|
890
|
+
kid: env.TROVE_ACCESS_EVAL_KID || 'trove-access',
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
853
894
|
config.corsOrigin = env.TROVE_CORS_ORIGIN || null;
|
|
854
895
|
// App-shell CSP is opt-in (see SAMPLE_CSP) — provide a full policy string to
|
|
855
896
|
// enable it. Off by default because sandboxed plugin iframes can't satisfy one.
|
|
@@ -272,6 +272,27 @@ export function createRouter() {
|
|
|
272
272
|
return collections.remove(params.id, principal);
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Who has access to this collection.
|
|
277
|
+
*
|
|
278
|
+
* A separate route rather than a field on `describe()`, which is documented as "a safe,
|
|
279
|
+
* principal-scoped view (no secrets)" and is returned to everyone who can see the
|
|
280
|
+
* collection at all. The ACL is not a secret exactly, but it names people, and every
|
|
281
|
+
* listing would carry it. Asking for it is an explicitly admin-gated act.
|
|
282
|
+
*/
|
|
283
|
+
r.get('/api/collections/:id/grants', ['collections'], async (ctx) => {
|
|
284
|
+
requireCollections(ctx);
|
|
285
|
+
const c = await ctx.collections.assert(ctx.principal, ctx.params.id, 'admin');
|
|
286
|
+
// Drive administrators come from the DEPLOYMENT (TROVE_ADMINS), not from this ACL, and
|
|
287
|
+
// returning them alongside rather than inside `grants` is the honest shape: they hold
|
|
288
|
+
// admin on every collection, `setGrant` cannot touch them, and a UI that listed them as
|
|
289
|
+
// grants would offer a revoke button that silently does nothing.
|
|
290
|
+
return {
|
|
291
|
+
grants: c.acl?.grants || [],
|
|
292
|
+
admins: [...(ctx.collections.admins || [])],
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
|
|
275
296
|
r.post('/api/collections/:id/grants', ['collections'], async (ctx) => {
|
|
276
297
|
requireCollections(ctx);
|
|
277
298
|
return { collection: await ctx.collections.setGrant(ctx.params.id, await body(ctx.req), ctx.principal) };
|