@rebasepro/server 0.25.0 → 0.26.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/{GCSStorageController-C7ARrDAp.js → GCSStorageController-40FyFeSx.js} +8 -4
- package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
- package/dist/{S3StorageController-qevJ0pVE.js → S3StorageController-Bl5shp94.js} +8 -4
- package/dist/S3StorageController-Bl5shp94.js.map +1 -0
- package/dist/access-BFAUvsSs.js +1897 -0
- package/dist/access-BFAUvsSs.js.map +1 -0
- package/dist/api/branch-routes.d.ts +25 -0
- package/dist/api/closed-database.d.ts +25 -0
- package/dist/api/live-schema-routes.d.ts +10 -0
- package/dist/api/rest/api-generator.d.ts +9 -1
- package/dist/api/rest/idempotency.d.ts +15 -1
- package/dist/api/root-error-handler.d.ts +6 -5
- package/dist/api/types.d.ts +12 -0
- package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
- package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
- package/dist/auth/access.d.ts +10 -0
- package/dist/auth/adapter-middleware.d.ts +2 -1
- package/dist/auth/adapter-service-key.d.ts +24 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
- package/dist/auth/api-keys/api-key-store.d.ts +8 -1
- package/dist/auth/api-keys/key-grant.d.ts +18 -1
- package/dist/auth/expired-token-sweep.d.ts +5 -0
- package/dist/auth/impersonation.d.ts +3 -1
- package/dist/auth/jwt.d.ts +8 -2
- package/dist/auth/oauth-signin-policy.d.ts +21 -35
- package/dist/auth/rls-scope.d.ts +17 -7
- package/dist/auth/rls-simulator-routes.d.ts +51 -0
- package/dist/auth/simulation-read-only.d.ts +28 -0
- package/dist/auth/simulation.d.ts +141 -0
- package/dist/auth/sql-rate-limit-store.d.ts +15 -1
- package/dist/{auth-CteGWxsL.js → auth-izI7AEzC.js} +391 -1893
- package/dist/auth-izI7AEzC.js.map +1 -0
- package/dist/auth_adapter-B7RUMk6e.js +89 -0
- package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
- package/dist/backend/boot-announcements.d.ts +13 -0
- package/dist/backend/create-backend.d.ts +10 -0
- package/dist/backend/options.d.ts +9 -4
- package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
- package/dist/backend-CaG_Jyso.js.map +1 -0
- package/dist/{backup-Dq0lyJ_D.js → backup-BVbHoKo8.js} +8 -8
- package/dist/{backup-Dq0lyJ_D.js.map → backup-BVbHoKo8.js.map} +1 -1
- package/dist/boot/boot.d.ts +2 -15
- package/dist/boot/ddl-bootstrap.d.ts +34 -0
- package/dist/boot/env.d.ts +6 -0
- package/dist/boot/fetch-bundle.d.ts +5 -1
- package/dist/boot/provision-only.d.ts +23 -0
- package/dist/boot/provision.d.ts +53 -2
- package/dist/boot/role.d.ts +2 -0
- package/dist/boot/schema-change-recorder.d.ts +45 -0
- package/dist/boot/security-headers.d.ts +22 -0
- package/dist/branch-routes-B2vvEx53.js +84 -0
- package/dist/branch-routes-B2vvEx53.js.map +1 -0
- package/dist/{contract-routes-DiFwiLg2.js → contract-routes-CU2A3JnF.js} +3 -3
- package/dist/{contract-routes-DiFwiLg2.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
- package/dist/cron/cron-store.d.ts +8 -1
- package/dist/{cron-loader-NHqRfKF0.js → cron-loader-mMhqbVsl.js} +3 -3
- package/dist/{cron-loader-NHqRfKF0.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
- package/dist/{cron-routes-Dhf82HzT.js → cron-routes-Cs33j_Kr.js} +5 -5
- package/dist/{cron-routes-Dhf82HzT.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
- package/dist/{cron-scheduler-D1DT0nw5.js → cron-scheduler-Be7CNghL.js} +3 -3
- package/dist/{cron-scheduler-D1DT0nw5.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
- package/dist/{cron-store-DEpVstUU.js → cron-store-DLn11M8H.js} +11 -10
- package/dist/cron-store-DLn11M8H.js.map +1 -0
- package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
- package/dist/data_driver-D0mg1Y_x.js.map +1 -0
- package/dist/{ddl-bootstrap-BNqi2NZe.js → ddl-bootstrap-CjRrn12l.js} +92 -3
- package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
- package/dist/deploy/pod-contract.d.ts +1 -1
- package/dist/email/dev-sink.d.ts +6 -0
- package/dist/email/index.d.ts +1 -1
- package/dist/{errors-_ytoGi8A.js → errors-OMWkKSH-.js} +66 -5
- package/dist/errors-OMWkKSH-.js.map +1 -0
- package/dist/{soft-delete-params-BAgpmBvp.js → field-access-DYsvjI_P.js} +27 -57
- package/dist/field-access-DYsvjI_P.js.map +1 -0
- package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
- package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
- package/dist/{function-loader-DbCSoNJj.js → function-loader-B8YzXEsQ.js} +22 -15
- package/dist/function-loader-B8YzXEsQ.js.map +1 -0
- package/dist/{function-routes-DNV4RUF0.js → function-routes-RxrCHNuM.js} +2 -2
- package/dist/{function-routes-DNV4RUF0.js.map → function-routes-RxrCHNuM.js.map} +1 -1
- package/dist/functions/hono-like.d.ts +12 -0
- package/dist/functions/index.js +37 -2
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/request-timeout.d.ts +3 -3
- package/dist/history/history-routes.d.ts +6 -0
- package/dist/history/retention-sweep.d.ts +5 -0
- package/dist/{history-recorder-RZ1Ow4YY.js → history-recorder-Bsau7h4X.js} +7 -7
- package/dist/history-recorder-Bsau7h4X.js.map +1 -0
- package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
- package/dist/history-store-BQz5YCkW.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.es.js +3003 -681
- package/dist/index.es.js.map +1 -1
- package/dist/init/process-safety.d.ts +4 -3
- package/dist/init/shutdown.d.ts +8 -0
- package/dist/init.d.ts +6 -1
- package/dist/jobs/job-store.d.ts +8 -1
- package/dist/{jobs-8G-_agld.js → jobs-Co6Ni5HP.js} +7 -7
- package/dist/jobs-Co6Ni5HP.js.map +1 -0
- package/dist/{jwt-YhciR93H.js → jwt-rDS0884_.js} +5 -4
- package/dist/{jwt-YhciR93H.js.map → jwt-rDS0884_.js.map} +1 -1
- package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
- package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
- package/dist/{logger-Bt32sHFw.js → logger-CW8pmTS6.js} +35 -3
- package/dist/logger-CW8pmTS6.js.map +1 -0
- package/dist/{logs-routes-CelTiS7C.js → logs-routes-CBt7mMIL.js} +3 -3
- package/dist/{logs-routes-CelTiS7C.js.map → logs-routes-CBt7mMIL.js.map} +1 -1
- package/dist/mcp/consent-page.d.ts +38 -1
- package/dist/mcp/mcp-routes.d.ts +9 -0
- package/dist/mcp/mcp-tools.d.ts +7 -1
- package/dist/mcp/oauth-store.d.ts +12 -5
- package/dist/metrics/history-recorder.d.ts +4 -1
- package/dist/metrics/history-store.d.ts +8 -0
- package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
- package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
- package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
- package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
- package/dist/{openapi-generator-BamY6-ga.js → openapi-generator-CyxEtYEX.js} +61 -22
- package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
- package/dist/{proxy-BEKbsil6.js → proxy-BYir8tWK.js} +5 -7
- package/dist/proxy-BYir8tWK.js.map +1 -0
- package/dist/{query-parser-CGohjyWI.js → query-parser-CDXWf2hH.js} +7 -6
- package/dist/{query-parser-CGohjyWI.js.map → query-parser-CDXWf2hH.js.map} +1 -1
- package/dist/{request-timeout-CRny5KD0.js → request-timeout-DVjo-c_8.js} +6 -6
- package/dist/request-timeout-DVjo-c_8.js.map +1 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
- package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
- package/dist/{schema-editor-routes-CnNGCayU.js → schema-editor-routes-Dc3DU_jm.js} +4 -4
- package/dist/{schema-editor-routes-CnNGCayU.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
- package/dist/soft-delete-params-BRONCOA2.js +59 -0
- package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
- package/dist/{sql-export-routes-BgTJ7K3S.js → sql-export-routes-QycX8W8S.js} +40 -6
- package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
- package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
- package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
- package/dist/storage/GCSStorageController.d.ts +2 -0
- package/dist/storage/LocalStorageController.d.ts +8 -3
- package/dist/storage/S3StorageController.d.ts +2 -0
- package/dist/storage/maintenance-keys.d.ts +17 -0
- package/dist/storage/maintenance-routes.d.ts +28 -5
- package/dist/storage/maintenance.d.ts +136 -4
- package/dist/storage/orphan-scans.d.ts +120 -0
- package/dist/storage/types.d.ts +11 -0
- package/dist/strings.es-B7QLnHh2.js +96 -0
- package/dist/strings.es-B7QLnHh2.js.map +1 -0
- package/dist/{upload-validation-I9cCUBIW.js → upload-validation-Bgo626uY.js} +2 -2
- package/dist/upload-validation-Bgo626uY.js.map +1 -0
- package/dist/utils/dev-dependency-command.d.ts +15 -0
- package/dist/utils/dev-port.d.ts +8 -0
- package/dist/utils/logger.d.ts +24 -0
- package/package.json +5 -5
- package/dist/GCSStorageController-C7ARrDAp.js.map +0 -1
- package/dist/S3StorageController-qevJ0pVE.js.map +0 -1
- package/dist/auth-CteGWxsL.js.map +0 -1
- package/dist/auth_adapter-CIItCTc8.js +0 -21
- package/dist/auth_adapter-CIItCTc8.js.map +0 -1
- package/dist/backend-DTAOsLQc.js.map +0 -1
- package/dist/cron-store-DEpVstUU.js.map +0 -1
- package/dist/ddl-bootstrap-BNqi2NZe.js.map +0 -1
- package/dist/errors-_ytoGi8A.js.map +0 -1
- package/dist/function-loader-DbCSoNJj.js.map +0 -1
- package/dist/history-recorder-RZ1Ow4YY.js.map +0 -1
- package/dist/history-store-DPofGVDp.js.map +0 -1
- package/dist/jobs-8G-_agld.js.map +0 -1
- package/dist/logger-Bt32sHFw.js.map +0 -1
- package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
- package/dist/openapi-generator-BamY6-ga.js.map +0 -1
- package/dist/proxy-BEKbsil6.js.map +0 -1
- package/dist/request-timeout-CRny5KD0.js.map +0 -1
- package/dist/soft-delete-params-BAgpmBvp.js.map +0 -1
- package/dist/sql-export-routes-BgTJ7K3S.js.map +0 -1
- package/dist/strings.es-BeZWAodn.js.map +0 -1
- package/dist/upload-validation-I9cCUBIW.js.map +0 -1
|
@@ -3,20 +3,18 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { i as __toESM, n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import {
|
|
7
|
-
import { B as rewriteLegacyRlsFunctions, _ as scopesForRoles, a as KEY_MANAGEMENT_SCOPES, b as validateScopes, c as intersectScopes, d as isDataPlaneScope, f as knownScopes, g as scopesBeyond, h as scopeGrantsAny, i as EMPTY_ACCESS_MODEL, l as isAdminScope, m as scopeGrants, n as ADMIN_SCOPES, o as SCOPE_TARGETS, p as parseScope, r as BUILT_IN_SCOPES, s as hasAdminRole, t as ADMIN_ROLE, u as isBuiltInScope, v as summarizeRoles } from "./src-D5Y3N-S4.js";
|
|
6
|
+
import { V as rewriteLegacyRlsFunctions, _ as scopesBeyond, a as KEY_MANAGEMENT_SCOPES, f as knownScopes, l as isAdminScope, m as roleNameProblem, o as SCOPE_TARGETS, p as parseScope, s as hasAdminRole, u as isBuiltInScope, x as validateScopes, y as summarizeRoles } from "./src-Bh9LRPb9.js";
|
|
8
7
|
import { n as isAnonymousUid, r as policy, t as ANONYMOUS_USER_ID } from "./policy-PdzX-Opd.js";
|
|
9
|
-
import {
|
|
10
|
-
import { t as
|
|
11
|
-
import {
|
|
8
|
+
import { n as isSQLAdmin } from "./backend-CaG_Jyso.js";
|
|
9
|
+
import { $ as accessTokenRefusalError, C as queryTokenAuth, E as requireAuth, G as isApiKeyToken, J as extractBearerToken, K as resolveApiKey, L as applySimulation, P as applyImpersonation, T as requireAdmin, X as SERVICE_IDENTITY, Y as safeCompare, Z as scopeDataDriver, _ as createAuthMiddleware, a as assertMayManageAccount, at as httpMethodToOperation, b as fileTokenAuth, c as configureAccess, f as processAccessControl, h as requireScopeByMethod, i as assertMayGrantRoles, l as getAccessModel, m as requireScope, n as accessModelFromCollections, nt as replaceUserPassword, o as callerIsAdmin, p as readRoleNames, q as validateApiKey, r as accessOf, rt as revokeAllSessions, s as callerScopes, t as AccessModelError, tt as judgeAccessToken, u as hasScope, v as createRequireAuth, w as refuse, x as optionalAuth, y as extractUserFromToken } from "./access-BFAUvsSs.js";
|
|
10
|
+
import { a as logger } from "./logger-CW8pmTS6.js";
|
|
11
|
+
import { r as toSnakeCase } from "./strings.es-B7QLnHh2.js";
|
|
12
12
|
import { n as revokeInternalTableSql, t as REBASE_USER_ROLE } from "./internal-tables-L2t39RdT.js";
|
|
13
13
|
import { t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
|
|
14
|
-
import { a as resolveListLimitParam } from "./query-parser-
|
|
15
|
-
import { a as
|
|
16
|
-
import {
|
|
17
|
-
import { a as
|
|
18
|
-
import { n as scopeGuardProblem } from "./admin-roles-DI9RsgN3.js";
|
|
19
|
-
import { n as createDdlBootstrapper } from "./ddl-bootstrap-BNqi2NZe.js";
|
|
14
|
+
import { a as resolveListLimitParam } from "./query-parser-CDXWf2hH.js";
|
|
15
|
+
import { _ as verifyAccessToken, a as generateAccessToken, c as getAccessTokenExpiry, d as hasAsymmetricSigningKey, f as hashRefreshToken, h as processJwtService, i as customClaimsOf, l as getJwks, m as jwtOf, n as configureJwt, o as generateDownloadToken, p as isJwtConfigured, s as generateRefreshToken, t as MAX_COOKIE_AGE_MS, u as getRefreshTokenExpiry, v as verifyDownloadToken, w as randomInt$1, x as require_jsonwebtoken } from "./jwt-rDS0884_.js";
|
|
16
|
+
import { c as provisioningExec, n as SchemaChangeRefusedError, r as createDdlBootstrapper } from "./ddl-bootstrap-CjRrn12l.js";
|
|
17
|
+
import { a as isDataException, c as warnIfDatabaseClosed, i as errorHandler, o as isDatabaseUnreachable, t as ApiError } from "./errors-OMWkKSH-.js";
|
|
20
18
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
21
19
|
import { Hono } from "hono";
|
|
22
20
|
import { promisify } from "util";
|
|
@@ -24,108 +22,6 @@ import { createCipheriv, createDecipheriv, createHash as createHash$1, createHma
|
|
|
24
22
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
25
23
|
import { createMiddleware } from "hono/factory";
|
|
26
24
|
import { z } from "zod";
|
|
27
|
-
//#region ../types/src/controllers/storage.ts
|
|
28
|
-
/**
|
|
29
|
-
* Path prefix that marks an object as **public**. Files stored under this
|
|
30
|
-
* prefix are served without any auth token via a stable, permanent,
|
|
31
|
-
* CDN-cacheable URL (see {@link StorageSource.getSignedUrl}). Shared by the
|
|
32
|
-
* client SDK and the backend so both agree on which objects are public.
|
|
33
|
-
*
|
|
34
|
-
* @group Models
|
|
35
|
-
*/
|
|
36
|
-
var PUBLIC_STORAGE_PREFIX = "public/";
|
|
37
|
-
/**
|
|
38
|
-
* True when a storage **key** is public: it lies under
|
|
39
|
-
* {@link PUBLIC_STORAGE_PREFIX}.
|
|
40
|
-
*
|
|
41
|
-
* The key is read exactly as it is — the canonical key within its bucket, the
|
|
42
|
-
* one the server stores, serves, and hands a `storageAuthorize` hook as
|
|
43
|
-
* `key` — and nothing is parsed out of it. That is the difference from
|
|
44
|
-
* {@link isPublicStoragePath}, which reads a client-side *path* and so strips
|
|
45
|
-
* a `scheme://` and a leading `default/` bucket segment first. On a key both are
|
|
46
|
-
* wrong: `default/public/x` is a key in a folder called `default`, and a
|
|
47
|
-
* request path `notes://public/x` names the key `notes:/public/x`. Neither is
|
|
48
|
-
* public, and the server decides on this function so that it never says they
|
|
49
|
-
* are.
|
|
50
|
-
*
|
|
51
|
-
* @group Models
|
|
52
|
-
*/
|
|
53
|
-
function isPublicStorageKey(key) {
|
|
54
|
-
if (!key || !key.startsWith("public/")) return false;
|
|
55
|
-
return !key.split(/[\\/]/).includes("..");
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* True when a storage key or client-side path points at a public object
|
|
59
|
-
* (lives under {@link PUBLIC_STORAGE_PREFIX}).
|
|
60
|
-
*
|
|
61
|
-
* For a path as a client holds it: a `scheme://` prefix and a single leading
|
|
62
|
-
* `default/` bucket segment are stripped first. The server and a
|
|
63
|
-
* `storageAuthorize` hook, which hold the canonical key, use
|
|
64
|
-
* {@link isPublicStorageKey}.
|
|
65
|
-
*
|
|
66
|
-
* @group Models
|
|
67
|
-
*/
|
|
68
|
-
function isPublicStoragePath(path) {
|
|
69
|
-
if (!path) return false;
|
|
70
|
-
let p = path;
|
|
71
|
-
const scheme = p.indexOf("://");
|
|
72
|
-
if (scheme !== -1) p = p.substring(scheme + 3);
|
|
73
|
-
p = p.replace(/^\/+/, "");
|
|
74
|
-
if (p.split("/").some((seg) => seg === "..")) return false;
|
|
75
|
-
return p.startsWith("public/") || p.startsWith(`default/public/`);
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* The path the storage routes address an object by, from a key or from the
|
|
79
|
-
* `storageUrl` an upload returned (`<scheme>://<bucket>/<key>`, which is what a
|
|
80
|
-
* property with `includeBucketUrl: true` keeps in its column).
|
|
81
|
-
*
|
|
82
|
-
* The routes address a source's objects by key. On S3 and GCS the bucket in a
|
|
83
|
-
* `storageUrl` is always the source's own — the server refuses any other — so
|
|
84
|
-
* it is dropped: sent along, `s3://acme-media/products/a.png` reached the
|
|
85
|
-
* server as the key `acme-media/products/a.png`, the preview found nothing and
|
|
86
|
-
* a delete reported success while the object stayed. It worked on local disk
|
|
87
|
-
* only, because `local://default/<key>` happens to carry the one bucket
|
|
88
|
-
* segment the routes recognise — which is kept, as is any other local bucket.
|
|
89
|
-
*
|
|
90
|
-
* A bare key whose first folder is `default` is sent behind that bucket
|
|
91
|
-
* segment. The routes read one leading `default/` (in any case) as the bucket,
|
|
92
|
-
* so the key `default/photo.png` sent as it is names the root `photo.png`:
|
|
93
|
-
* {@link defaultBucketPath} is the spelling that names the key itself.
|
|
94
|
-
*
|
|
95
|
-
* Shared by every storage source that talks to those routes, so they address
|
|
96
|
-
* an object alike.
|
|
97
|
-
*
|
|
98
|
-
* @group Models
|
|
99
|
-
*/
|
|
100
|
-
function storageObjectPath(keyOrUrl, bucket) {
|
|
101
|
-
let filePath = keyOrUrl;
|
|
102
|
-
let bucketQualified = false;
|
|
103
|
-
const scheme = /^(local|s3|gs):\/\//.exec(filePath);
|
|
104
|
-
if (scheme) {
|
|
105
|
-
filePath = filePath.substring(scheme[0].length);
|
|
106
|
-
if (scheme[1] !== "local") {
|
|
107
|
-
const slash = filePath.indexOf("/");
|
|
108
|
-
if (slash > 0) filePath = filePath.substring(slash + 1);
|
|
109
|
-
} else bucketQualified = true;
|
|
110
|
-
}
|
|
111
|
-
if (bucket && filePath) {
|
|
112
|
-
if (!filePath.startsWith(`${bucket}/`)) filePath = `${bucket}/${filePath}`;
|
|
113
|
-
bucketQualified = true;
|
|
114
|
-
}
|
|
115
|
-
return bucketQualified ? filePath : defaultBucketPath(filePath);
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* The request path of a key in the default bucket: the key itself, unless the
|
|
119
|
-
* routes would read its first folder as the bucket — a first folder named
|
|
120
|
-
* `default`, matched as the routes match it (case-insensitively, and only when
|
|
121
|
-
* something follows it). Then the bucket segment goes in front, so exactly the
|
|
122
|
-
* one segment the routes strip is the one added.
|
|
123
|
-
*/
|
|
124
|
-
function defaultBucketPath(key) {
|
|
125
|
-
const segments = key.split("/");
|
|
126
|
-
return segments.length > 1 && segments[0].toLowerCase() === "default" ? `default/${key}` : key;
|
|
127
|
-
}
|
|
128
|
-
//#endregion
|
|
129
25
|
//#region ../common/src/util/email.ts
|
|
130
26
|
/**
|
|
131
27
|
* Email normalization — one implementation, because the database enforces it.
|
|
@@ -283,6 +179,21 @@ function scalarSubqueryBody(s) {
|
|
|
283
179
|
const inner = s.slice(1, -1).trim();
|
|
284
180
|
return STARTS_WITH_SELECT.test(inner) ? inner.slice(6).trim() : null;
|
|
285
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* The names in a role array's body — `'admin', 'editor'`, each with or without
|
|
184
|
+
* Postgres's `::text` — or `null` when an element is anything else. A quote
|
|
185
|
+
* or a comma inside a name is not split into roles that were never there: the
|
|
186
|
+
* clause stays raw and round-trips verbatim.
|
|
187
|
+
*/
|
|
188
|
+
function roleArrayElements(list) {
|
|
189
|
+
const roles = [];
|
|
190
|
+
for (const element of list.split(",")) {
|
|
191
|
+
const match = /^'([^',]*)'(?:::text)?$/.exec(element.trim());
|
|
192
|
+
if (!match) return null;
|
|
193
|
+
roles.push(match[1]);
|
|
194
|
+
}
|
|
195
|
+
return roles;
|
|
196
|
+
}
|
|
286
197
|
/** Whether any part of `expr` fell through to `raw`. */
|
|
287
198
|
function containsRaw(expr) {
|
|
288
199
|
switch (expr.kind) {
|
|
@@ -303,16 +214,9 @@ function sqlToPolicy(sql) {
|
|
|
303
214
|
}
|
|
304
215
|
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
305
216
|
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
return policy.rolesOverlap(roles);
|
|
310
|
-
}
|
|
311
|
-
const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
|
|
312
|
-
if (containMatch) {
|
|
313
|
-
const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
314
|
-
return policy.rolesContain(roles);
|
|
315
|
-
}
|
|
217
|
+
const rolesMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','(?:::text)?\s*\)\s*(&&|@>)\s*ARRAY\s*\[(.+)\]$/i);
|
|
218
|
+
const roles = rolesMatch ? roleArrayElements(rolesMatch[2]) : null;
|
|
219
|
+
if (rolesMatch && roles) return rolesMatch[1] === "&&" ? policy.rolesOverlap(roles) : policy.rolesContain(roles);
|
|
316
220
|
const orParts = splitTopLevel(trimmed, "OR");
|
|
317
221
|
if (orParts) return policy.or(...orParts.map(sqlToPolicy));
|
|
318
222
|
const andParts = splitTopLevel(trimmed, "AND");
|
|
@@ -629,26 +533,188 @@ function checkOperation(collection, authContext, entity, targetOperation, option
|
|
|
629
533
|
return hasPermissive ? grantedByPermissive : false;
|
|
630
534
|
}
|
|
631
535
|
//#endregion
|
|
632
|
-
//#region src/auth/
|
|
536
|
+
//#region src/auth/resolve-rate-limit-store.ts
|
|
537
|
+
/**
|
|
538
|
+
* A refusal to boot, phrased as the variable to change.
|
|
539
|
+
*
|
|
540
|
+
* Its own class so `bootFromBundle` can present it as a configuration problem
|
|
541
|
+
* rather than a crash, matching `RoleConfigurationError`.
|
|
542
|
+
*/
|
|
543
|
+
var RateLimitStoreConfigurationError = class extends Error {
|
|
544
|
+
hint;
|
|
545
|
+
constructor(message, hint) {
|
|
546
|
+
super(message);
|
|
547
|
+
this.hint = hint;
|
|
548
|
+
this.name = "RateLimitStoreConfigurationError";
|
|
549
|
+
}
|
|
550
|
+
};
|
|
633
551
|
/**
|
|
634
|
-
*
|
|
552
|
+
* Which store this process should build.
|
|
635
553
|
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
554
|
+
* A misspelled value **refuses to boot** rather than falling back to memory.
|
|
555
|
+
* Every other outcome here is silent — the whole failure mode of this setting is
|
|
556
|
+
* that nothing looks wrong — so the one moment it can be caught is the moment it
|
|
557
|
+
* is read. `REBASE_RATE_LIMIT_STORE=postgres` is the obvious thing to type and
|
|
558
|
+
* would otherwise be a per-replica limit wearing the shape of a shared one.
|
|
639
559
|
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
560
|
+
* A blank or whitespace-only value is *unset*, not invalid:
|
|
561
|
+
* `REBASE_RATE_LIMIT_STORE=${SOMETHING}` with `SOMETHING` undefined is the
|
|
562
|
+
* ordinary way to write a compose file, and it is how the platform neutralises a
|
|
563
|
+
* tenant's own variable.
|
|
642
564
|
*/
|
|
643
|
-
function
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
565
|
+
function resolveRateLimitStoreKind(env) {
|
|
566
|
+
const raw = (env.REBASE_RATE_LIMIT_STORE ?? "").trim().toLowerCase();
|
|
567
|
+
if (raw === "") return "memory";
|
|
568
|
+
if (raw === "memory" || raw === "sql") return raw;
|
|
569
|
+
throw new RateLimitStoreConfigurationError(`REBASE_RATE_LIMIT_STORE="${raw}" is not a rate-limit store.`, "Use \"sql\" to share counts across processes, or \"memory\" (the default) to keep them in this process. A deployment running more than one replica or more than one role wants \"sql\" — otherwise each process enforces the whole limit by itself.");
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region src/auth/sql-rate-limit-store.ts
|
|
573
|
+
var SCHEMA = "rebase";
|
|
574
|
+
var TABLE_NAME = "rate_limit_hits";
|
|
575
|
+
var TABLE$1 = `${SCHEMA}.${TABLE_NAME}`;
|
|
576
|
+
/** How often expired buckets are deleted. */
|
|
577
|
+
var DEFAULT_SWEEP_MS = 300 * 1e3;
|
|
578
|
+
function createSqlRateLimitStore(driver, options = {}) {
|
|
579
|
+
const admin = driver.admin;
|
|
580
|
+
if (!isSQLAdmin(admin)) {
|
|
581
|
+
logger.warn("⚠️ [rate-limit] The configured driver has no SQL admin, so rate-limit counts cannot be shared between processes. Falling back to this process's memory — each replica will enforce the limit independently.");
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const exec = provisioningExec((sqlText, execOptions) => admin.executeSql(sqlText, execOptions?.params ? { params: execOptions.params } : void 0), options.provision);
|
|
585
|
+
const ddl = createDdlBootstrapper(exec, "rate-limit-store");
|
|
586
|
+
const now = options.now ?? Date.now;
|
|
587
|
+
const sweepMs = options.sweepMs ?? DEFAULT_SWEEP_MS;
|
|
588
|
+
let sweepTimer;
|
|
589
|
+
let ready;
|
|
590
|
+
/**
|
|
591
|
+
* Create the table once, and remember whether it worked.
|
|
592
|
+
*
|
|
593
|
+
* Lazily rather than at boot because the store is constructed while routes
|
|
594
|
+
* are being mounted, and an `await` there would put a database round trip on
|
|
595
|
+
* the boot path of every deployment — including the ones that never take a
|
|
596
|
+
* request. The first request pays for it.
|
|
597
|
+
*
|
|
598
|
+
* The result is cached even on failure: a store that cannot create its table
|
|
599
|
+
* must not retry the DDL on every request, which would turn one broken
|
|
600
|
+
* permission into a per-request query storm.
|
|
601
|
+
*
|
|
602
|
+
* Readiness is decided by reading the table, never by the DDL not throwing —
|
|
603
|
+
* `ensureObject` routes through `step`, which logs a failure and returns
|
|
604
|
+
* normally. Keying on the absence of an exception would report a table that
|
|
605
|
+
* was never created as ready, and every later request would fall into the
|
|
606
|
+
* per-request error path instead of the one-time fallback.
|
|
607
|
+
*/
|
|
608
|
+
async function ensureReady() {
|
|
609
|
+
if (!ready) ready = (async () => {
|
|
610
|
+
try {
|
|
611
|
+
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
612
|
+
await ddl.ensureObject(`Creating ${TABLE$1}`, `
|
|
613
|
+
CREATE TABLE IF NOT EXISTS ${TABLE$1} (
|
|
614
|
+
key TEXT NOT NULL,
|
|
615
|
+
bucket BIGINT NOT NULL,
|
|
616
|
+
hits INTEGER NOT NULL DEFAULT 0,
|
|
617
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
618
|
+
PRIMARY KEY (key, bucket)
|
|
619
|
+
)
|
|
620
|
+
`);
|
|
621
|
+
await ddl.ensureObject("Creating idx_rate_limit_hits_expiry", `
|
|
622
|
+
CREATE INDEX IF NOT EXISTS idx_rate_limit_hits_expiry
|
|
623
|
+
ON ${TABLE$1}(expires_at)
|
|
624
|
+
`);
|
|
625
|
+
const readable = await ddl.isReadable(TABLE$1);
|
|
626
|
+
if (readable) await ddl.step("Revoking end-user access to rate_limit_hits", () => exec(revokeInternalTableSql(SCHEMA, TABLE_NAME)));
|
|
627
|
+
else if (options.provision === false) logger.error(`[rate-limit] ${TABLE$1} does not exist, and this backend changes nothing in its database, so counts stay per-process. Every replica will enforce the limit independently until a provisioning run creates the table.`);
|
|
628
|
+
else logger.error("[rate-limit] The shared rate-limit table could not be created, so counts stay per-process. Every replica will enforce the limit independently.");
|
|
629
|
+
return readable;
|
|
630
|
+
} catch (err) {
|
|
631
|
+
logger.error("[rate-limit] Could not prepare the shared rate-limit table. Falling back to per-process counts for the life of this process.", { error: err instanceof Error ? err.message : String(err) });
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
})();
|
|
635
|
+
return ready;
|
|
636
|
+
}
|
|
637
|
+
function startSweeping() {
|
|
638
|
+
if (sweepTimer) return;
|
|
639
|
+
sweepTimer = setInterval(() => {
|
|
640
|
+
exec(`DELETE FROM ${TABLE$1} WHERE expires_at < now()`).catch((err) => {
|
|
641
|
+
logger.debug("[rate-limit] Bucket sweep failed", { error: err instanceof Error ? err.message : String(err) });
|
|
642
|
+
});
|
|
643
|
+
}, sweepMs);
|
|
644
|
+
sweepTimer.unref?.();
|
|
650
645
|
}
|
|
646
|
+
return {
|
|
647
|
+
ensureTable: ensureReady,
|
|
648
|
+
async hit(key, windowMs, limit) {
|
|
649
|
+
if (!await ensureReady()) return {
|
|
650
|
+
allowed: true,
|
|
651
|
+
remaining: limit,
|
|
652
|
+
retryAfterMs: 0
|
|
653
|
+
};
|
|
654
|
+
startSweeping();
|
|
655
|
+
const at = now();
|
|
656
|
+
const bucket = Math.floor(at / windowMs);
|
|
657
|
+
const carry = 1 - (at - bucket * windowMs) / windowMs;
|
|
658
|
+
const expiresAt = new Date((bucket + 2) * windowMs).toISOString();
|
|
659
|
+
let current;
|
|
660
|
+
let previous;
|
|
661
|
+
try {
|
|
662
|
+
const row = firstRow(await exec(`WITH bumped AS (
|
|
663
|
+
INSERT INTO ${TABLE$1} AS r (key, bucket, hits, expires_at)
|
|
664
|
+
VALUES ($1, $2, 1, $3)
|
|
665
|
+
ON CONFLICT (key, bucket)
|
|
666
|
+
DO UPDATE SET hits = r.hits + 1, expires_at = EXCLUDED.expires_at
|
|
667
|
+
RETURNING hits
|
|
668
|
+
)
|
|
669
|
+
SELECT
|
|
670
|
+
(SELECT hits FROM bumped) AS current,
|
|
671
|
+
COALESCE((SELECT hits FROM ${TABLE$1} WHERE key = $1 AND bucket = $4), 0) AS previous`, { params: [
|
|
672
|
+
key,
|
|
673
|
+
bucket,
|
|
674
|
+
expiresAt,
|
|
675
|
+
bucket - 1
|
|
676
|
+
] }));
|
|
677
|
+
current = Number(row?.current ?? 1);
|
|
678
|
+
previous = Number(row?.previous ?? 0);
|
|
679
|
+
} catch (err) {
|
|
680
|
+
logger.debug("[rate-limit] Shared count unavailable for this request", { error: err instanceof Error ? err.message : String(err) });
|
|
681
|
+
return {
|
|
682
|
+
allowed: true,
|
|
683
|
+
remaining: limit,
|
|
684
|
+
retryAfterMs: 0
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
const estimate = current + previous * carry;
|
|
688
|
+
if (estimate > limit) {
|
|
689
|
+
exec(`UPDATE ${TABLE$1} SET hits = GREATEST(hits - 1, 0) WHERE key = $1 AND bucket = $2`, { params: [key, bucket] }).catch(() => {});
|
|
690
|
+
return {
|
|
691
|
+
allowed: false,
|
|
692
|
+
remaining: 0,
|
|
693
|
+
retryAfterMs: Math.max(0, (bucket + 1) * windowMs - at)
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
allowed: true,
|
|
698
|
+
remaining: Math.max(0, Math.floor(limit - estimate)),
|
|
699
|
+
retryAfterMs: 0
|
|
700
|
+
};
|
|
701
|
+
},
|
|
702
|
+
dispose() {
|
|
703
|
+
if (sweepTimer) clearInterval(sweepTimer);
|
|
704
|
+
sweepTimer = void 0;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
651
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* The first row of whatever shape this driver returns.
|
|
710
|
+
*
|
|
711
|
+
* `SqlExec` declares an array of rows, but drivers in this repository have also
|
|
712
|
+
* returned a `{ rows }` envelope through the same escape hatch. Reading one
|
|
713
|
+
* shape only is how a store silently counts nothing and reports every caller as
|
|
714
|
+
* being on their first request — a limiter that never limits, with no error
|
|
715
|
+
* anywhere. Tolerating both costs one line.
|
|
716
|
+
*/
|
|
717
|
+
var firstRow = firstSqlRow;
|
|
652
718
|
//#endregion
|
|
653
719
|
//#region src/email/html.ts
|
|
654
720
|
/**
|
|
@@ -1660,6 +1726,7 @@ function enforceCreationPasswordPolicy(body, resolvedHooks, options) {
|
|
|
1660
1726
|
*/
|
|
1661
1727
|
async function prepareAdminUserValues(rawBody, ctx, options) {
|
|
1662
1728
|
const { resolvedHooks, collectionAuthConfig } = ctx;
|
|
1729
|
+
if (rawBody.roles !== void 0 && rawBody.roles !== null) readRoleNames(rawBody.roles);
|
|
1663
1730
|
const body = enforceCreationPasswordPolicy(rawBody, resolvedHooks, options);
|
|
1664
1731
|
if (collectionAuthConfig?.onCreateUser) {
|
|
1665
1732
|
const collectionCtx = buildCollectionContext(ctx);
|
|
@@ -1822,1509 +1889,46 @@ async function normalizeUserEmailChange(authRepo, uid, email) {
|
|
|
1822
1889
|
*/
|
|
1823
1890
|
function runAfterUserDelete(resolvedHooks, uid) {
|
|
1824
1891
|
if (!resolvedHooks.afterUserDelete) return;
|
|
1825
|
-
resolvedHooks.afterUserDelete(uid).catch((err) => {
|
|
1826
|
-
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
1827
|
-
});
|
|
1828
|
-
}
|
|
1829
|
-
/**
|
|
1830
|
-
* The auth adapter's side of user administration through the auth collection
|
|
1831
|
-
* (`AuthAdapter.prepareUserUpdates` and the deletion pair): the admin user
|
|
1832
|
-
* routes' rules and hooks, for the data API's writes to the same rows.
|
|
1833
|
-
*/
|
|
1834
|
-
function authCollectionUserAdmin(ctx) {
|
|
1835
|
-
const { authRepo, resolvedHooks } = ctx;
|
|
1836
|
-
return {
|
|
1837
|
-
async prepareUserUpdates(updates) {
|
|
1838
|
-
await assertRoleChangesAllowed(authRepo, updates.flatMap(({ uid, values }) => Array.isArray(values.roles) ? [{
|
|
1839
|
-
uid,
|
|
1840
|
-
roles: values.roles
|
|
1841
|
-
}] : []));
|
|
1842
|
-
const prepared = [];
|
|
1843
|
-
for (const { uid, values } of updates) prepared.push(typeof values.email === "string" ? {
|
|
1844
|
-
...values,
|
|
1845
|
-
email: await normalizeUserEmailChange(authRepo, uid, values.email)
|
|
1846
|
-
} : values);
|
|
1847
|
-
return prepared;
|
|
1848
|
-
},
|
|
1849
|
-
async prepareUserDeletions(uids) {
|
|
1850
|
-
await assertUserDeletionsAllowed(authRepo, uids);
|
|
1851
|
-
if (resolvedHooks.beforeUserDelete) for (const uid of uids) await resolvedHooks.beforeUserDelete(uid);
|
|
1852
|
-
},
|
|
1853
|
-
async finalizeUserDeletions(uids) {
|
|
1854
|
-
for (const uid of uids) {
|
|
1855
|
-
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
1856
|
-
runAfterUserDelete(resolvedHooks, uid);
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
};
|
|
1860
|
-
}
|
|
1861
|
-
//#endregion
|
|
1862
|
-
//#region src/auth/token-revocation.ts
|
|
1863
|
-
/**
|
|
1864
|
-
* Has this access token been revoked?
|
|
1865
|
-
*
|
|
1866
|
-
* Everything that ends every session a user holds — a password change or reset
|
|
1867
|
-
* of any kind, and `DELETE /auth/sessions` — goes through
|
|
1868
|
-
* {@link revokeAllSessions}, which stamps a `tokensValidAfter` watermark on the
|
|
1869
|
-
* user. It also deletes refresh-token rows, which is what made the gap easy to
|
|
1870
|
-
* miss: the session really is gone, and the *refresh* path really does check
|
|
1871
|
-
* the watermark — so signing out looked like it worked.
|
|
1872
|
-
*
|
|
1873
|
-
* The access token was untouched. It is a bearer credential that nothing
|
|
1874
|
-
* consulted a database about, so it stayed valid for its full lifetime after
|
|
1875
|
-
* every one of those actions. "Sign out everywhere" left the stolen token
|
|
1876
|
-
* working; so did changing your password because you thought it had leaked.
|
|
1877
|
-
*
|
|
1878
|
-
* The watermark had exactly one read in the repository, on refresh. This adds
|
|
1879
|
-
* the other one.
|
|
1880
|
-
*
|
|
1881
|
-
* ## Cost
|
|
1882
|
-
*
|
|
1883
|
-
* One indexed lookup per request, on the paths that already make one. The
|
|
1884
|
-
* adapter re-reads roles for every authenticated request; the admin routes now
|
|
1885
|
-
* do too. Both call this with the row they were already fetching in mind — a
|
|
1886
|
-
* repository that wants to serve both from one query is free to cache
|
|
1887
|
-
* internally, but nothing here assumes it.
|
|
1888
|
-
*
|
|
1889
|
-
* ## Failure
|
|
1890
|
-
*
|
|
1891
|
-
* Fails **open**, deliberately, and this is the one place in the auth stack
|
|
1892
|
-
* where that is right. The watermark is an extra revocation signal layered over
|
|
1893
|
-
* a token that has already been verified — signature, expiry and purpose all
|
|
1894
|
-
* checked. Refusing every request when the database is unreachable would turn a
|
|
1895
|
-
* transient outage into a total sign-out of an entire deployment, and the
|
|
1896
|
-
* attacker this protects against needs to have already stolen a live token. The
|
|
1897
|
-
* failure is logged at warn so it is visible rather than silent.
|
|
1898
|
-
*/
|
|
1899
|
-
async function isAccessTokenRevoked(authRepo, payload) {
|
|
1900
|
-
if (typeof authRepo.getTokensValidAfter !== "function") return false;
|
|
1901
|
-
if (typeof payload.iat !== "number") return false;
|
|
1902
|
-
let validAfter;
|
|
1903
|
-
try {
|
|
1904
|
-
validAfter = await authRepo.getTokensValidAfter(payload.uid);
|
|
1905
|
-
} catch (error) {
|
|
1906
|
-
logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
|
|
1907
|
-
uid: payload.uid,
|
|
1908
|
-
error
|
|
1909
|
-
});
|
|
1910
|
-
return false;
|
|
1911
|
-
}
|
|
1912
|
-
return issuedBefore(payload, validAfter);
|
|
1913
|
-
}
|
|
1914
|
-
/**
|
|
1915
|
-
* Is the account behind this verified access token still the one that may use
|
|
1916
|
-
* it — and with which roles?
|
|
1917
|
-
*
|
|
1918
|
-
* A verified signature says who the token was minted for, an hour ago at
|
|
1919
|
-
* most. This asks the database what has happened to that account since. Every
|
|
1920
|
-
* door that honours an access token — the data plane, the admin gates, the
|
|
1921
|
-
* realtime socket — asks it here, so they cannot disagree about it.
|
|
1922
|
-
*
|
|
1923
|
-
* They did. A deleted account read as "not revoked": the watermark lives on
|
|
1924
|
-
* the user row, so once the row was gone there was no watermark, and the
|
|
1925
|
-
* roles lookup answered `[]` rather than "nobody". A token its owner had
|
|
1926
|
-
* revoked came back to life when an administrator deleted the account, as an
|
|
1927
|
-
* authenticated principal with that uid, for the rest of its lifetime. MCP
|
|
1928
|
-
* refresh and personal API keys already treated a missing account as revoked;
|
|
1929
|
-
* the main door did not.
|
|
1930
|
-
*
|
|
1931
|
-
* Throws when the repository does. Unlike the watermark read on its own, this
|
|
1932
|
-
* one decides the roles a request runs with, so a failure is a refusal (the
|
|
1933
|
-
* callers answer 503) rather than a guess.
|
|
1934
|
-
*
|
|
1935
|
-
* Except one failure that is an answer: the store refusing the uid as a value
|
|
1936
|
-
* its id column cannot hold — `u1` against a UUID key. That names no account,
|
|
1937
|
-
* exactly as a well-formed id nobody has does, so it is `account-deleted` and
|
|
1938
|
-
* the caller answers 401. Answered as a failure it was a 503 asking the
|
|
1939
|
-
* client to try again, which it would do forever.
|
|
1940
|
-
*/
|
|
1941
|
-
async function judgeAccessToken(authRepo, payload) {
|
|
1942
|
-
try {
|
|
1943
|
-
return await readAccessVerdict(authRepo, payload);
|
|
1944
|
-
} catch (error) {
|
|
1945
|
-
if (isDataException(error)) return {
|
|
1946
|
-
live: false,
|
|
1947
|
-
refusal: "account-deleted"
|
|
1948
|
-
};
|
|
1949
|
-
throw error;
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
async function readAccessVerdict(authRepo, payload) {
|
|
1953
|
-
let roles;
|
|
1954
|
-
let validAfter = null;
|
|
1955
|
-
if (typeof authRepo.getAccountAccessState === "function") {
|
|
1956
|
-
const state = await authRepo.getAccountAccessState(payload.uid, payload.sid);
|
|
1957
|
-
if (!state) return {
|
|
1958
|
-
live: false,
|
|
1959
|
-
refusal: "account-deleted"
|
|
1960
|
-
};
|
|
1961
|
-
if (state.disabled) return {
|
|
1962
|
-
live: false,
|
|
1963
|
-
refusal: "account-disabled"
|
|
1964
|
-
};
|
|
1965
|
-
roles = state.roles;
|
|
1966
|
-
validAfter = state.tokensValidAfter;
|
|
1967
|
-
if (payload.sid && state.sessionActive === false) return {
|
|
1968
|
-
live: false,
|
|
1969
|
-
refusal: "session-revoked"
|
|
1970
|
-
};
|
|
1971
|
-
} else {
|
|
1972
|
-
if (typeof authRepo.getUserWithRoles === "function") {
|
|
1973
|
-
const account = await authRepo.getUserWithRoles(payload.uid);
|
|
1974
|
-
if (!account) return {
|
|
1975
|
-
live: false,
|
|
1976
|
-
refusal: "account-deleted"
|
|
1977
|
-
};
|
|
1978
|
-
if (account.user.disabled) return {
|
|
1979
|
-
live: false,
|
|
1980
|
-
refusal: "account-disabled"
|
|
1981
|
-
};
|
|
1982
|
-
roles = account.roles;
|
|
1983
|
-
}
|
|
1984
|
-
if (typeof authRepo.getTokensValidAfter === "function") validAfter = await authRepo.getTokensValidAfter(payload.uid);
|
|
1985
|
-
}
|
|
1986
|
-
if (issuedBefore(payload, validAfter)) return {
|
|
1987
|
-
live: false,
|
|
1988
|
-
refusal: "revoked"
|
|
1989
|
-
};
|
|
1990
|
-
return {
|
|
1991
|
-
live: true,
|
|
1992
|
-
roles
|
|
1993
|
-
};
|
|
1994
|
-
}
|
|
1995
|
-
/**
|
|
1996
|
-
* The 401 a door answers an access token with when {@link judgeAccessToken}
|
|
1997
|
-
* refuses it — one wording and one code for each reason, wherever the token
|
|
1998
|
-
* was presented.
|
|
1999
|
-
*
|
|
2000
|
-
* `SESSION_REVOKED` for a token whose session or account is gone: the client
|
|
2001
|
-
* SDK answers it with a refresh, which the refresh route refuses for the same
|
|
2002
|
-
* reason, and the client signs out. `ACCOUNT_DISABLED` for an account an
|
|
2003
|
-
* administrator switched off, which no refresh can mend.
|
|
2004
|
-
*/
|
|
2005
|
-
function accessTokenRefusalError(refusal) {
|
|
2006
|
-
switch (refusal) {
|
|
2007
|
-
case "account-disabled": return ApiError.unauthorized("This account has been disabled", "ACCOUNT_DISABLED");
|
|
2008
|
-
case "account-deleted": return ApiError.unauthorized("This account no longer exists", "SESSION_REVOKED");
|
|
2009
|
-
case "revoked":
|
|
2010
|
-
case "session-revoked": return ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
/** The watermark comparison {@link isAccessTokenRevoked} and the judge share. */
|
|
2014
|
-
function issuedBefore(payload, validAfter) {
|
|
2015
|
-
if (!validAfter || typeof payload.iat !== "number") return false;
|
|
2016
|
-
return payload.iat < Math.floor(validAfter.getTime() / 1e3);
|
|
2017
|
-
}
|
|
2018
|
-
/**
|
|
2019
|
-
* End every session this user holds, on every device.
|
|
2020
|
-
*
|
|
2021
|
-
* Two writes, because each covers what the other cannot. Deleting the refresh
|
|
2022
|
-
* rows ends the sessions that exist at this instant, and on a repository
|
|
2023
|
-
* without the watermark it is the only revocation there is. The watermark
|
|
2024
|
-
* voids what the delete cannot see: a refresh already in flight that inserts
|
|
2025
|
-
* its rotated token a moment after the delete ran, and every access token
|
|
2026
|
-
* already handed out, which {@link isAccessTokenRevoked} refuses from here on.
|
|
2027
|
-
*
|
|
2028
|
-
* The watermark write does not fail the caller. By the time it runs the
|
|
2029
|
-
* credential has usually already changed and the rows are gone, so a 500 would
|
|
2030
|
-
* tell someone their password did not change when it did. It is logged, because
|
|
2031
|
-
* a failure here leaves access tokens working until they expire.
|
|
2032
|
-
*/
|
|
2033
|
-
async function revokeAllSessions(authRepo, uid) {
|
|
2034
|
-
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
2035
|
-
try {
|
|
2036
|
-
await authRepo.setTokensValidAfter?.(uid, /* @__PURE__ */ new Date());
|
|
2037
|
-
} catch (error) {
|
|
2038
|
-
logger.warn("[Auth] Could not write the token revocation watermark; access tokens issued before now stay valid until they expire", {
|
|
2039
|
-
uid,
|
|
2040
|
-
error
|
|
2041
|
-
});
|
|
2042
|
-
}
|
|
2043
|
-
}
|
|
2044
|
-
/**
|
|
2045
|
-
* Replace a user's password and end every session they hold.
|
|
2046
|
-
*
|
|
2047
|
-
* The one way a password hash is written over an existing account. A new
|
|
2048
|
-
* password is what someone sets when they believe the old one — or a session
|
|
2049
|
-
* signed in with it — is in someone else's hands, so a password change that
|
|
2050
|
-
* leaves those sessions alive does not do the thing it was done for.
|
|
2051
|
-
*
|
|
2052
|
-
* It used to be a pair of lines repeated after each `updatePassword`, and it
|
|
2053
|
-
* reached the two self-service routes and none of the admin ones: an
|
|
2054
|
-
* administrator resetting a phished account left the attacker's refresh token
|
|
2055
|
-
* minting access tokens for the rest of its lifetime.
|
|
2056
|
-
* `test/password-change-revokes-sessions.test.ts` holds every route that sets a
|
|
2057
|
-
* password to this, and fails if anything but this function calls
|
|
2058
|
-
* `updatePassword`.
|
|
2059
|
-
*
|
|
2060
|
-
* `null` removes the password instead, for the same reason and with the same
|
|
2061
|
-
* revocation: see `confirmAddressOwnership`.
|
|
2062
|
-
*/
|
|
2063
|
-
async function replaceUserPassword(authRepo, uid, passwordHash) {
|
|
2064
|
-
await authRepo.updatePassword(uid, passwordHash);
|
|
2065
|
-
await revokeAllSessions(authRepo, uid);
|
|
2066
|
-
await authRepo.deleteAllPasswordResetTokensForUser?.(uid);
|
|
2067
|
-
}
|
|
2068
|
-
//#endregion
|
|
2069
|
-
//#region src/auth/rls-scope.ts
|
|
2070
|
-
var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
|
|
2071
|
-
/**
|
|
2072
|
-
* The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
|
|
2073
|
-
* caller presenting the service key.
|
|
2074
|
-
*
|
|
2075
|
-
* Naming it makes the privilege legible, because it is narrower than it reads.
|
|
2076
|
-
* This is **not** an RLS bypass — a driver scoped with it is an
|
|
2077
|
-
* `AuthenticatedPostgresBackendDriver`, so every statement runs in a
|
|
2078
|
-
* transaction that has done `SET LOCAL ROLE rebase_user` with
|
|
2079
|
-
* `app.uid = 'service'`, and policies are evaluated. Two consequences worth
|
|
2080
|
-
* knowing before you write a policy:
|
|
2081
|
-
*
|
|
2082
|
-
* - It passes the default policies through their `rolesOverlap(['admin'])`
|
|
2083
|
-
* arm — the same arm an application user holding the `admin` role passes.
|
|
2084
|
-
* - `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore
|
|
2085
|
-
* **false** for it. A collection with `disableDefaultPolicies: true` whose
|
|
2086
|
-
* only rule is `serverContext()` denies these writes (42501) and returns
|
|
2087
|
-
* zero rows for these reads.
|
|
2088
|
-
*
|
|
2089
|
-
* The true bypass is `rebase.sql()`, which runs on the owner connection and
|
|
2090
|
-
* never goes near `withAuth`.
|
|
2091
|
-
*/
|
|
2092
|
-
var SERVICE_IDENTITY = {
|
|
2093
|
-
uid: "service",
|
|
2094
|
-
roles: ["admin"]
|
|
2095
|
-
};
|
|
2096
|
-
/**
|
|
2097
|
-
* Returns true if the driver supports RLS scoping via `withAuth()`.
|
|
2098
|
-
*/
|
|
2099
|
-
function isRLSScopedDriver(driver) {
|
|
2100
|
-
return "withAuth" in driver && typeof driver.withAuth === "function";
|
|
2101
|
-
}
|
|
2102
|
-
/**
|
|
2103
|
-
* Can this driver run a request as a specific user?
|
|
2104
|
-
*
|
|
2105
|
-
* The same question `scopeDataDriver` asks internally, exported because one
|
|
2106
|
-
* caller must act on the answer BEFORE it has a request to scope.
|
|
2107
|
-
*
|
|
2108
|
-
* `scopeDataDriver` returns the driver unscoped when the answer is no, which is
|
|
2109
|
-
* the right behaviour for `/api/data` — Mongo has no row-level security and
|
|
2110
|
-
* never claimed to, and the route's own checks still apply. It is the wrong
|
|
2111
|
-
* behaviour for a surface whose entire promise is "this runs as you": there,
|
|
2112
|
-
* an unscoped driver silently means "runs as the database owner". So the MCP
|
|
2113
|
-
* surface asks this at boot and declines to mount rather than degrade.
|
|
2114
|
-
*/
|
|
2115
|
-
function supportsRlsScoping(driver) {
|
|
2116
|
-
return isRLSScopedDriver(driver);
|
|
2117
|
-
}
|
|
2118
|
-
/**
|
|
2119
|
-
* Scope a DataDriver via `withAuth()` for RLS.
|
|
2120
|
-
*
|
|
2121
|
-
* SECURITY: If `withAuth()` is available but fails, the error is re-thrown
|
|
2122
|
-
* so the request is **denied** rather than proceeding with unscoped access
|
|
2123
|
-
* (fail-closed behavior).
|
|
2124
|
-
*
|
|
2125
|
-
* If the driver does not support RLS, the original driver is returned.
|
|
2126
|
-
*
|
|
2127
|
-
* @param driver - The DataDriver to scope.
|
|
2128
|
-
* @param user - The authenticated user identity for RLS.
|
|
2129
|
-
* @returns The RLS-scoped DataDriver (or the original if RLS is unsupported).
|
|
2130
|
-
*/
|
|
2131
|
-
async function scopeDataDriver(driver, user) {
|
|
2132
|
-
if (isRLSScopedDriver(driver)) return await driver.withAuth(user);
|
|
2133
|
-
return driver;
|
|
2134
|
-
}
|
|
2135
|
-
//#endregion
|
|
2136
|
-
//#region src/auth/crypto-utils.ts
|
|
2137
|
-
/**
|
|
2138
|
-
* Cryptographic utility functions for auth.
|
|
2139
|
-
*
|
|
2140
|
-
* @module
|
|
2141
|
-
*/
|
|
2142
|
-
/**
|
|
2143
|
-
* Constant-time string comparison to prevent timing attacks.
|
|
2144
|
-
*
|
|
2145
|
-
* Used for comparing service keys, tokens, and other secrets where
|
|
2146
|
-
* timing side-channels could leak information about the expected value.
|
|
2147
|
-
*
|
|
2148
|
-
* The implementation lives in `utils/portable-crypto.ts`, which does this over
|
|
2149
|
-
* UTF-8 bytes without `node:crypto` — this function is on the request path, and
|
|
2150
|
-
* the byte-level reasoning it depends on is documented there.
|
|
2151
|
-
*
|
|
2152
|
-
* @param a - First string to compare.
|
|
2153
|
-
* @param b - Second string to compare.
|
|
2154
|
-
* @returns `true` if the strings are identical, `false` otherwise.
|
|
2155
|
-
*/
|
|
2156
|
-
function safeCompare(a, b) {
|
|
2157
|
-
return constantTimeEqual(a, b);
|
|
2158
|
-
}
|
|
2159
|
-
//#endregion
|
|
2160
|
-
//#region src/auth/bearer-token.ts
|
|
2161
|
-
/**
|
|
2162
|
-
* Parsing of the `Authorization: Bearer <token>` header.
|
|
2163
|
-
*
|
|
2164
|
-
* Its own module rather than a helper inside `middleware.ts`, because the
|
|
2165
|
-
* API-key middleware needs it too and `middleware.ts` already imports from
|
|
2166
|
-
* there — putting it in either one would close an import cycle.
|
|
2167
|
-
*
|
|
2168
|
-
* @module
|
|
2169
|
-
*/
|
|
2170
|
-
/**
|
|
2171
|
-
* Pull the token out of an `Authorization: Bearer <token>` header.
|
|
2172
|
-
*
|
|
2173
|
-
* RFC 7235 §2.1 makes the auth-scheme name case-insensitive, and plenty of
|
|
2174
|
-
* clients send `bearer` — HTTP libraries that lower-case what they emit,
|
|
2175
|
-
* hand-written curl, proxies that normalise headers. Matching `"Bearer "`
|
|
2176
|
-
* exactly turned those into a 401 that, from the caller's side, is
|
|
2177
|
-
* indistinguishable from a rejected token; people spend an afternoon
|
|
2178
|
-
* regenerating credentials that were fine. Only the scheme is folded — the
|
|
2179
|
-
* token itself is returned byte-for-byte.
|
|
2180
|
-
*
|
|
2181
|
-
* Returns `undefined` when there is no header or it names another scheme.
|
|
2182
|
-
* That is deliberately distinct from an empty token (`Bearer ` with nothing
|
|
2183
|
-
* after it), which callers still route into verification so it fails as a bad
|
|
2184
|
-
* token rather than as a missing header.
|
|
2185
|
-
*/
|
|
2186
|
-
function extractBearerToken(authHeader) {
|
|
2187
|
-
if (!authHeader) return void 0;
|
|
2188
|
-
const separator = authHeader.indexOf(" ");
|
|
2189
|
-
if (separator < 0) return void 0;
|
|
2190
|
-
if (authHeader.slice(0, separator).toLowerCase() !== "bearer") return void 0;
|
|
2191
|
-
return authHeader.slice(separator + 1);
|
|
2192
|
-
}
|
|
2193
|
-
//#endregion
|
|
2194
|
-
//#region src/auth/access.ts
|
|
2195
|
-
/**
|
|
2196
|
-
* How a personal key finds its owner in an account store: the account as it
|
|
2197
|
-
* is now — a missing or disabled one is no owner — and its roles.
|
|
2198
|
-
*
|
|
2199
|
-
* One read where the store answers both at once: `getAccountAccessState`, the
|
|
2200
|
-
* statement every access token is already judged against, which reads roles
|
|
2201
|
-
* and `disabled_at` together. Otherwise the account, then its roles — the two
|
|
2202
|
-
* reads this always made, one round trip more per request.
|
|
2203
|
-
*/
|
|
2204
|
-
function keyOwnerResolverFor(repo) {
|
|
2205
|
-
if (typeof repo.getAccountAccessState === "function") {
|
|
2206
|
-
const readAccount = repo.getAccountAccessState.bind(repo);
|
|
2207
|
-
return async (uid) => {
|
|
2208
|
-
const account = await readAccount(uid);
|
|
2209
|
-
if (!account || account.disabled) return null;
|
|
2210
|
-
return { roles: account.roles };
|
|
2211
|
-
};
|
|
2212
|
-
}
|
|
2213
|
-
return async (uid) => {
|
|
2214
|
-
const user = await repo.getUserById(uid);
|
|
2215
|
-
if (!user || user.disabled) return null;
|
|
2216
|
-
return { roles: await repo.getUserRoleIds(uid) };
|
|
2217
|
-
};
|
|
2218
|
-
}
|
|
2219
|
-
/**
|
|
2220
|
-
* What the module-level API answers for: {@link getAccessModel},
|
|
2221
|
-
* {@link getKeyOwnerResolver}, and every scope check on a route mounted
|
|
2222
|
-
* outside any backend. Built-ins only until {@link configureAccess} or a
|
|
2223
|
-
* backend installed as the process default sets it.
|
|
2224
|
-
*/
|
|
2225
|
-
var processAccess = { model: EMPTY_ACCESS_MODEL };
|
|
2226
|
-
/** The process default's access model. Built-ins only until one is configured. */
|
|
2227
|
-
function getAccessModel() {
|
|
2228
|
-
return processAccess.model;
|
|
2229
|
-
}
|
|
2230
|
-
/**
|
|
2231
|
-
* Set the process default's access model, and — when the app enables
|
|
2232
|
-
* `auth.personalKeys` and has an account store to read owners from — how
|
|
2233
|
-
* personal keys find their owner. For a test, or a server wired by hand that
|
|
2234
|
-
* uses the module-level API; a backend built by `createRebaseBackend` carries
|
|
2235
|
-
* its own.
|
|
2236
|
-
*/
|
|
2237
|
-
function configureAccess(options) {
|
|
2238
|
-
processAccess = {
|
|
2239
|
-
model: options.model,
|
|
2240
|
-
resolveKeyOwner: options.resolveKeyOwner
|
|
2241
|
-
};
|
|
2242
|
-
}
|
|
2243
|
-
/** @internal See {@link defaultAccessControl}. */
|
|
2244
|
-
function setDefaultAccessControl(access) {
|
|
2245
|
-
processAccess = access;
|
|
2246
|
-
}
|
|
2247
|
-
/**
|
|
2248
|
-
* The process default as an {@link AccessControl} that follows it — what a
|
|
2249
|
-
* route or a key check built outside any backend falls back to.
|
|
2250
|
-
*/
|
|
2251
|
-
var processAccessControl = {
|
|
2252
|
-
get model() {
|
|
2253
|
-
return processAccess.model;
|
|
2254
|
-
},
|
|
2255
|
-
get resolveKeyOwner() {
|
|
2256
|
-
return processAccess.resolveKeyOwner;
|
|
2257
|
-
}
|
|
2258
|
-
};
|
|
2259
|
-
/**
|
|
2260
|
-
* The access control serving this request: the backend's whose route group is
|
|
2261
|
-
* answering it, or the process default for a route mounted outside any.
|
|
2262
|
-
*/
|
|
2263
|
-
function accessOf(c) {
|
|
2264
|
-
return c.get("services")?.access ?? processAccessControl;
|
|
2265
|
-
}
|
|
2266
|
-
/** The resource names Rebase's own scopes use; an app scope may not reuse one. */
|
|
2267
|
-
var BUILT_IN_RESOURCES = new Set(BUILT_IN_SCOPES.map((scope) => scope.slice(0, scope.indexOf(":"))));
|
|
2268
|
-
/** A declaration the access model cannot be built from. Fails the boot. */
|
|
2269
|
-
var AccessModelError = class extends Error {
|
|
2270
|
-
constructor(message) {
|
|
2271
|
-
super(message);
|
|
2272
|
-
this.name = "AccessModelError";
|
|
2273
|
-
}
|
|
2274
|
-
};
|
|
2275
|
-
/**
|
|
2276
|
-
* Build the access model from the users collection's `auth` block, refusing
|
|
2277
|
-
* anything that would read as a grant it is not.
|
|
2278
|
-
*
|
|
2279
|
-
* - `admin` may not be declared: it is built in and holds everything, and a
|
|
2280
|
-
* declaration would either repeat that or quietly narrow it.
|
|
2281
|
-
* - An app scope may not reuse a built-in resource name (`logs:export`): the
|
|
2282
|
-
* built-in resources are Rebase's, and a reader of `logs:*` should not have
|
|
2283
|
-
* to know which half is whose.
|
|
2284
|
-
* - A role may not list a data-plane scope: a person's data access is the
|
|
2285
|
-
* collection's `securityRules`, so `data:write` on a role would grant nothing
|
|
2286
|
-
* and look like it granted something.
|
|
2287
|
-
*/
|
|
2288
|
-
function accessModelFromCollections(collections) {
|
|
2289
|
-
const authCollection = collections.find((collection) => {
|
|
2290
|
-
const auth = collection.auth;
|
|
2291
|
-
return auth === true || !!auth && typeof auth === "object" && auth.enabled === true;
|
|
2292
|
-
});
|
|
2293
|
-
const auth = authCollection && typeof authCollection.auth === "object" ? authCollection.auth : void 0;
|
|
2294
|
-
const declaredScopes = { ...auth?.scopes ?? {} };
|
|
2295
|
-
const declaredRoles = { ...auth?.roles ?? {} };
|
|
2296
|
-
const where = `the "${authCollection?.slug ?? "users"}" collection's auth block`;
|
|
2297
|
-
for (const [name, definition] of Object.entries(declaredScopes)) {
|
|
2298
|
-
const parsed = parseScope(name);
|
|
2299
|
-
if (!parsed || parsed.target !== void 0) throw new AccessModelError(`auth.scopes: "${name}" in ${where} is not a scope name. Name scopes resource:action, lower-case, without a target — for example "project:deploy".`);
|
|
2300
|
-
const resource = name.slice(0, name.indexOf(":"));
|
|
2301
|
-
if (BUILT_IN_RESOURCES.has(resource)) throw new AccessModelError(`auth.scopes: "${name}" in ${where} uses the built-in resource "${resource}". App scopes need their own resource name; the built-in ones are ${[...BUILT_IN_RESOURCES].join(", ")}.`);
|
|
2302
|
-
if (typeof definition?.label !== "string" || definition.label.trim() === "") throw new AccessModelError(`auth.scopes: "${name}" in ${where} needs a label — it is what a person granting it reads.`);
|
|
2303
|
-
}
|
|
2304
|
-
const model = {
|
|
2305
|
-
roles: declaredRoles,
|
|
2306
|
-
scopes: declaredScopes
|
|
2307
|
-
};
|
|
2308
|
-
for (const [role, definition] of Object.entries(declaredRoles)) {
|
|
2309
|
-
if (role === "admin") throw new AccessModelError(`auth.roles: "admin" in ${where} is built in — it holds every scope — and cannot be declared. Declare a role with another name for a narrower set.`);
|
|
2310
|
-
if (!Array.isArray(definition?.scopes)) throw new AccessModelError(`auth.roles: "${role}" in ${where} needs a scopes list.`);
|
|
2311
|
-
const dataPlane = definition.scopes.filter((scope) => isDataPlaneScope(parseScope(scope)?.scope ?? ""));
|
|
2312
|
-
if (dataPlane.length > 0) throw new AccessModelError(`auth.roles: "${role}" in ${where} lists ${dataPlane.join(", ")}. Every signed-in person already holds the data plane; what they may do with rows is the collection's securityRules. List admin-plane and app scopes only.`);
|
|
2313
|
-
const problems = validateScopes(definition.scopes, model);
|
|
2314
|
-
if (problems.length > 0) throw new AccessModelError(`auth.roles: "${role}" in ${where} lists ${problems.map((p) => `"${p.scope}" (${p.reason})`).join(", ")}. Role scopes are ${ADMIN_SCOPES.join(", ")} and the app's own auth.scopes.`);
|
|
2315
|
-
}
|
|
2316
|
-
return model;
|
|
2317
|
-
}
|
|
2318
|
-
/** Narrow a context's user to the roles it carries. */
|
|
2319
|
-
function rolesOf(user) {
|
|
2320
|
-
if (typeof user !== "object" || user === null || !("roles" in user)) return [];
|
|
2321
|
-
const roles = user.roles;
|
|
2322
|
-
return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
|
|
2323
|
-
}
|
|
2324
|
-
/**
|
|
2325
|
-
* Everything the caller of this request may do. Empty for a request with no
|
|
2326
|
-
* caller.
|
|
2327
|
-
*/
|
|
2328
|
-
function callerScopes(c) {
|
|
2329
|
-
const narrowed = c.get("scopes");
|
|
2330
|
-
if (narrowed) return narrowed;
|
|
2331
|
-
const user = c.get("user");
|
|
2332
|
-
if (!user) return [];
|
|
2333
|
-
return scopesForRoles(rolesOf(user), accessOf(c).model);
|
|
2334
|
-
}
|
|
2335
|
-
/**
|
|
2336
|
-
* Whether this request's caller holds the `admin` role. The service key signs
|
|
2337
|
-
* in as one.
|
|
2338
|
-
*/
|
|
2339
|
-
function callerIsAdmin(c) {
|
|
2340
|
-
return hasAdminRole(rolesOf(c.get("user")));
|
|
2341
|
-
}
|
|
2342
|
-
/** Does this request's caller hold `scope`, on `target` when one is given? */
|
|
2343
|
-
function hasScope(c, scope, target) {
|
|
2344
|
-
return scopeGrants(callerScopes(c), scope, target);
|
|
2345
|
-
}
|
|
2346
|
-
/**
|
|
2347
|
-
* The refusal for a missing scope, worded for whoever is holding the
|
|
2348
|
-
* credential. `details.requiredScope` names it for clients that branch.
|
|
2349
|
-
*/
|
|
2350
|
-
function scopeMissing(c, scope, target) {
|
|
2351
|
-
const wanted = target !== void 0 ? `${scope}:${target}` : scope;
|
|
2352
|
-
return new ApiError(403, "SCOPE_MISSING", c.get("apiKey") !== void 0 ? `This API key does not hold the "${wanted}" scope. Create a key that includes it.` : c.get("scopes") ? `This token does not hold the "${wanted}" scope. Reconnect and grant it.` : `Your account does not hold the "${wanted}" scope. A role that lists it (auth.roles on the users collection) grants it; the admin role holds every scope.`, { requiredScope: wanted });
|
|
2353
|
-
}
|
|
2354
|
-
/**
|
|
2355
|
-
* Refuse a request whose caller does not hold `scope`.
|
|
2356
|
-
*
|
|
2357
|
-
* `scope` is a built-in scope or one this backend declares under
|
|
2358
|
-
* `auth.scopes`. A name no caller could ever hold — not a scope name, a typo
|
|
2359
|
-
* of a built-in one (`data:raed`) — throws as the route is registered; an app
|
|
2360
|
-
* scope the backend answering does not declare is answered
|
|
2361
|
-
* `500 SCOPE_NOT_DECLARED`, naming the declaration to add.
|
|
2362
|
-
*
|
|
2363
|
-
* `target` narrows the check to one collection, bucket, function or app
|
|
2364
|
-
* resource, and may be read from the request. A guard, not an authenticator:
|
|
2365
|
-
* it judges the caller an auth middleware identified, so mount it after one —
|
|
2366
|
-
* `requireAuth`, or a route group's own. A request with no caller is refused
|
|
2367
|
-
* as unauthenticated, not as unscoped.
|
|
2368
|
-
*
|
|
2369
|
-
* @example
|
|
2370
|
-
* ```ts
|
|
2371
|
-
* app.post("/deploy/:project", requireAuth, requireScope("project:deploy", c => c.req.param("project")), handler);
|
|
2372
|
-
* ```
|
|
2373
|
-
*/
|
|
2374
|
-
function requireScope(scope, target) {
|
|
2375
|
-
const problem = scopeGuardProblem(scope, typeof target === "string" ? target : void 0);
|
|
2376
|
-
if (problem) throw new Error(problem);
|
|
2377
|
-
return async (c, next) => {
|
|
2378
|
-
if (!c.get("user")) return refuse(c, noCallerRefusal(c));
|
|
2379
|
-
const undeclared = scopeGuardProblem(scope, void 0, Object.keys(accessOf(c).model.scopes));
|
|
2380
|
-
if (undeclared) return refuse(c, new ApiError(500, "SCOPE_NOT_DECLARED", undeclared));
|
|
2381
|
-
const resolvedTarget = typeof target === "function" ? target(c) : target;
|
|
2382
|
-
if (!hasScope(c, scope, resolvedTarget)) return refuse(c, scopeMissing(c, scope, resolvedTarget));
|
|
2383
|
-
return next();
|
|
2384
|
-
};
|
|
2385
|
-
}
|
|
2386
|
-
/**
|
|
2387
|
-
* The 401 for a guard that found no caller.
|
|
2388
|
-
*
|
|
2389
|
-
* A request that sent a credential, on a route where no auth middleware ran —
|
|
2390
|
-
* it sets `driver` for every caller, anonymous included — never had that
|
|
2391
|
-
* credential read. "Authentication required" sends whoever holds a valid
|
|
2392
|
-
* session to check it, so this names the wiring instead.
|
|
2393
|
-
*/
|
|
2394
|
-
function noCallerRefusal(c) {
|
|
2395
|
-
if (c.req.header("authorization") !== void 0 && c.get("driver") === void 0) return ApiError.unauthenticated("Authentication required: this request carries a credential, but nothing on this route verified it. requireScope judges the caller an auth middleware identified; put requireAuth in front of it — app.post(path, requireAuth, requireScope(scope), handler).");
|
|
2396
|
-
return ApiError.unauthenticated("Authentication required");
|
|
2397
|
-
}
|
|
2398
|
-
/**
|
|
2399
|
-
* Refuse a request unless its caller holds the scope for its HTTP method:
|
|
2400
|
-
* `read` for GET and HEAD, `write` for everything else. For an admin surface
|
|
2401
|
-
* whose reads and writes split exactly along the method.
|
|
2402
|
-
*/
|
|
2403
|
-
function requireScopeByMethod(scopes) {
|
|
2404
|
-
const read = requireScope(scopes.read);
|
|
2405
|
-
const write = requireScope(scopes.write);
|
|
2406
|
-
return async (c, next) => {
|
|
2407
|
-
const method = c.req.method.toUpperCase();
|
|
2408
|
-
return (method === "GET" || method === "HEAD" ? read : write)(c, next);
|
|
2409
|
-
};
|
|
2410
|
-
}
|
|
2411
|
-
/**
|
|
2412
|
-
* What a set of roles holds beyond this request's caller — the scopes, plus
|
|
2413
|
-
* `admin` itself when the roles include it and the caller does not hold it.
|
|
2414
|
-
* Empty when the caller covers every one.
|
|
2415
|
-
*
|
|
2416
|
-
* `admin` is named separately because it is more than its scopes: it is the
|
|
2417
|
-
* RLS role that reads every row, so a role listing every admin-plane scope
|
|
2418
|
-
* still cannot hand it out.
|
|
2419
|
-
*/
|
|
2420
|
-
function beyondCaller(c, roles) {
|
|
2421
|
-
const beyond = scopesBeyond(scopesForRoles(roles, accessOf(c).model), callerScopes(c));
|
|
2422
|
-
if (hasAdminRole(roles) && !hasAdminRole(rolesOf(c.get("user")))) beyond.unshift(ADMIN_ROLE);
|
|
2423
|
-
return beyond;
|
|
2424
|
-
}
|
|
2425
|
-
/**
|
|
2426
|
-
* Refuse to let the caller act on an account — edit it, reset its password,
|
|
2427
|
-
* delete it — when that account holds anything the caller does not.
|
|
2428
|
-
*
|
|
2429
|
-
* `users:write` is the scope that lets a support role manage accounts, and
|
|
2430
|
-
* without this it would also let that role reset an administrator's password
|
|
2431
|
-
* and sign in as them.
|
|
2432
|
-
*/
|
|
2433
|
-
function assertMayManageAccount(c, targetRoles) {
|
|
2434
|
-
const beyond = beyondCaller(c, targetRoles);
|
|
2435
|
-
if (beyond.length > 0) throw new ApiError(403, "ACCOUNT_OUTRANKS_CALLER", `This account holds ${beyond.join(", ")}, which you do not, so you cannot change it.`, { beyond });
|
|
2436
|
-
}
|
|
2437
|
-
/** Refuse to let the caller grant roles that hold anything the caller does not. */
|
|
2438
|
-
function assertMayGrantRoles(c, roles) {
|
|
2439
|
-
const beyond = beyondCaller(c, roles);
|
|
2440
|
-
if (beyond.length > 0) throw new ApiError(403, "ROLE_EXCEEDS_CALLER", `These roles hold ${beyond.join(", ")}, which you do not, so you cannot grant them.`, { beyond });
|
|
2441
|
-
}
|
|
2442
|
-
//#endregion
|
|
2443
|
-
//#region src/auth/api-keys/api-key-middleware.ts
|
|
2444
|
-
/**
|
|
2445
|
-
* Check whether a token looks like a Rebase API key.
|
|
2446
|
-
*/
|
|
2447
|
-
function isApiKeyToken(token) {
|
|
2448
|
-
return token.startsWith("rk_");
|
|
2449
|
-
}
|
|
2450
|
-
/**
|
|
2451
|
-
* Verify a presented key: it exists, is live, and — for a personal key —
|
|
2452
|
-
* personal keys are on and its owner still exists. Records the use.
|
|
2453
|
-
*
|
|
2454
|
-
* `access` is the backend the key belongs to: whether it takes personal keys,
|
|
2455
|
-
* how it finds their owners, and what their roles hold. The process default
|
|
2456
|
-
* when omitted.
|
|
2457
|
-
*/
|
|
2458
|
-
async function resolveApiKey(store, token, access = processAccessControl) {
|
|
2459
|
-
const apiKey = await store.findByKeyHash(await sha256Hex(token));
|
|
2460
|
-
if (!apiKey) return { message: "Invalid API key" };
|
|
2461
|
-
if (apiKey.revoked_at) return { message: "API key has been revoked" };
|
|
2462
|
-
if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return { message: "API key has expired" };
|
|
2463
|
-
let identity;
|
|
2464
|
-
if (apiKey.kind === "personal") {
|
|
2465
|
-
const resolveOwner = access.resolveKeyOwner;
|
|
2466
|
-
if (!resolveOwner || !apiKey.owner_uid) return { message: "Personal API keys are switched off on this backend" };
|
|
2467
|
-
const owner = await resolveOwner(apiKey.owner_uid);
|
|
2468
|
-
if (!owner) return { message: "The account this API key acts as no longer exists" };
|
|
2469
|
-
identity = {
|
|
2470
|
-
uid: apiKey.owner_uid,
|
|
2471
|
-
roles: owner.roles,
|
|
2472
|
-
scopes: intersectScopes(apiKey.scopes, scopesForRoles(owner.roles, access.model))
|
|
2473
|
-
};
|
|
2474
|
-
} else identity = {
|
|
2475
|
-
uid: `api-key:${apiKey.id}`,
|
|
2476
|
-
roles: ["service", ...apiKey.roles.filter((role) => role !== "service")],
|
|
2477
|
-
scopes: apiKey.scopes
|
|
2478
|
-
};
|
|
2479
|
-
const lastTouch = lastUsedTouchedAt.get(apiKey.id);
|
|
2480
|
-
const now = Date.now();
|
|
2481
|
-
if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
|
|
2482
|
-
lastUsedTouchedAt.set(apiKey.id, now);
|
|
2483
|
-
store.updateLastUsed(apiKey.id).catch(() => {});
|
|
2484
|
-
}
|
|
2485
|
-
const masked = {
|
|
2486
|
-
id: apiKey.id,
|
|
2487
|
-
name: apiKey.name,
|
|
2488
|
-
kind: apiKey.kind,
|
|
2489
|
-
key_prefix: apiKey.key_prefix,
|
|
2490
|
-
scopes: apiKey.scopes,
|
|
2491
|
-
roles: apiKey.roles,
|
|
2492
|
-
owner_uid: apiKey.owner_uid,
|
|
2493
|
-
rate_limit: apiKey.rate_limit,
|
|
2494
|
-
created_by: apiKey.created_by,
|
|
2495
|
-
created_at: apiKey.created_at,
|
|
2496
|
-
updated_at: apiKey.updated_at,
|
|
2497
|
-
last_used_at: apiKey.last_used_at,
|
|
2498
|
-
expires_at: apiKey.expires_at,
|
|
2499
|
-
revoked_at: apiKey.revoked_at
|
|
2500
|
-
};
|
|
2501
|
-
return {
|
|
2502
|
-
...identity,
|
|
2503
|
-
apiKey: masked
|
|
2504
|
-
};
|
|
2505
|
-
}
|
|
2506
|
-
/** Per-process debounce state for last_used_at touches. */
|
|
2507
|
-
var lastUsedTouchedAt = /* @__PURE__ */ new Map();
|
|
2508
|
-
var LAST_USED_DEBOUNCE_MS = 6e4;
|
|
2509
|
-
/**
|
|
2510
|
-
* Validate an API key token and populate the Hono context: `user`, `apiKey`,
|
|
2511
|
-
* `scopes` and the RLS-scoped `driver`.
|
|
2512
|
-
*
|
|
2513
|
-
* Returns `true` when the context is populated, or the error Response.
|
|
2514
|
-
*/
|
|
2515
|
-
async function validateApiKey(c, token, options) {
|
|
2516
|
-
const resolved = await resolveApiKey(options.store, token, accessOf(c));
|
|
2517
|
-
if (!("uid" in resolved)) return errorHandler(ApiError.unauthenticated(resolved.message), c);
|
|
2518
|
-
c.set("user", {
|
|
2519
|
-
uid: resolved.uid,
|
|
2520
|
-
roles: resolved.roles
|
|
2521
|
-
});
|
|
2522
|
-
c.set("apiKey", resolved.apiKey);
|
|
2523
|
-
c.set("scopes", resolved.scopes);
|
|
2524
|
-
try {
|
|
2525
|
-
c.set("driver", await scopeDataDriver(options.driver, {
|
|
2526
|
-
uid: resolved.uid,
|
|
2527
|
-
roles: resolved.roles
|
|
2528
|
-
}));
|
|
2529
|
-
} catch (error) {
|
|
2530
|
-
logger.error("[AUTH] RLS scoping failed for API key", { error });
|
|
2531
|
-
return errorHandler(ApiError.internal("Internal authentication error"), c);
|
|
2532
|
-
}
|
|
2533
|
-
return true;
|
|
2534
|
-
}
|
|
2535
|
-
/**
|
|
2536
|
-
* The 403 for a credential that lacks a data-plane scope outside the REST
|
|
2537
|
-
* generator (storage and functions). Names the scope that would grant it.
|
|
2538
|
-
*/
|
|
2539
|
-
function forbidScope(c, scope, target) {
|
|
2540
|
-
const wanted = target !== void 0 && target !== "" ? `${scope}:${target}` : scope;
|
|
2541
|
-
return errorHandler(new ApiError(403, "SCOPE_MISSING", `This credential does not hold the "${wanted}" scope.`, { requiredScope: wanted }), c);
|
|
2542
|
-
}
|
|
2543
|
-
/** Whether this request's caller holds `scope` — on `target` when given. */
|
|
2544
|
-
function callerHolds(c, scope, target) {
|
|
2545
|
-
const narrowed = c.get("scopes");
|
|
2546
|
-
if (!narrowed) return true;
|
|
2547
|
-
return target === void 0 ? scopeGrantsAny(narrowed, scope) : scopeGrants(narrowed, scope, target);
|
|
2548
|
-
}
|
|
2549
|
-
/**
|
|
2550
|
-
* Scope guard for the resumable-upload routes (`/tus/:id`).
|
|
2551
|
-
*
|
|
2552
|
-
* Every step of an upload — the offset check (HEAD), the chunks (PATCH), the
|
|
2553
|
-
* cancel (DELETE) — is part of writing it, so all of them need
|
|
2554
|
-
* `storage:write`. Which source the upload writes to was checked when it was
|
|
2555
|
-
* created; these steps only reach an upload the same caller owns.
|
|
2556
|
-
*/
|
|
2557
|
-
function createTusScopeGuard() {
|
|
2558
|
-
return async (c, next) => {
|
|
2559
|
-
if (/\/tus\/[^/]+$/.test(c.req.path) && c.get("user") && !callerHolds(c, "storage:write")) return forbidScope(c, "storage:write");
|
|
2560
|
-
return next();
|
|
2561
|
-
};
|
|
2562
|
-
}
|
|
2563
|
-
/**
|
|
2564
|
-
* Scope guard for the custom-functions router: a narrowed credential needs
|
|
2565
|
-
* `functions:invoke` on the function it calls. The functions index — the
|
|
2566
|
-
* listing at the mount point itself — needs the unqualified scope.
|
|
2567
|
-
*
|
|
2568
|
-
* People pass: what a person may do inside a function is the function's own
|
|
2569
|
-
* business, as it always was.
|
|
2570
|
-
*
|
|
2571
|
-
* @param mountPrefix - The path the functions router is mounted at
|
|
2572
|
-
* (e.g. `/api/functions`), used to extract the function
|
|
2573
|
-
* name from the request path. Omitted, it is the
|
|
2574
|
-
* `mountPath` the functions group recorded for this
|
|
2575
|
-
* request — wherever the application mounted it.
|
|
2576
|
-
*/
|
|
2577
|
-
function createFunctionScopeGuard(mountPrefix) {
|
|
2578
|
-
return async (c, next) => {
|
|
2579
|
-
const narrowed = c.get("scopes");
|
|
2580
|
-
if (!narrowed) return next();
|
|
2581
|
-
const path = c.req.path;
|
|
2582
|
-
const prefix = mountPrefix ?? c.get("mountPath") ?? "";
|
|
2583
|
-
const idx = path.indexOf(prefix);
|
|
2584
|
-
const rawName = (idx < 0 ? "" : path.slice(idx + prefix.length)).split("/").filter(Boolean)[0] ?? "";
|
|
2585
|
-
let functionName;
|
|
2586
|
-
try {
|
|
2587
|
-
functionName = decodeURIComponent(rawName);
|
|
2588
|
-
} catch {
|
|
2589
|
-
functionName = rawName;
|
|
2590
|
-
}
|
|
2591
|
-
if (!(functionName === "" ? scopeGrants(narrowed, "functions:invoke") : scopeGrants(narrowed, "functions:invoke", functionName))) return forbidScope(c, "functions:invoke", functionName);
|
|
2592
|
-
return next();
|
|
2593
|
-
};
|
|
2594
|
-
}
|
|
2595
|
-
/**
|
|
2596
|
-
* Pre-auth middleware for `rk_` bearer tokens.
|
|
2597
|
-
*
|
|
2598
|
-
* Routers whose auth gate is JWT-based (`createRequireAuth` — the admin
|
|
2599
|
-
* surfaces, the key routes) don't know about API keys. Mounted in front of
|
|
2600
|
-
* them, this authenticates `rk_` tokens and populates the request context; the
|
|
2601
|
-
* downstream gates then see the already-resolved caller and check its scopes.
|
|
2602
|
-
*
|
|
2603
|
-
* Requests without an `rk_` bearer token pass through untouched. An invalid,
|
|
2604
|
-
* revoked, or expired `rk_` token is rejected here (401) rather than falling
|
|
2605
|
-
* through to be misparsed as a JWT.
|
|
2606
|
-
*/
|
|
2607
|
-
function createApiKeyPreAuth(options) {
|
|
2608
|
-
return async (c, next) => {
|
|
2609
|
-
if (c.get("apiKey")) return next();
|
|
2610
|
-
const token = extractBearerToken(c.req.header("authorization")) ?? "";
|
|
2611
|
-
if (!token || !isApiKeyToken(token)) return next();
|
|
2612
|
-
const result = await validateApiKey(c, token, options);
|
|
2613
|
-
if (result !== true) return result;
|
|
2614
|
-
return next();
|
|
2615
|
-
};
|
|
2616
|
-
}
|
|
2617
|
-
//#endregion
|
|
2618
|
-
//#region src/auth/impersonation.ts
|
|
2619
|
-
var ADMIN_ONLY = "Only a signed-in administrator can run a request as another user.";
|
|
2620
|
-
var NOT_A_PERSON = {
|
|
2621
|
-
"api-key": {
|
|
2622
|
-
reason: "api-key",
|
|
2623
|
-
message: "An API key cannot run a request as another user. Send it with an administrator's session instead."
|
|
2624
|
-
},
|
|
2625
|
-
"service-key": {
|
|
2626
|
-
reason: "service-key",
|
|
2627
|
-
message: "The service key cannot run a request as another user. Send it with an administrator's session instead."
|
|
2628
|
-
},
|
|
2629
|
-
none: {
|
|
2630
|
-
reason: "anonymous",
|
|
2631
|
-
message: ADMIN_ONLY
|
|
2632
|
-
}
|
|
2633
|
-
};
|
|
2634
|
-
/**
|
|
2635
|
-
* Decide whether this caller may act as the user they named, and who that
|
|
2636
|
-
* user is now.
|
|
2637
|
-
*
|
|
2638
|
-
* The one decision both doors take — the HTTP data plane through
|
|
2639
|
-
* {@link applyImpersonation}, the realtime socket at `AUTHENTICATE` and before
|
|
2640
|
-
* every frame after it. The caller is judged before the named user is looked
|
|
2641
|
-
* up, so the answer to a caller who may not impersonate says nothing about
|
|
2642
|
-
* whether the uid exists.
|
|
2643
|
-
*
|
|
2644
|
-
* Throws when a lookup fails: that is not an answer about anyone, and each
|
|
2645
|
-
* door refuses it its own way.
|
|
2646
|
-
*/
|
|
2647
|
-
async function decideImpersonation(request) {
|
|
2648
|
-
const { credential, caller, resolveUser, audit } = request;
|
|
2649
|
-
const targetUid = request.requestedUid.trim();
|
|
2650
|
-
const refuse = (reason, error) => {
|
|
2651
|
-
if (audit) logger.warn("[Security Audit] Refused a request to act as another user", {
|
|
2652
|
-
eventType: "auth.impersonation.refused",
|
|
2653
|
-
reason,
|
|
2654
|
-
callerUid: caller?.uid,
|
|
2655
|
-
requestedUid: targetUid,
|
|
2656
|
-
...audit
|
|
2657
|
-
});
|
|
2658
|
-
return { refused: error };
|
|
2659
|
-
};
|
|
2660
|
-
if (targetUid === "") return refuse("malformed", ApiError.badRequest(`The ${IMPERSONATE_HEADER} header names no user. Send the uid of the user to act as, or leave the header out.`, "IMPERSONATION_INVALID"));
|
|
2661
|
-
if (credential !== "session") {
|
|
2662
|
-
const { reason, message } = NOT_A_PERSON[credential];
|
|
2663
|
-
return refuse(reason, ApiError.forbidden(message, "IMPERSONATION_FORBIDDEN"));
|
|
2664
|
-
}
|
|
2665
|
-
if (!caller || !hasAdminRole(caller.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
|
|
2666
|
-
if (!resolveUser) return refuse("unavailable", new ApiError(501, "IMPERSONATION_UNAVAILABLE", "This backend's auth cannot run a request as another user: its auth adapter does not implement resolveUser."));
|
|
2667
|
-
const self = await resolveUser(caller.uid);
|
|
2668
|
-
if (!self || !hasAdminRole(self.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
|
|
2669
|
-
const target = await resolveUser(targetUid);
|
|
2670
|
-
if (!target) return refuse("unknown-target", ApiError.notFound(`No active user has the id "${targetUid}", so there is nobody to run this request as.`, "IMPERSONATION_TARGET_NOT_FOUND"));
|
|
2671
|
-
if (audit) logger.info("[Security Audit] Ran a request as another user", {
|
|
2672
|
-
eventType: "auth.impersonation",
|
|
2673
|
-
impersonatorUid: caller.uid,
|
|
2674
|
-
targetUid: target.uid,
|
|
2675
|
-
targetRoles: target.roles,
|
|
2676
|
-
...audit
|
|
2677
|
-
});
|
|
2678
|
-
return {
|
|
2679
|
-
granted: target,
|
|
2680
|
-
impersonator: caller.uid
|
|
2681
|
-
};
|
|
2682
|
-
}
|
|
2683
|
-
/** The caller the auth middleware put on the context, narrowed. */
|
|
2684
|
-
function callerOf(c) {
|
|
2685
|
-
const user = c.get("user");
|
|
2686
|
-
if (typeof user !== "object" || user === null) return void 0;
|
|
2687
|
-
const uid = "uid" in user && typeof user.uid === "string" && user.uid !== "" ? user.uid : void 0;
|
|
2688
|
-
if (uid === void 0) return void 0;
|
|
2689
|
-
return {
|
|
2690
|
-
uid,
|
|
2691
|
-
roles: "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : []
|
|
2692
|
-
};
|
|
2693
|
-
}
|
|
2694
|
-
/**
|
|
2695
|
-
* Honour or refuse this request's {@link IMPERSONATE_HEADER}.
|
|
2696
|
-
*
|
|
2697
|
-
* Called by the data-plane auth middlewares once the caller is on the context
|
|
2698
|
-
* and its driver is scoped. When the header names a user the caller may act
|
|
2699
|
-
* as, the context's `user` and `driver` are replaced with that user's and
|
|
2700
|
-
* `impersonator` names the caller.
|
|
2701
|
-
*
|
|
2702
|
-
* @returns The refusal to send, or `undefined` to carry on — as the caller
|
|
2703
|
-
* when the header is absent, as the named user when it was granted.
|
|
2704
|
-
*/
|
|
2705
|
-
async function applyImpersonation(c, { credential, driver, resolveUser }) {
|
|
2706
|
-
const requested = c.req.header(IMPERSONATE_HEADER);
|
|
2707
|
-
if (requested === void 0) return void 0;
|
|
2708
|
-
const caller = callerOf(c);
|
|
2709
|
-
let decision;
|
|
2710
|
-
try {
|
|
2711
|
-
decision = await decideImpersonation({
|
|
2712
|
-
requestedUid: requested,
|
|
2713
|
-
credential,
|
|
2714
|
-
caller,
|
|
2715
|
-
resolveUser,
|
|
2716
|
-
audit: {
|
|
2717
|
-
door: "http",
|
|
2718
|
-
method: c.req.method,
|
|
2719
|
-
path: c.req.path,
|
|
2720
|
-
requestId: c.get("requestId")
|
|
2721
|
-
}
|
|
2722
|
-
});
|
|
2723
|
-
} catch (error) {
|
|
2724
|
-
return errorHandler(error instanceof Error ? error : new Error(String(error)), c);
|
|
2725
|
-
}
|
|
2726
|
-
if ("refused" in decision) return errorHandler(decision.refused, c);
|
|
2727
|
-
const target = decision.granted;
|
|
2728
|
-
const isAnonymous = target.isAnonymous === true;
|
|
2729
|
-
c.set("user", {
|
|
2730
|
-
uid: target.uid,
|
|
2731
|
-
email: target.email,
|
|
2732
|
-
roles: target.roles,
|
|
2733
|
-
isAnonymous
|
|
2734
|
-
});
|
|
2735
|
-
c.set("impersonator", { uid: decision.impersonator });
|
|
2736
|
-
try {
|
|
2737
|
-
c.set("driver", await scopeDataDriver(driver, {
|
|
2738
|
-
uid: target.uid,
|
|
2739
|
-
roles: target.roles,
|
|
2740
|
-
isAnonymous,
|
|
2741
|
-
...target.claims ? { claims: target.claims } : {}
|
|
2742
|
-
}));
|
|
2743
|
-
} catch (error) {
|
|
2744
|
-
logger.error("[AUTH] RLS scoping failed for an impersonated request", { error });
|
|
2745
|
-
return errorHandler(ApiError.internal("Internal authentication error"), c);
|
|
2746
|
-
}
|
|
2747
|
-
}
|
|
2748
|
-
/**
|
|
2749
|
-
* Refuse {@link IMPERSONATE_HEADER} on every route that does not honour it.
|
|
2750
|
-
*
|
|
2751
|
-
* Only the data API and custom functions run a request as another user; the
|
|
2752
|
-
* rest — storage, auth, the admin surfaces — authenticate the caller and
|
|
2753
|
-
* ignore the header. Ignored, a request asking to act as B would be answered
|
|
2754
|
-
* as the administrator who sent it, which is the one outcome impersonation
|
|
2755
|
-
* must never have. Mounted ahead of every route, so a surface added later is
|
|
2756
|
-
* refused until it is listed here as honouring the header.
|
|
2757
|
-
*
|
|
2758
|
-
* @param honouredMounts - The mount points whose auth middleware applies the
|
|
2759
|
-
* header (`/api/data`, `/api/functions`). A path is under one when it
|
|
2760
|
-
* is the mount itself or continues it with a `/`.
|
|
2761
|
-
*/
|
|
2762
|
-
function refuseUnhonouredImpersonation(honouredMounts) {
|
|
2763
|
-
return async (c, next) => {
|
|
2764
|
-
if (c.req.header("x-rebase-impersonate") === void 0) return next();
|
|
2765
|
-
const path = c.req.path;
|
|
2766
|
-
if (honouredMounts.some((mount) => path === mount || path.startsWith(`${mount}/`))) return next();
|
|
2767
|
-
logger.warn("[Security Audit] Refused a request to act as another user", {
|
|
2768
|
-
eventType: "auth.impersonation.refused",
|
|
2769
|
-
reason: "unsupported-route",
|
|
2770
|
-
method: c.req.method,
|
|
2771
|
-
path,
|
|
2772
|
-
requestId: c.get("requestId")
|
|
2773
|
-
});
|
|
2774
|
-
return errorHandler(ApiError.badRequest(`This route cannot run as another user, so it refuses the ${IMPERSONATE_HEADER} header. Only the data API and custom functions honour it.`, "IMPERSONATION_UNSUPPORTED"), c);
|
|
2775
|
-
};
|
|
2776
|
-
}
|
|
2777
|
-
//#endregion
|
|
2778
|
-
//#region src/storage/requested-object.ts
|
|
2779
|
-
/**
|
|
2780
|
-
* Which object a storage request's URL path names.
|
|
2781
|
-
*
|
|
2782
|
-
* Three parties read the path of a `GET /file/*`, `GET /metadata/*` or
|
|
2783
|
-
* `DELETE /file/*`: the route, which serves or deletes an object;
|
|
2784
|
-
* `publicObjectAuth`, which lets an anonymous caller through when that object
|
|
2785
|
-
* is public; and `fileTokenAuth`, which compares it with the path a download
|
|
2786
|
-
* token grants. A decision is only about the object acted on if it is made on
|
|
2787
|
-
* the same key, so all three derive it here — the wildcard, the decoding, the
|
|
2788
|
-
* bucket segment and the canonical key — and none of them spells a step of its
|
|
2789
|
-
* own.
|
|
2790
|
-
*
|
|
2791
|
-
* They used to. `publicObjectAuth` judged the raw path with a check that strips
|
|
2792
|
-
* everything up to a `://`, while the route canonicalized it, folding `//` to
|
|
2793
|
-
* `/`: `GET /file/notes://public/secret.txt` was public to the one and served
|
|
2794
|
-
* the private key `notes:/public/secret.txt` from the other, with the authorize
|
|
2795
|
-
* hook never asked.
|
|
2796
|
-
*
|
|
2797
|
-
* Nothing here parses a scheme. A request path is a key, optionally behind the
|
|
2798
|
-
* one bucket segment the routes recognise; a `://` in it is a `:` followed by
|
|
2799
|
-
* an empty segment, and canonicalization folds the empty segment away.
|
|
2800
|
-
*/
|
|
2801
|
-
/**
|
|
2802
|
-
* The wildcard part of a storage route's path, still percent-encoded.
|
|
2803
|
-
*
|
|
2804
|
-
* Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
|
|
2805
|
-
* via `app.route(prefix, subRouter)`. Instead it is derived from the
|
|
2806
|
-
* fully-resolved `c.req.path` and `c.req.routePath`.
|
|
2807
|
-
*
|
|
2808
|
-
* For a route `/metadata/*` mounted at `/api/storage`, a request to
|
|
2809
|
-
* `/api/storage/metadata/default/file.jpg` yields routePath
|
|
2810
|
-
* `/api/storage/metadata/*`. The prefix (everything before `/*`) is stripped,
|
|
2811
|
-
* plus one character for the trailing `/`, to obtain `default/file.jpg`.
|
|
2812
|
-
*/
|
|
2813
|
-
function storageRequestWildcard(c) {
|
|
2814
|
-
const prefix = c.req.routePath.replace("/*", "");
|
|
2815
|
-
const fullPath = c.req.path;
|
|
2816
|
-
const idx = fullPath.indexOf(prefix);
|
|
2817
|
-
if (idx < 0) return "";
|
|
2818
|
-
return fullPath.substring(idx + prefix.length + 1);
|
|
2819
|
-
}
|
|
2820
|
-
/**
|
|
2821
|
-
* The object a decoded path names: a key, behind an optional `default/` bucket
|
|
2822
|
-
* segment (matched case-insensitively). Throws {@link InvalidStorageKeyError}
|
|
2823
|
-
* when the key cannot be canonicalized.
|
|
2824
|
-
*
|
|
2825
|
-
* For a path that is already text — the folder route's JSON body. A URL path
|
|
2826
|
-
* goes through {@link requestedStorageObject}, which decodes it first.
|
|
2827
|
-
*/
|
|
2828
|
-
function storageObjectOfPath(decodedPath) {
|
|
2829
|
-
const parts = decodedPath.split("/");
|
|
2830
|
-
return {
|
|
2831
|
-
bucket: "default",
|
|
2832
|
-
key: canonicalStorageKey(parts.length > 1 && parts[0].toLowerCase() === "default" ? parts.slice(1).join("/") : decodedPath)
|
|
2833
|
-
};
|
|
2834
|
-
}
|
|
2835
|
-
/**
|
|
2836
|
-
* The object a request's wildcard path names, or {@link InvalidStorageKeyError}.
|
|
2837
|
-
*
|
|
2838
|
-
* The wildcard arrives as Hono leaves it — `decodeURI`d, with the reserved
|
|
2839
|
-
* characters (`/`, `:`, `%25` among them) still escaped — so one
|
|
2840
|
-
* `decodeURIComponent` yields the path the client encoded. A `%` that does not
|
|
2841
|
-
* begin an escape names no key, and is refused like any other key that cannot be
|
|
2842
|
-
* canonicalized rather than thrown as a `URIError`.
|
|
2843
|
-
*/
|
|
2844
|
-
function requestedStorageObject(wildcard) {
|
|
2845
|
-
let decoded;
|
|
2846
|
-
try {
|
|
2847
|
-
decoded = decodeURIComponent(wildcard);
|
|
2848
|
-
} catch {
|
|
2849
|
-
throw new InvalidStorageKeyError("Storage path is not valid percent-encoding: a '%' must begin an escape such as '%25' (a literal '%').");
|
|
2850
|
-
}
|
|
2851
|
-
return storageObjectOfPath(decoded);
|
|
2852
|
-
}
|
|
2853
|
-
/**
|
|
2854
|
-
* {@link requestedStorageObject}, or `null` when the path names no object.
|
|
2855
|
-
*
|
|
2856
|
-
* For the middleware, which must fail closed without an exception: a path that
|
|
2857
|
-
* names no object is neither public nor covered by any download token, and the
|
|
2858
|
-
* route answers it with a 400 of its own.
|
|
2859
|
-
*/
|
|
2860
|
-
function tryRequestedStorageObject(wildcard) {
|
|
2861
|
-
try {
|
|
2862
|
-
return requestedStorageObject(wildcard);
|
|
2863
|
-
} catch (err) {
|
|
2864
|
-
if (err instanceof InvalidStorageKeyError) return null;
|
|
2865
|
-
throw err;
|
|
2866
|
-
}
|
|
2867
|
-
}
|
|
2868
|
-
//#endregion
|
|
2869
|
-
//#region src/storage/request-principals.ts
|
|
2870
|
-
/**
|
|
2871
|
-
* The two principals a storage read can be granted without a user: the reader
|
|
2872
|
-
* of a declared-public object, and the bearer of a download token.
|
|
2873
|
-
*
|
|
2874
|
-
* `checkAuthorized` does not ask the `storageAuthorize` hook about either — the
|
|
2875
|
-
* public prefix and the token are the authorization. That skip used to be
|
|
2876
|
-
* decided on the principal's `uid` string (`"public"`, `"download-token"`), so
|
|
2877
|
-
* any identity carrying that uid skipped the hook: a custom `AuthAdapter` whose
|
|
2878
|
-
* `verifyRequest` returns `{ uid: "public" }` for a guest could then upload,
|
|
2879
|
-
* delete and list anywhere, with the hook never asked.
|
|
2880
|
-
*
|
|
2881
|
-
* Each principal is now one object, set only by the middleware that granted
|
|
2882
|
-
* it (`publicObjectAuth`, `fileTokenAuth`) and recognised by identity. An
|
|
2883
|
-
* adapter builds a new object per request, so it can name the uid but never be
|
|
2884
|
-
* the grant.
|
|
2885
|
-
*/
|
|
2886
|
-
/** The principal `publicObjectAuth` grants an anonymous read of a public object. */
|
|
2887
|
-
var PUBLIC_OBJECT_PRINCIPAL = Object.freeze({
|
|
2888
|
-
uid: "public",
|
|
2889
|
-
roles: ["public"]
|
|
2890
|
-
});
|
|
2891
|
-
/** The principal `fileTokenAuth` grants a read covered by a valid download token. */
|
|
2892
|
-
var DOWNLOAD_TOKEN_PRINCIPAL = Object.freeze({
|
|
2893
|
-
uid: "download-token",
|
|
2894
|
-
roles: ["reader"]
|
|
2895
|
-
});
|
|
2896
|
-
/** Whether `user` is the grant `publicObjectAuth` made — the object, not the uid. */
|
|
2897
|
-
function isPublicObjectGrant(user) {
|
|
2898
|
-
return user === PUBLIC_OBJECT_PRINCIPAL;
|
|
2899
|
-
}
|
|
2900
|
-
/** Whether `user` is the grant `fileTokenAuth` made — the object, not the uid. */
|
|
2901
|
-
function isDownloadTokenGrant(user) {
|
|
2902
|
-
return user === DOWNLOAD_TOKEN_PRINCIPAL;
|
|
2903
|
-
}
|
|
2904
|
-
//#endregion
|
|
2905
|
-
//#region src/auth/middleware.ts
|
|
2906
|
-
/**
|
|
2907
|
-
* Answer a refusal with the canonical envelope, through the same formatter
|
|
2908
|
-
* every route uses.
|
|
2909
|
-
*
|
|
2910
|
-
* These middlewares used to hand-build `c.json({ error: { message, code } },
|
|
2911
|
-
* 401)` — seven near-copies of it, which is how they came to omit `requestId`
|
|
2912
|
-
* while every other error on the server carried it: the one string a bug report
|
|
2913
|
-
* quotes was missing from precisely the errors an app hits most. It also meant
|
|
2914
|
-
* a routine 401 (a client refreshing before it knows whether a session exists)
|
|
2915
|
-
* logged like an incident, because `ApiError.expected` never reached the
|
|
2916
|
-
* logger.
|
|
2917
|
-
*
|
|
2918
|
-
* `errorHandler` is *called* rather than thrown to. A throw only becomes an
|
|
2919
|
-
* envelope where an `onError` is registered, and these middlewares are mounted
|
|
2920
|
-
* by callers this package does not control — `defineFunction` hands users a
|
|
2921
|
-
* Hono app and invites them to put `requireAuth` on a route of it. Making the
|
|
2922
|
-
* status depend on the mount point would turn a 401 into a 500 for exactly
|
|
2923
|
-
* those callers. Calling the formatter directly gets the envelope with none of
|
|
2924
|
-
* that.
|
|
2925
|
-
*/
|
|
2926
|
-
function refuse(c, error) {
|
|
2927
|
-
return errorHandler(error, c);
|
|
2928
|
-
}
|
|
2929
|
-
/**
|
|
2930
|
-
* Hono middleware that requires a valid JWT token via Authorization header.
|
|
2931
|
-
* Returns 401 if token is missing or invalid.
|
|
2932
|
-
*
|
|
2933
|
-
* **Security:** Tokens are only accepted via the `Authorization: Bearer`
|
|
2934
|
-
* header. Query-string tokens (`?token=`) are intentionally NOT accepted
|
|
2935
|
-
* here because URLs leak into access logs, proxies, Referer headers, and
|
|
2936
|
-
* browser history. Use {@link queryTokenAuth} on routes that legitimately
|
|
2937
|
-
* need query-string tokens (e.g. storage file serving for `<img src>`).
|
|
2938
|
-
*/
|
|
2939
|
-
var requireAuth = async (c, next) => {
|
|
2940
|
-
if (c.get("user")) return next();
|
|
2941
|
-
const token = extractBearerToken(c.req.header("authorization"));
|
|
2942
|
-
if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
|
|
2943
|
-
const payload = await jwtOf(c).verifyAccessToken(token);
|
|
2944
|
-
if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
2945
|
-
c.set("user", payload);
|
|
2946
|
-
return next();
|
|
2947
|
-
};
|
|
2948
|
-
/**
|
|
2949
|
-
* Factory that creates a requireAuth middleware with optional service key support.
|
|
2950
|
-
*
|
|
2951
|
-
* When `serviceKey` is provided, the middleware will check if the Bearer token
|
|
2952
|
-
* matches the service key using constant-time comparison. If it matches, the
|
|
2953
|
-
* request is authenticated as a service user with admin privileges.
|
|
2954
|
-
*
|
|
2955
|
-
* This allows admin routes (which use standalone requireAuth + requireAdmin)
|
|
2956
|
-
* to be accessed via service keys for scripts and server-to-server calls.
|
|
2957
|
-
*/
|
|
2958
|
-
function createRequireAuth(options) {
|
|
2959
|
-
if (!options?.serviceKey && !options?.resolveRoles && !options?.revocationRepo) return requireAuth;
|
|
2960
|
-
const key = options.serviceKey;
|
|
2961
|
-
const resolveRoles = options.resolveRoles;
|
|
2962
|
-
const revocationRepo = options.revocationRepo;
|
|
2963
|
-
return async (c, next) => {
|
|
2964
|
-
if (c.get("user")) return next();
|
|
2965
|
-
const token = extractBearerToken(c.req.header("authorization"));
|
|
2966
|
-
if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
|
|
2967
|
-
if (key !== void 0 && safeCompare(token, key)) {
|
|
2968
|
-
c.set("user", {
|
|
2969
|
-
uid: "service",
|
|
2970
|
-
roles: ["admin"]
|
|
2971
|
-
});
|
|
2972
|
-
return next();
|
|
2973
|
-
}
|
|
2974
|
-
const payload = await jwtOf(c).verifyAccessToken(token);
|
|
2975
|
-
if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
2976
|
-
if (resolveRoles || revocationRepo) try {
|
|
2977
|
-
let judgedRoles;
|
|
2978
|
-
if (revocationRepo) {
|
|
2979
|
-
const verdict = await judgeAccessToken(revocationRepo, payload);
|
|
2980
|
-
if (!verdict.live) return refuse(c, accessTokenRefusalError(verdict.refusal));
|
|
2981
|
-
judgedRoles = verdict.roles;
|
|
2982
|
-
}
|
|
2983
|
-
c.set("user", resolveRoles ? {
|
|
2984
|
-
...payload,
|
|
2985
|
-
roles: judgedRoles ?? await resolveRoles(payload.uid)
|
|
2986
|
-
} : payload);
|
|
2987
|
-
return next();
|
|
2988
|
-
} catch (error) {
|
|
2989
|
-
if (isDatabaseUnreachable(error)) {
|
|
2990
|
-
logger.warn("[Auth] Could not resolve roles for an admin-gated request: the database is unreachable", { uid: payload.uid });
|
|
2991
|
-
return refuse(c, ApiError.databaseUnavailable());
|
|
2992
|
-
}
|
|
2993
|
-
logger.warn("[Auth] Could not resolve roles for an admin-gated request", {
|
|
2994
|
-
uid: payload.uid,
|
|
2995
|
-
error
|
|
2996
|
-
});
|
|
2997
|
-
return refuse(c, ApiError.serviceUnavailable("Could not verify your permissions. Please try again.", "ROLE_LOOKUP_FAILED"));
|
|
2998
|
-
}
|
|
2999
|
-
c.set("user", payload);
|
|
3000
|
-
return next();
|
|
3001
|
-
};
|
|
3002
|
-
}
|
|
3003
|
-
/**
|
|
3004
|
-
* Middleware that requires the user to hold the `admin` role, which holds
|
|
3005
|
-
* every scope. Must be used AFTER requireAuth or on a route where user is
|
|
3006
|
-
* guaranteed.
|
|
3007
|
-
*
|
|
3008
|
-
* Prefer `requireScope` from `./access` for anything a scope names: a scope
|
|
3009
|
-
* can be granted to a narrower role and to a key, and `admin` cannot.
|
|
3010
|
-
*/
|
|
3011
|
-
var requireAdmin = async (c, next) => {
|
|
3012
|
-
const user = c.get("user");
|
|
3013
|
-
if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
|
|
3014
|
-
if (!hasAdminRole(typeof user === "object" && user !== null && "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : [])) return refuse(c, ApiError.forbidden("Admin privileges required for this operation"));
|
|
3015
|
-
return next();
|
|
3016
|
-
};
|
|
3017
|
-
/**
|
|
3018
|
-
* Middleware that optionally extracts user from JWT via Authorization header.
|
|
3019
|
-
* Does not return 401 if token is missing — allows anonymous access.
|
|
3020
|
-
*
|
|
3021
|
-
* Query-string tokens are NOT accepted here. Use {@link queryTokenAuth}
|
|
3022
|
-
* on routes that need them.
|
|
3023
|
-
*/
|
|
3024
|
-
var optionalAuth = async (c, next) => {
|
|
3025
|
-
if (c.get("user")) return next();
|
|
3026
|
-
const token = extractBearerToken(c.req.header("authorization"));
|
|
3027
|
-
const jwt = jwtOf(c);
|
|
3028
|
-
if (token !== void 0 && jwt.isConfigured()) {
|
|
3029
|
-
const payload = await jwt.verifyAccessToken(token);
|
|
3030
|
-
if (payload) c.set("user", payload);
|
|
3031
|
-
}
|
|
3032
|
-
return next();
|
|
3033
|
-
};
|
|
3034
|
-
/**
|
|
3035
|
-
* Who a session token names, as `jwt` verifies it — or "nobody" rather than a
|
|
3036
|
-
* throw on a backend with no JWT of its own, so a socket closes as
|
|
3037
|
-
* unauthenticated, which is the outcome this describes.
|
|
3038
|
-
*/
|
|
3039
|
-
async function userFromToken(jwt, token) {
|
|
3040
|
-
if (!jwt.isConfigured()) return null;
|
|
3041
|
-
return jwt.verifyAccessToken(token);
|
|
3042
|
-
}
|
|
3043
|
-
/**
|
|
3044
|
-
* Extract user from token - for WebSocket authentication. Answers for the
|
|
3045
|
-
* process default; a backend's own socket verifies with its own keys.
|
|
3046
|
-
*/
|
|
3047
|
-
async function extractUserFromToken(token) {
|
|
3048
|
-
return userFromToken(processJwtService, token);
|
|
3049
|
-
}
|
|
3050
|
-
/**
|
|
3051
|
-
* Create a configurable auth middleware that handles:
|
|
3052
|
-
* 1. Token extraction (via custom validator or JWT Bearer token)
|
|
3053
|
-
* 2. RLS-scoped DataDriver via withAuth()
|
|
3054
|
-
* 3. Enforcement (401 when requireAuth is true and no user)
|
|
3055
|
-
*
|
|
3056
|
-
* **Secure by default:** `requireAuth` defaults to `true`. Anonymous
|
|
3057
|
-
* access is only allowed when the developer explicitly opts out by
|
|
3058
|
-
* setting `requireAuth: false`, indicating that Postgres RLS policies
|
|
3059
|
-
* fully control access.
|
|
3060
|
-
*
|
|
3061
|
-
* **Fail-closed:** The raw unscoped driver is never placed in the
|
|
3062
|
-
* request context. Every code path either scopes via `withAuth()` or
|
|
3063
|
-
* rejects the request. This prevents silent RLS bypass.
|
|
3064
|
-
*
|
|
3065
|
-
* This is the single source of truth for HTTP auth in Rebase.
|
|
3066
|
-
* Use this instead of manually parsing tokens in route handlers.
|
|
3067
|
-
*/
|
|
3068
|
-
function createAuthMiddleware(options) {
|
|
3069
|
-
const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
|
|
3070
|
-
return async (c, next) => {
|
|
3071
|
-
const driver = resolveDriver ? resolveDriver(c) : baseDriver;
|
|
3072
|
-
let credential = "none";
|
|
3073
|
-
if (validator) try {
|
|
3074
|
-
const authResult = await validator(c);
|
|
3075
|
-
if (authResult && typeof authResult === "object") {
|
|
3076
|
-
const id = "uid" in authResult ? authResult.uid : void 0;
|
|
3077
|
-
if (id) {
|
|
3078
|
-
const roles = authResult.roles || [];
|
|
3079
|
-
credential = "session";
|
|
3080
|
-
c.set("user", {
|
|
3081
|
-
uid: id,
|
|
3082
|
-
roles
|
|
3083
|
-
});
|
|
3084
|
-
const user = {
|
|
3085
|
-
uid: id,
|
|
3086
|
-
roles,
|
|
3087
|
-
...authResult
|
|
3088
|
-
};
|
|
3089
|
-
c.set("driver", await scopeDataDriver(driver, user));
|
|
3090
|
-
} else c.set("driver", await scopeDataDriver(driver, {
|
|
3091
|
-
uid: ANONYMOUS_USER_ID,
|
|
3092
|
-
roles: ["anon"]
|
|
3093
|
-
}));
|
|
3094
|
-
} else if (authResult === true) {
|
|
3095
|
-
credential = "session";
|
|
3096
|
-
c.set("user", {
|
|
3097
|
-
uid: "default",
|
|
3098
|
-
roles: []
|
|
3099
|
-
});
|
|
3100
|
-
c.set("driver", await scopeDataDriver(driver, {
|
|
3101
|
-
uid: "default",
|
|
3102
|
-
roles: []
|
|
3103
|
-
}));
|
|
3104
|
-
} else c.set("driver", await scopeDataDriver(driver, {
|
|
3105
|
-
uid: ANONYMOUS_USER_ID,
|
|
3106
|
-
roles: ["anon"]
|
|
3107
|
-
}));
|
|
3108
|
-
} catch (error) {
|
|
3109
|
-
return refuse(c, ApiError.unauthenticated("Unauthorized"));
|
|
3110
|
-
}
|
|
3111
|
-
else {
|
|
3112
|
-
const token = extractBearerToken(c.req.header("authorization"));
|
|
3113
|
-
if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
|
|
3114
|
-
const serviceUser = {
|
|
3115
|
-
uid: "service",
|
|
3116
|
-
roles: ["admin"]
|
|
3117
|
-
};
|
|
3118
|
-
credential = "service-key";
|
|
3119
|
-
c.set("user", serviceUser);
|
|
3120
|
-
try {
|
|
3121
|
-
c.set("driver", await scopeDataDriver(driver, {
|
|
3122
|
-
uid: "service",
|
|
3123
|
-
roles: ["admin"]
|
|
3124
|
-
}));
|
|
3125
|
-
} catch (error) {
|
|
3126
|
-
logger.error("[AUTH] RLS scoping failed for service key", { error });
|
|
3127
|
-
return c.json({ error: {
|
|
3128
|
-
message: "Internal authentication error",
|
|
3129
|
-
code: "INTERNAL_ERROR"
|
|
3130
|
-
} }, 500);
|
|
3131
|
-
}
|
|
3132
|
-
} else if (apiKeyStore && isApiKeyToken(token)) {
|
|
3133
|
-
const result = await validateApiKey(c, token, {
|
|
3134
|
-
store: apiKeyStore,
|
|
3135
|
-
driver
|
|
3136
|
-
});
|
|
3137
|
-
if (result !== true) return result;
|
|
3138
|
-
credential = "api-key";
|
|
3139
|
-
} else {
|
|
3140
|
-
const payload = await userFromToken(jwtOf(c), token);
|
|
3141
|
-
if (payload) {
|
|
3142
|
-
credential = "session";
|
|
3143
|
-
c.set("user", payload);
|
|
3144
|
-
try {
|
|
3145
|
-
const user = {
|
|
3146
|
-
uid: payload.uid,
|
|
3147
|
-
roles: payload.roles,
|
|
3148
|
-
isAnonymous: payload.isAnonymous === true,
|
|
3149
|
-
claims: payload.claims
|
|
3150
|
-
};
|
|
3151
|
-
c.set("driver", await scopeDataDriver(driver, user));
|
|
3152
|
-
} catch (error) {
|
|
3153
|
-
logger.error("[AUTH] RLS scoping failed for authenticated user", { error });
|
|
3154
|
-
return c.json({ error: {
|
|
3155
|
-
message: "Internal authentication error",
|
|
3156
|
-
code: "INTERNAL_ERROR"
|
|
3157
|
-
} }, 500);
|
|
3158
|
-
}
|
|
3159
|
-
} else return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
3160
|
-
}
|
|
3161
|
-
else try {
|
|
3162
|
-
c.set("driver", await scopeDataDriver(driver, {
|
|
3163
|
-
uid: ANONYMOUS_USER_ID,
|
|
3164
|
-
roles: ["anon"]
|
|
3165
|
-
}));
|
|
3166
|
-
} catch (error) {
|
|
3167
|
-
logger.error("[AUTH] Failed to create anon-scoped driver", { error });
|
|
3168
|
-
return c.json({ error: {
|
|
3169
|
-
message: "Server configuration error",
|
|
3170
|
-
code: "INTERNAL_ERROR"
|
|
3171
|
-
} }, 500);
|
|
3172
|
-
}
|
|
3173
|
-
}
|
|
3174
|
-
const refused = await applyImpersonation(c, {
|
|
3175
|
-
credential,
|
|
3176
|
-
driver
|
|
3177
|
-
});
|
|
3178
|
-
if (refused) return refused;
|
|
3179
|
-
if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
|
|
3180
|
-
return next();
|
|
3181
|
-
};
|
|
3182
|
-
}
|
|
3183
|
-
/**
|
|
3184
|
-
* Middleware that authenticates via a `?token=` query parameter.
|
|
3185
|
-
*
|
|
3186
|
-
* **Use sparingly.** Tokens in URLs leak into access logs, proxy logs,
|
|
3187
|
-
* Referer headers, and browser history. This middleware exists solely for
|
|
3188
|
-
* routes where the consumer cannot set HTTP headers — e.g. `<img src>`,
|
|
3189
|
-
* `<a href>` for file downloads, or similar browser-native requests.
|
|
3190
|
-
*
|
|
3191
|
-
* Apply it **before** `requireAuth` or `optionalAuth` on the specific
|
|
3192
|
-
* route that needs it. Those middlewares will see the user context this
|
|
3193
|
-
* middleware sets and skip their own 401 check.
|
|
3194
|
-
*
|
|
3195
|
-
* @example
|
|
3196
|
-
* ```ts
|
|
3197
|
-
* router.get("/file/*", queryTokenAuth, readAuthMiddleware, handler);
|
|
3198
|
-
* ```
|
|
3199
|
-
*/
|
|
3200
|
-
var queryTokenAuth = async (c, next) => {
|
|
3201
|
-
if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
|
|
3202
|
-
const queryToken = c.req.query("token");
|
|
3203
|
-
const jwt = jwtOf(c);
|
|
3204
|
-
if (queryToken && jwt.isConfigured()) {
|
|
3205
|
-
const payload = await jwt.verifyAccessToken(queryToken);
|
|
3206
|
-
if (payload) c.set("user", payload);
|
|
3207
|
-
}
|
|
3208
|
-
return next();
|
|
3209
|
-
};
|
|
3210
|
-
/**
|
|
3211
|
-
* Authorizes anonymous access to **public** storage objects (those under the
|
|
3212
|
-
* public prefix), which are served token-less via stable, permanent URLs.
|
|
3213
|
-
*
|
|
3214
|
-
* Runs on the storage `/file/*` and `/metadata/*` routes, *after* the token
|
|
3215
|
-
* middleware and *before* the read-auth gate. If the request is already
|
|
3216
|
-
* authenticated (Bearer or scoped token), it does nothing. Otherwise, when the
|
|
3217
|
-
* requested object path is public, it sets a minimal "public" principal so the
|
|
3218
|
-
* downstream `requireAuth` gate lets the read through. Private paths are left
|
|
3219
|
-
* untouched, so they still require a valid token.
|
|
3220
|
-
*
|
|
3221
|
-
* Public means the object the route will serve is public, so the decision is
|
|
3222
|
-
* made on that object's canonical key, derived by the route's own function
|
|
3223
|
-
* (`requestedStorageObject`). It used to be made on the raw path, through a
|
|
3224
|
-
* check that strips a `scheme://`: `notes://public/secret.txt` read as
|
|
3225
|
-
* `public/secret.txt` here while the route served the private
|
|
3226
|
-
* `notes:/public/secret.txt`, and the authorize hook — not asked about this
|
|
3227
|
-
* principal — never saw it.
|
|
3228
|
-
*/
|
|
3229
|
-
var publicObjectAuth = async (c, next) => {
|
|
3230
|
-
if (c.get("user")) return next();
|
|
3231
|
-
const requested = tryRequestedStorageObject(storageRequestWildcard(c));
|
|
3232
|
-
if (requested && isPublicStorageKey(requested.key)) c.set("user", PUBLIC_OBJECT_PRINCIPAL);
|
|
3233
|
-
return next();
|
|
3234
|
-
};
|
|
3235
|
-
/**
|
|
3236
|
-
* Helper to match paths for scoped file tokens.
|
|
3237
|
-
* Matches exact paths or prefixes (exact folder prefixes or ending with /).
|
|
3238
|
-
*
|
|
3239
|
-
* The traversal check is defence in depth, not the load-bearing defence.
|
|
3240
|
-
*
|
|
3241
|
-
* A prefix comparison is a lie for any path containing `..`: `user1/../user2/x`
|
|
3242
|
-
* starts with `user1/`, so a token scoped to `user1/` would authorize reading
|
|
3243
|
-
* user2's file — and the storage controller would not object, because its own
|
|
3244
|
-
* guard stops a path escaping the *bucket*, and that one resolves to `user2/x`,
|
|
3245
|
-
* comfortably inside it. The grant is what would be escaped, and this is the
|
|
3246
|
-
* only layer that knows what was granted.
|
|
3247
|
-
*
|
|
3248
|
-
* Nothing arrives here in that shape today: the URL parser resolves `..` (and
|
|
3249
|
-
* `%2e%2e`, which the WHATWG spec treats as a dot for normalization) before
|
|
3250
|
-
* Hono routes the request, so this comparison already runs on a resolved path.
|
|
3251
|
-
* But that is a guarantee of the runtime rather than of this code, and it is
|
|
3252
|
-
* one line to not depend on it. The same rule already guards the public-object
|
|
3253
|
-
* path — see `isPublicStorageKey`.
|
|
3254
|
-
*/
|
|
3255
|
-
function isPathMatch(requested, allowed) {
|
|
3256
|
-
if (requested.split("/").some((seg) => seg === "..")) return false;
|
|
3257
|
-
if (requested === allowed) return true;
|
|
3258
|
-
if (allowed.endsWith("/")) return requested.startsWith(allowed);
|
|
3259
|
-
return requested.startsWith(allowed + "/");
|
|
1892
|
+
resolvedHooks.afterUserDelete(uid).catch((err) => {
|
|
1893
|
+
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
1894
|
+
});
|
|
3260
1895
|
}
|
|
3261
1896
|
/**
|
|
3262
|
-
*
|
|
3263
|
-
*
|
|
3264
|
-
*
|
|
1897
|
+
* The auth adapter's side of user administration through the auth collection
|
|
1898
|
+
* (`AuthAdapter.prepareUserUpdates` and the deletion pair): the admin user
|
|
1899
|
+
* routes' rules and hooks, for the data API's writes to the same rows.
|
|
3265
1900
|
*/
|
|
3266
|
-
|
|
3267
|
-
const
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
/**
|
|
3291
|
-
* Which half of the scope failed is named in the message. The holder of the
|
|
3292
|
-
* token already knows both values, so this reveals nothing to an attacker —
|
|
3293
|
-
* and without it, a client that forgets to forward `?storageId=` on the file
|
|
3294
|
-
* URL reports a "path mismatch" for a path that matches perfectly.
|
|
3295
|
-
*/
|
|
3296
|
-
const denyMismatch = (outcome) => refuse(c, ApiError.forbidden(outcome === "deny-storage" ? "Forbidden: Scoped token storage mismatch" : "Forbidden: Scoped token path mismatch"));
|
|
3297
|
-
const bearerToken = extractBearerToken(authHeader);
|
|
3298
|
-
if (bearerToken !== void 0) {
|
|
3299
|
-
const token = bearerToken;
|
|
3300
|
-
const payload = await jwtOf(c).verifyDownloadToken(token);
|
|
3301
|
-
if (payload) {
|
|
3302
|
-
const outcome = evaluateGrant(payload);
|
|
3303
|
-
if (outcome === "grant") {
|
|
3304
|
-
c.set("user", DOWNLOAD_TOKEN_PRINCIPAL);
|
|
3305
|
-
return next();
|
|
3306
|
-
}
|
|
3307
|
-
if (outcome !== "no-path") return denyMismatch(outcome);
|
|
3308
|
-
}
|
|
3309
|
-
if (isFileRoute) return refuse(c, ApiError.unauthenticated("Unauthorized: Access JWT not allowed on file routes"));
|
|
3310
|
-
return next();
|
|
3311
|
-
}
|
|
3312
|
-
if (queryToken) {
|
|
3313
|
-
const payload = await jwtOf(c).verifyDownloadToken(queryToken);
|
|
3314
|
-
if (payload) {
|
|
3315
|
-
const outcome = evaluateGrant(payload);
|
|
3316
|
-
if (outcome === "grant") {
|
|
3317
|
-
c.set("user", DOWNLOAD_TOKEN_PRINCIPAL);
|
|
3318
|
-
return next();
|
|
1901
|
+
function authCollectionUserAdmin(ctx) {
|
|
1902
|
+
const { authRepo, resolvedHooks } = ctx;
|
|
1903
|
+
return {
|
|
1904
|
+
async prepareUserUpdates(updates) {
|
|
1905
|
+
for (const { values } of updates) if (values.roles !== void 0 && values.roles !== null) readRoleNames(values.roles);
|
|
1906
|
+
await assertRoleChangesAllowed(authRepo, updates.flatMap(({ uid, values }) => Array.isArray(values.roles) ? [{
|
|
1907
|
+
uid,
|
|
1908
|
+
roles: values.roles
|
|
1909
|
+
}] : []));
|
|
1910
|
+
const prepared = [];
|
|
1911
|
+
for (const { uid, values } of updates) prepared.push(typeof values.email === "string" ? {
|
|
1912
|
+
...values,
|
|
1913
|
+
email: await normalizeUserEmailChange(authRepo, uid, values.email)
|
|
1914
|
+
} : values);
|
|
1915
|
+
return prepared;
|
|
1916
|
+
},
|
|
1917
|
+
async prepareUserDeletions(uids) {
|
|
1918
|
+
await assertUserDeletionsAllowed(authRepo, uids);
|
|
1919
|
+
if (resolvedHooks.beforeUserDelete) for (const uid of uids) await resolvedHooks.beforeUserDelete(uid);
|
|
1920
|
+
},
|
|
1921
|
+
async finalizeUserDeletions(uids) {
|
|
1922
|
+
for (const uid of uids) {
|
|
1923
|
+
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
1924
|
+
runAfterUserDelete(resolvedHooks, uid);
|
|
3319
1925
|
}
|
|
3320
|
-
if (outcome !== "no-path") return denyMismatch(outcome);
|
|
3321
1926
|
}
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
return next();
|
|
3325
|
-
};
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
3326
1929
|
//#endregion
|
|
3327
1930
|
//#region src/auth/adapter-middleware.ts
|
|
1931
|
+
var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
|
|
3328
1932
|
/**
|
|
3329
1933
|
* What a request is refused with when `adapter.verifyRequest` throws: the
|
|
3330
1934
|
* adapter's own answer when it gave one, otherwise a 401 — fail closed either
|
|
@@ -3353,6 +1957,11 @@ function createAdapterAuthMiddleware(options) {
|
|
|
3353
1957
|
driver
|
|
3354
1958
|
});
|
|
3355
1959
|
if (result !== true) return result;
|
|
1960
|
+
const refusedSimulation = await applySimulation(c, {
|
|
1961
|
+
credential: "api-key",
|
|
1962
|
+
driver
|
|
1963
|
+
});
|
|
1964
|
+
if (refusedSimulation) return refusedSimulation;
|
|
3356
1965
|
const refused = await applyImpersonation(c, {
|
|
3357
1966
|
credential: "api-key",
|
|
3358
1967
|
driver
|
|
@@ -3397,13 +2006,22 @@ function createAdapterAuthMiddleware(options) {
|
|
|
3397
2006
|
return refuse(c, ApiError.internal("Server configuration error"));
|
|
3398
2007
|
}
|
|
3399
2008
|
}
|
|
2009
|
+
const credential = !authenticatedUser ? "none" : authenticatedUser.uid === SERVICE_IDENTITY.uid ? "service-key" : "session";
|
|
2010
|
+
const resolveUser = adapter.resolveUser ? adapter.resolveUser.bind(adapter) : void 0;
|
|
2011
|
+
const refusedSimulation = await applySimulation(c, {
|
|
2012
|
+
credential,
|
|
2013
|
+
driver,
|
|
2014
|
+
resolveUser,
|
|
2015
|
+
apiKeyStore
|
|
2016
|
+
});
|
|
2017
|
+
if (refusedSimulation) return refusedSimulation;
|
|
3400
2018
|
const refused = await applyImpersonation(c, {
|
|
3401
|
-
credential
|
|
2019
|
+
credential,
|
|
3402
2020
|
driver,
|
|
3403
|
-
resolveUser
|
|
2021
|
+
resolveUser
|
|
3404
2022
|
});
|
|
3405
2023
|
if (refused) return refused;
|
|
3406
|
-
if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
|
|
2024
|
+
if (enforceAuth && !c.get("user") && !c.get("simulation")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
|
|
3407
2025
|
return next();
|
|
3408
2026
|
};
|
|
3409
2027
|
}
|
|
@@ -3847,6 +2465,13 @@ function createDevEmailSink(options = {}) {
|
|
|
3847
2465
|
}
|
|
3848
2466
|
var activeSink;
|
|
3849
2467
|
/**
|
|
2468
|
+
* Every sink registered, by the `sendEmail` it hands out: what lets a backend
|
|
2469
|
+
* serve the mailbox it was given rather than the process's last one. With two
|
|
2470
|
+
* backends in one development process, the slot above made B's admins read
|
|
2471
|
+
* A's users' reset and magic links.
|
|
2472
|
+
*/
|
|
2473
|
+
var sinksBySender = /* @__PURE__ */ new WeakMap();
|
|
2474
|
+
/**
|
|
3850
2475
|
* Make `sink` the process's development mailbox.
|
|
3851
2476
|
*
|
|
3852
2477
|
* Returns the sink, so this can wrap the construction it replaces. In
|
|
@@ -3859,8 +2484,18 @@ function registerDevEmailSink(sink) {
|
|
|
3859
2484
|
return sink;
|
|
3860
2485
|
}
|
|
3861
2486
|
activeSink = sink;
|
|
2487
|
+
sinksBySender.set(sink.sendEmail, sink);
|
|
3862
2488
|
return sink;
|
|
3863
2489
|
}
|
|
2490
|
+
/**
|
|
2491
|
+
* The registered mailbox whose `sendEmail` this is — the sink a backend's
|
|
2492
|
+
* `auth.email.sendEmail` came from — or `undefined` when it is some other
|
|
2493
|
+
* sender, or the process is production.
|
|
2494
|
+
*/
|
|
2495
|
+
function devEmailSinkFor(sendEmail) {
|
|
2496
|
+
if (process.env.NODE_ENV === "production" || !sendEmail) return void 0;
|
|
2497
|
+
return sinksBySender.get(sendEmail);
|
|
2498
|
+
}
|
|
3864
2499
|
/** The registered mailbox, if there is one. */
|
|
3865
2500
|
function activeDevEmailSink() {
|
|
3866
2501
|
return process.env.NODE_ENV === "production" ? void 0 : activeSink;
|
|
@@ -4340,6 +2975,11 @@ function createDataRateLimiter(config = {}, options = {}) {
|
|
|
4340
2975
|
const resolveBucket = async (c) => {
|
|
4341
2976
|
if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
|
|
4342
2977
|
if (isServiceKey(c)) return null;
|
|
2978
|
+
const impersonator = c.get("impersonator");
|
|
2979
|
+
if (impersonator?.uid) return bucketFor({
|
|
2980
|
+
uid: impersonator.uid,
|
|
2981
|
+
address: () => defaultKeyGenerator(c, trustedProxyHops)
|
|
2982
|
+
});
|
|
4343
2983
|
const apiKey = c.get("apiKey");
|
|
4344
2984
|
return bucketFor({
|
|
4345
2985
|
apiKey,
|
|
@@ -6258,36 +4898,29 @@ function mountOtpRoutes(deps) {
|
|
|
6258
4898
|
/**
|
|
6259
4899
|
* May an OAuth identity be attached to a pre-existing account found *by email*?
|
|
6260
4900
|
*
|
|
6261
|
-
*
|
|
6262
|
-
*
|
|
6263
|
-
* - the **provider** must have verified the address, or the caller has not
|
|
6264
|
-
* shown they control it;
|
|
6265
|
-
* - the **local account**'s address must have been verified too. Whoever
|
|
6266
|
-
* made an unverified account never proved the address, and every way in
|
|
6267
|
-
* they left on it — a password, an identity from a provider that did not
|
|
6268
|
-
* vouch for the address — would go on opening the account the owner is
|
|
6269
|
-
* about to be signed into.
|
|
4901
|
+
* Only when the **provider** verified the address. Otherwise the caller has
|
|
4902
|
+
* not shown they control it, and linking would hand them the account.
|
|
6270
4903
|
*
|
|
6271
|
-
*
|
|
6272
|
-
*
|
|
6273
|
-
*
|
|
6274
|
-
*
|
|
6275
|
-
*
|
|
6276
|
-
*
|
|
6277
|
-
*
|
|
6278
|
-
*
|
|
6279
|
-
*
|
|
4904
|
+
* When it did, the caller has proven the address exactly as reading a code out
|
|
4905
|
+
* of the inbox does: only the inbox's owner can hold a provider account that
|
|
4906
|
+
* vouches for it. If the **local account**'s address was never verified,
|
|
4907
|
+
* whoever made it never proved the address, and every way in they left on it —
|
|
4908
|
+
* a password, an identity from a provider that did not vouch for the address,
|
|
4909
|
+
* a live session — would go on opening the account the owner is about to be
|
|
4910
|
+
* signed into. So that case answers `provesAddress: true`, and the route
|
|
4911
|
+
* removes those ways in before it links (the pre-hijack defence). It used to
|
|
4912
|
+
* refuse instead, which left the owner of the address locked out of their own
|
|
4913
|
+
* address until they found a magic link or a reset.
|
|
6280
4914
|
*/
|
|
6281
4915
|
function decideOAuthAutoLink(args) {
|
|
6282
4916
|
if (args.providerEmailVerified !== true) return {
|
|
6283
4917
|
allowed: false,
|
|
6284
4918
|
reason: "provider-email-unverified"
|
|
6285
4919
|
};
|
|
6286
|
-
|
|
6287
|
-
allowed:
|
|
6288
|
-
|
|
4920
|
+
return {
|
|
4921
|
+
allowed: true,
|
|
4922
|
+
provesAddress: !args.existingUser.emailVerified
|
|
6289
4923
|
};
|
|
6290
|
-
return { allowed: true };
|
|
6291
4924
|
}
|
|
6292
4925
|
/**
|
|
6293
4926
|
* Is `redirectUri` one the operator authorised?
|
|
@@ -6743,10 +5376,18 @@ function createAuthRoutes(config) {
|
|
|
6743
5376
|
providerEmailVerified: externalUser.emailVerified,
|
|
6744
5377
|
existingUser: user
|
|
6745
5378
|
});
|
|
6746
|
-
if (!decision.allowed) throw new ApiError(403, "EMAIL_NOT_VERIFIED", `An account with this email already exists with a different sign-in method. ${
|
|
5379
|
+
if (!decision.allowed) throw new ApiError(403, "EMAIL_NOT_VERIFIED", `An account with this email already exists with a different sign-in method. ${provider.id} has not verified this email address, so it cannot be linked automatically. Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, {
|
|
6747
5380
|
reason: decision.reason,
|
|
6748
5381
|
provider: provider.id
|
|
6749
5382
|
});
|
|
5383
|
+
if (decision.provesAddress) {
|
|
5384
|
+
await confirmAddressOwnership(authRepo, user, null);
|
|
5385
|
+
user = {
|
|
5386
|
+
...user,
|
|
5387
|
+
emailVerified: true,
|
|
5388
|
+
passwordHash: null
|
|
5389
|
+
};
|
|
5390
|
+
}
|
|
6750
5391
|
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
6751
5392
|
await authRepo.updateUser(user.id, {
|
|
6752
5393
|
displayName: user.displayName || externalUser.displayName || void 0,
|
|
@@ -7710,9 +6351,10 @@ function createAdminUsersRoute(config) {
|
|
|
7710
6351
|
});
|
|
7711
6352
|
router.post("/users", requireScope("users:write"), async (c) => {
|
|
7712
6353
|
const body = await c.req.json();
|
|
7713
|
-
const { email
|
|
6354
|
+
const { email } = body;
|
|
7714
6355
|
if (!email) throw ApiError.badRequest("Email is required");
|
|
7715
|
-
|
|
6356
|
+
const roles = body.roles === void 0 || body.roles === null ? void 0 : readRoleNames(body.roles);
|
|
6357
|
+
if (roles !== void 0) assertMayGrantRoles(c, roles);
|
|
7716
6358
|
if (await authRepo.getUserByEmail(normalizeEmail(email))) throw ApiError.conflict("A user with this email already exists");
|
|
7717
6359
|
const prepResult = await prepareAdminUserValues(body, {
|
|
7718
6360
|
authRepo,
|
|
@@ -7731,7 +6373,7 @@ function createAdminUsersRoute(config) {
|
|
|
7731
6373
|
};
|
|
7732
6374
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
7733
6375
|
const user = await authRepo.createUser(createData);
|
|
7734
|
-
if (roles
|
|
6376
|
+
if (roles !== void 0) await authRepo.setUserRoles(user.id, roles);
|
|
7735
6377
|
const delivery = await completeUserCreation(prepResult, (clearPassword) => finalizeAdminUserCreation({
|
|
7736
6378
|
id: user.id,
|
|
7737
6379
|
values: prepResult.values
|
|
@@ -7750,7 +6392,9 @@ function createAdminUsersRoute(config) {
|
|
|
7750
6392
|
});
|
|
7751
6393
|
router.put("/users/:uid", requireScope("users:write"), async (c) => {
|
|
7752
6394
|
const uid = c.req.param("uid");
|
|
7753
|
-
const
|
|
6395
|
+
const body = await c.req.json();
|
|
6396
|
+
const { password, email, displayName, disabled } = body;
|
|
6397
|
+
const roles = body.roles === void 0 || body.roles === null ? void 0 : readRoleNames(body.roles);
|
|
7754
6398
|
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
7755
6399
|
if (disabled !== void 0) {
|
|
7756
6400
|
if (typeof disabled !== "boolean") throw ApiError.badRequest("`disabled` must be true or false", "INVALID_INPUT");
|
|
@@ -7759,8 +6403,8 @@ function createAdminUsersRoute(config) {
|
|
|
7759
6403
|
if (typeof authRepo.setUserDisabled !== "function") throw new ApiError(501, "NOT_SUPPORTED", "This backend's auth repository cannot disable accounts.");
|
|
7760
6404
|
}
|
|
7761
6405
|
assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
|
|
7762
|
-
if (roles !== void 0
|
|
7763
|
-
assertMayGrantRoles(c, roles
|
|
6406
|
+
if (roles !== void 0) {
|
|
6407
|
+
assertMayGrantRoles(c, roles);
|
|
7764
6408
|
await assertRoleChangesAllowed(authRepo, [{
|
|
7765
6409
|
uid,
|
|
7766
6410
|
roles
|
|
@@ -7777,7 +6421,7 @@ function createAdminUsersRoute(config) {
|
|
|
7777
6421
|
}
|
|
7778
6422
|
if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
|
|
7779
6423
|
if (passwordHash) await replaceUserPassword(authRepo, uid, passwordHash);
|
|
7780
|
-
if (roles !== void 0
|
|
6424
|
+
if (roles !== void 0) await authRepo.setUserRoles(uid, roles);
|
|
7781
6425
|
if (typeof disabled === "boolean" && authRepo.setUserDisabled) {
|
|
7782
6426
|
await authRepo.setUserDisabled(uid, disabled);
|
|
7783
6427
|
if (disabled) await revokeAllSessions(authRepo, uid);
|
|
@@ -7877,7 +6521,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7877
6521
|
*/
|
|
7878
6522
|
function lookupRefusal(line, uid, error) {
|
|
7879
6523
|
if (isDatabaseUnreachable(error)) {
|
|
7880
|
-
logger.warn(`${line}: the database is unreachable`, { uid });
|
|
6524
|
+
if (!warnIfDatabaseClosed(error)) logger.warn(`${line}: the database is unreachable`, { uid });
|
|
7881
6525
|
return ApiError.databaseUnavailable();
|
|
7882
6526
|
}
|
|
7883
6527
|
logger.warn(line, {
|
|
@@ -8277,7 +6921,7 @@ function scopesFromStoredPermissions(permissions, admin) {
|
|
|
8277
6921
|
*
|
|
8278
6922
|
* @module
|
|
8279
6923
|
*/
|
|
8280
|
-
var TABLE
|
|
6924
|
+
var TABLE = "rebase.api_keys";
|
|
8281
6925
|
/**
|
|
8282
6926
|
* Drops `rebase.idx_api_keys_hash` — a plain btree on `key_hash`, next to the
|
|
8283
6927
|
* UNIQUE (key_hash) constraint's own index — but only while a valid, unique,
|
|
@@ -8332,7 +6976,7 @@ var BOOT_COLUMNS = [
|
|
|
8332
6976
|
var BOOT_STATE_SQL = (() => {
|
|
8333
6977
|
const list = (names) => names.map((name) => `'${name}'`).join(", ");
|
|
8334
6978
|
const target = `(SELECT oid FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')`;
|
|
8335
|
-
const columnCount = (names) => `(SELECT count(*) FROM pg_attribute a WHERE a.attrelid = to_regclass('${TABLE
|
|
6979
|
+
const columnCount = (names) => `(SELECT count(*) FROM pg_attribute a WHERE a.attrelid = to_regclass('${TABLE}') AND a.attnum > 0 AND NOT a.attisdropped AND a.attname IN (${list(names)}))::int`;
|
|
8336
6980
|
return `
|
|
8337
6981
|
SELECT
|
|
8338
6982
|
(SELECT count(*) FROM pg_class c WHERE c.relnamespace = to_regnamespace('rebase')
|
|
@@ -8342,12 +6986,12 @@ SELECT
|
|
|
8342
6986
|
${columnCount(BOOT_COLUMNS)} AS columns,
|
|
8343
6987
|
${columnCount(["permissions"])} AS legacy,
|
|
8344
6988
|
(SELECT count(*) FROM pg_class c
|
|
8345
|
-
WHERE c.oid = to_regclass('${TABLE
|
|
6989
|
+
WHERE c.oid = to_regclass('${TABLE}') AND NOT c.relrowsecurity AND ${target} IS NOT NULL
|
|
8346
6990
|
AND (c.relacl IS NULL
|
|
8347
6991
|
OR EXISTS (SELECT 1 FROM aclexplode(c.relacl) a WHERE a.grantee = ${target})
|
|
8348
6992
|
OR EXISTS (SELECT 1 FROM pg_attribute att, aclexplode(att.attacl) a
|
|
8349
6993
|
WHERE att.attrelid = c.oid AND att.attacl IS NOT NULL AND a.grantee = ${target})))::int AS granted,
|
|
8350
|
-
(has_schema_privilege('rebase', 'USAGE') AND has_table_privilege(to_regclass('${TABLE
|
|
6994
|
+
(has_schema_privilege('rebase', 'USAGE') AND has_table_privilege(to_regclass('${TABLE}'), 'SELECT')) AS readable
|
|
8351
6995
|
WHERE to_regnamespace('rebase') IS NOT NULL`.trim();
|
|
8352
6996
|
})();
|
|
8353
6997
|
/**
|
|
@@ -8432,13 +7076,13 @@ function rowToApiKey(row) {
|
|
|
8432
7076
|
*
|
|
8433
7077
|
* Returns `undefined` if the driver does not support `executeSql`.
|
|
8434
7078
|
*/
|
|
8435
|
-
function createApiKeyStore(driver) {
|
|
7079
|
+
function createApiKeyStore(driver, options = {}) {
|
|
8436
7080
|
const admin = driver.admin;
|
|
8437
7081
|
if (!isSQLAdmin(admin)) {
|
|
8438
7082
|
logger.warn("⚠️ [api-key-store] DataDriver does not support SQL admin — API keys will not be available.");
|
|
8439
7083
|
return;
|
|
8440
7084
|
}
|
|
8441
|
-
const exec = (sqlText,
|
|
7085
|
+
const exec = provisioningExec((sqlText, execOptions) => admin.executeSql(sqlText, execOptions?.params ? { params: execOptions.params } : void 0), options.provision);
|
|
8442
7086
|
const ddl = createDdlBootstrapper(exec, "api-key-store");
|
|
8443
7087
|
/**
|
|
8444
7088
|
* Give every row stored before scopes existed the scopes it now holds.
|
|
@@ -8449,10 +7093,10 @@ function createApiKeyStore(driver) {
|
|
|
8449
7093
|
* runtime that predates scopes still reads its own keys after a rollback.
|
|
8450
7094
|
*/
|
|
8451
7095
|
async function backfillScopes() {
|
|
8452
|
-
const rows = await exec(`SELECT id, permissions, admin FROM ${TABLE
|
|
7096
|
+
const rows = await exec(`SELECT id, permissions, admin FROM ${TABLE} WHERE scopes IS NULL`);
|
|
8453
7097
|
for (const row of rows) {
|
|
8454
7098
|
const { scopes, roles } = scopesFromStoredPermissions(parseStoredPermissions(row.permissions), row.admin === true);
|
|
8455
|
-
await exec(`UPDATE ${TABLE
|
|
7099
|
+
await exec(`UPDATE ${TABLE} SET scopes = $1::jsonb, roles = $2::jsonb WHERE id = $3 AND scopes IS NULL`, { params: [
|
|
8456
7100
|
JSON.stringify(scopes),
|
|
8457
7101
|
JSON.stringify(roles),
|
|
8458
7102
|
row.id
|
|
@@ -8484,8 +7128,8 @@ function createApiKeyStore(driver) {
|
|
|
8484
7128
|
return;
|
|
8485
7129
|
}
|
|
8486
7130
|
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
8487
|
-
await ddl.ensureObject(`Creating ${TABLE
|
|
8488
|
-
CREATE TABLE IF NOT EXISTS ${TABLE
|
|
7131
|
+
await ddl.ensureObject(`Creating ${TABLE}`, `
|
|
7132
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
8489
7133
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
8490
7134
|
name TEXT NOT NULL,
|
|
8491
7135
|
kind TEXT NOT NULL DEFAULT 'service',
|
|
@@ -8506,35 +7150,42 @@ function createApiKeyStore(driver) {
|
|
|
8506
7150
|
try {
|
|
8507
7151
|
await exec(DROP_DUPLICATE_KEY_HASH_INDEX_SQL);
|
|
8508
7152
|
} catch (err) {
|
|
8509
|
-
logger.warn(`[api-key-store] Left the duplicate idx_api_keys_hash in place for now: ${err instanceof Error ? err.message : String(err)}`);
|
|
7153
|
+
if (!(err instanceof SchemaChangeRefusedError)) logger.warn(`[api-key-store] Left the duplicate idx_api_keys_hash in place for now: ${err instanceof Error ? err.message : String(err)}`);
|
|
8510
7154
|
}
|
|
8511
7155
|
await ddl.ensureObject("Creating idx_api_keys_prefix", `
|
|
8512
7156
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
|
|
8513
|
-
ON ${TABLE
|
|
7157
|
+
ON ${TABLE}(key_prefix)
|
|
8514
7158
|
`);
|
|
8515
|
-
await ddl.ensureObject(`Adding ${TABLE
|
|
8516
|
-
ALTER TABLE ${TABLE
|
|
7159
|
+
await ddl.ensureObject(`Adding ${TABLE}.kind`, `
|
|
7160
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'service'
|
|
8517
7161
|
`);
|
|
8518
|
-
await ddl.ensureObject(`Adding ${TABLE
|
|
8519
|
-
ALTER TABLE ${TABLE
|
|
7162
|
+
await ddl.ensureObject(`Adding ${TABLE}.scopes`, `
|
|
7163
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS scopes JSONB
|
|
8520
7164
|
`);
|
|
8521
|
-
await ddl.ensureObject(`Adding ${TABLE
|
|
8522
|
-
ALTER TABLE ${TABLE
|
|
7165
|
+
await ddl.ensureObject(`Adding ${TABLE}.roles`, `
|
|
7166
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS roles JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
8523
7167
|
`);
|
|
8524
|
-
await ddl.ensureObject(`Adding ${TABLE
|
|
8525
|
-
ALTER TABLE ${TABLE
|
|
7168
|
+
await ddl.ensureObject(`Adding ${TABLE}.owner_uid`, `
|
|
7169
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS owner_uid TEXT
|
|
8526
7170
|
`);
|
|
8527
7171
|
await ddl.ensureObject("Creating idx_api_keys_owner", `
|
|
8528
7172
|
CREATE INDEX IF NOT EXISTS idx_api_keys_owner
|
|
8529
|
-
ON ${TABLE
|
|
7173
|
+
ON ${TABLE}(owner_uid) WHERE owner_uid IS NOT NULL
|
|
8530
7174
|
`);
|
|
8531
|
-
if (!await ddl.isReadable(TABLE
|
|
8532
|
-
logger.error(`❌ [api-key-store] ${TABLE$
|
|
7175
|
+
if (!await ddl.isReadable(TABLE)) {
|
|
7176
|
+
logger.error(options.provision === false ? `❌ [api-key-store] ${TABLE} does not exist, and this backend changes nothing in its database — every API-key authenticated request will be rejected on this instance until a provisioning run creates it.` : `❌ [api-key-store] ${TABLE} is unavailable — every API-key authenticated request will be rejected on this instance, and the table could not be taken back off the end-user role.`);
|
|
8533
7177
|
return;
|
|
8534
7178
|
}
|
|
8535
7179
|
await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
|
|
8536
|
-
|
|
8537
|
-
WHERE table_schema = 'rebase' AND table_name = 'api_keys' AND column_name = 'permissions'`)
|
|
7180
|
+
const legacyColumns = await exec(`SELECT 1 FROM information_schema.columns
|
|
7181
|
+
WHERE table_schema = 'rebase' AND table_name = 'api_keys' AND column_name = 'permissions'`);
|
|
7182
|
+
if (options.provision === false) try {
|
|
7183
|
+
await exec(`SELECT ${BOOT_COLUMNS.join(", ")} FROM ${TABLE} WHERE false`);
|
|
7184
|
+
} catch {
|
|
7185
|
+
logger.error(`❌ [api-key-store] ${TABLE} is out of date — it lacks one of ${BOOT_COLUMNS.join(", ")}, which this release reads. This backend changes nothing in its database; a provisioning run adds them.`);
|
|
7186
|
+
}
|
|
7187
|
+
if (legacyColumns.length > 0 && options.provision === false) logger.warn(`⚠️ [api-key-store] ${TABLE} still has the pre-scopes \`permissions\` column. Keys stored before scopes are given theirs by a provisioning run; this backend changes nothing in its database.`);
|
|
7188
|
+
else if (legacyColumns.length > 0) await ddl.step("Backfilling API key scopes", backfillScopes);
|
|
8538
7189
|
logger.debug("✅ API keys table ready");
|
|
8539
7190
|
},
|
|
8540
7191
|
async createApiKey(key, createdBy) {
|
|
@@ -8542,7 +7193,7 @@ function createApiKeyStore(driver) {
|
|
|
8542
7193
|
const hash = hashKey(plaintext);
|
|
8543
7194
|
const prefix = keyPrefix(plaintext);
|
|
8544
7195
|
return {
|
|
8545
|
-
...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE
|
|
7196
|
+
...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE} (name, kind, key_prefix, key_hash, scopes, roles, owner_uid, rate_limit, created_by, expires_at)
|
|
8546
7197
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10)
|
|
8547
7198
|
RETURNING *`, { params: [
|
|
8548
7199
|
key.name,
|
|
@@ -8560,23 +7211,23 @@ function createApiKeyStore(driver) {
|
|
|
8560
7211
|
};
|
|
8561
7212
|
},
|
|
8562
7213
|
async findByKeyHash(hash) {
|
|
8563
|
-
const rows = await exec(`SELECT * FROM ${TABLE
|
|
7214
|
+
const rows = await exec(`SELECT * FROM ${TABLE}
|
|
8564
7215
|
WHERE key_hash = $1
|
|
8565
7216
|
LIMIT 1`, { params: [hash] });
|
|
8566
7217
|
if (rows.length === 0) return null;
|
|
8567
7218
|
return rowToApiKey(rows[0]);
|
|
8568
7219
|
},
|
|
8569
7220
|
async listApiKeys(filter) {
|
|
8570
|
-
return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE
|
|
7221
|
+
return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE}
|
|
8571
7222
|
WHERE kind = 'personal' AND owner_uid = $1
|
|
8572
7223
|
ORDER BY created_at DESC`, { params: [filter.owner_uid] }) : await exec(`
|
|
8573
|
-
SELECT * FROM ${TABLE
|
|
7224
|
+
SELECT * FROM ${TABLE}
|
|
8574
7225
|
WHERE kind = 'service'
|
|
8575
7226
|
ORDER BY created_at DESC
|
|
8576
7227
|
`)).map((r) => toMasked(rowToApiKey(r)));
|
|
8577
7228
|
},
|
|
8578
7229
|
async getApiKeyById(id) {
|
|
8579
|
-
const rows = await exec(`SELECT * FROM ${TABLE
|
|
7230
|
+
const rows = await exec(`SELECT * FROM ${TABLE}
|
|
8580
7231
|
WHERE id = $1
|
|
8581
7232
|
LIMIT 1`, { params: [id] });
|
|
8582
7233
|
if (rows.length === 0) return null;
|
|
@@ -8609,7 +7260,7 @@ function createApiKeyStore(driver) {
|
|
|
8609
7260
|
if (setClauses.length === 0) return this.getApiKeyById(id);
|
|
8610
7261
|
setClauses.push("updated_at = NOW()");
|
|
8611
7262
|
params.push(id);
|
|
8612
|
-
const rows = await exec(`UPDATE ${TABLE
|
|
7263
|
+
const rows = await exec(`UPDATE ${TABLE}
|
|
8613
7264
|
SET ${setClauses.join(", ")}
|
|
8614
7265
|
WHERE id = $${paramIdx} AND kind = 'service'
|
|
8615
7266
|
RETURNING *`, { params });
|
|
@@ -8617,17 +7268,17 @@ function createApiKeyStore(driver) {
|
|
|
8617
7268
|
return toMasked(rowToApiKey(rows[0]));
|
|
8618
7269
|
},
|
|
8619
7270
|
async revokeApiKey(id, owner_uid) {
|
|
8620
|
-
return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE
|
|
7271
|
+
return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE}
|
|
8621
7272
|
SET revoked_at = NOW(), updated_at = NOW()
|
|
8622
7273
|
WHERE id = $1 AND kind = 'service' AND revoked_at IS NULL
|
|
8623
|
-
RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE
|
|
7274
|
+
RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE}
|
|
8624
7275
|
SET revoked_at = NOW(), updated_at = NOW()
|
|
8625
7276
|
WHERE id = $1 AND kind = 'personal' AND owner_uid = $2 AND revoked_at IS NULL
|
|
8626
7277
|
RETURNING id`, { params: [id, owner_uid] })).length > 0;
|
|
8627
7278
|
},
|
|
8628
7279
|
async updateLastUsed(id) {
|
|
8629
7280
|
try {
|
|
8630
|
-
await exec(`UPDATE ${TABLE
|
|
7281
|
+
await exec(`UPDATE ${TABLE}
|
|
8631
7282
|
SET last_used_at = NOW()
|
|
8632
7283
|
WHERE id = $1`, { params: [id] });
|
|
8633
7284
|
} catch (err) {
|
|
@@ -8658,10 +7309,20 @@ function readScopesField(value) {
|
|
|
8658
7309
|
if (!Array.isArray(value) || value.length === 0 || value.some((entry) => typeof entry !== "string")) throw ApiError.badRequest("scopes must be a non-empty array of scope strings, e.g. [\"data:read\", \"data:write:posts\"]", "INVALID_INPUT");
|
|
8659
7310
|
return [...new Set(value.map((entry) => entry.trim()))];
|
|
8660
7311
|
}
|
|
8661
|
-
/**
|
|
7312
|
+
/**
|
|
7313
|
+
* Read a `roles` body field: an array of role names, trimmed, deduplicated,
|
|
7314
|
+
* without `service`. A key's roles reach RLS joined by `,` in `app.user_roles`,
|
|
7315
|
+
* so a name `roleNameProblem` refuses — `"editor,admin"` — is refused here too.
|
|
7316
|
+
*/
|
|
8662
7317
|
function readRolesField(value) {
|
|
8663
7318
|
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim() === "")) throw ApiError.badRequest("roles must be an array of role names", "INVALID_INPUT");
|
|
8664
|
-
|
|
7319
|
+
const trimmed = value.map((entry) => String(entry).trim());
|
|
7320
|
+
const problems = trimmed.flatMap((name) => {
|
|
7321
|
+
const problem = roleNameProblem(name);
|
|
7322
|
+
return problem ? [`${JSON.stringify(name)} ${problem}`] : [];
|
|
7323
|
+
});
|
|
7324
|
+
if (problems.length > 0) throw new ApiError(400, "INVALID_ROLE", `Not valid role names: ${problems.join("; ")}. A role name is a non-empty string without , { } " \\ or control characters.`, { problems });
|
|
7325
|
+
return [...new Set(trimmed)].filter((role) => role !== "service");
|
|
8665
7326
|
}
|
|
8666
7327
|
/**
|
|
8667
7328
|
* Refuse scopes that are malformed, unknown, aimed at a target that does not
|
|
@@ -8707,6 +7368,24 @@ function assertRolesGrantable(requested, minterRoles) {
|
|
|
8707
7368
|
const beyond = requested.filter((role) => !minterRoles.includes(role));
|
|
8708
7369
|
if (beyond.length > 0) throw new ApiError(403, "ROLE_EXCEEDS_CREATOR", `A key cannot run as a role the account creating it does not hold: ${beyond.join(", ")}.`, { roles: beyond });
|
|
8709
7370
|
}
|
|
7371
|
+
/**
|
|
7372
|
+
* Refuse changing or revoking a key that holds more than the caller — a role
|
|
7373
|
+
* they do not hold, or a scope beyond theirs. An admin manages any key.
|
|
7374
|
+
*
|
|
7375
|
+
* The same ceiling as minting, applied to the key as it already is: a
|
|
7376
|
+
* `keys:write` role could otherwise widen an admin-role key it could never
|
|
7377
|
+
* have minted to every data scope, or revoke the key an admin's integration
|
|
7378
|
+
* runs on. `assertMayManageAccount` is the same rule for accounts.
|
|
7379
|
+
*/
|
|
7380
|
+
function assertMayManageKey(existing, managerRoles, managerScopes) {
|
|
7381
|
+
if (hasAdminRole(managerRoles)) return;
|
|
7382
|
+
const roles = existing.roles.filter((role) => !managerRoles.includes(role));
|
|
7383
|
+
const scopes = scopesBeyond(existing.scopes, managerScopes);
|
|
7384
|
+
if (roles.length > 0 || scopes.length > 0) throw new ApiError(403, "KEY_OUTRANKS_CALLER", `This key holds ${[...roles.map((role) => `the role ${role}`), ...scopes].join(", ")}, which you do not, so you cannot change or revoke it.`, {
|
|
7385
|
+
roles,
|
|
7386
|
+
scopes
|
|
7387
|
+
});
|
|
7388
|
+
}
|
|
8710
7389
|
/** Read an optional `expires_at`: absent, null, or a future ISO-8601 instant. */
|
|
8711
7390
|
function readExpiresAt(value, requireFuture) {
|
|
8712
7391
|
if (value === void 0) return void 0;
|
|
@@ -8803,6 +7482,11 @@ function createApiKeyRoutes(options) {
|
|
|
8803
7482
|
read: "keys:read",
|
|
8804
7483
|
write: "keys:write"
|
|
8805
7484
|
}));
|
|
7485
|
+
const existingServiceKey = async (id) => {
|
|
7486
|
+
const key = await store.getApiKeyById(id);
|
|
7487
|
+
if (!key || key.kind !== "service") throw ApiError.notFound("API key not found");
|
|
7488
|
+
return key;
|
|
7489
|
+
};
|
|
8806
7490
|
router.get("/", async (c) => {
|
|
8807
7491
|
const keys = await store.listApiKeys({ kind: "service" });
|
|
8808
7492
|
return c.json({ keys });
|
|
@@ -8851,11 +7535,13 @@ function createApiKeyRoutes(options) {
|
|
|
8851
7535
|
if (rateLimit !== void 0) updates.rate_limit = rateLimit;
|
|
8852
7536
|
const expiresAt = readExpiresAt(body.expires_at, false);
|
|
8853
7537
|
if (expiresAt !== void 0) updates.expires_at = expiresAt;
|
|
7538
|
+
assertMayManageKey(await existingServiceKey(id), minter.roles, callerScopes(c));
|
|
8854
7539
|
const key = await store.updateApiKey(id, updates);
|
|
8855
7540
|
if (!key) throw ApiError.notFound("API key not found");
|
|
8856
7541
|
return c.json({ key });
|
|
8857
7542
|
});
|
|
8858
7543
|
router.delete("/:id", async (c) => {
|
|
7544
|
+
assertMayManageKey(await existingServiceKey(c.req.param("id")), caller(c).roles, callerScopes(c));
|
|
8859
7545
|
if (!await store.revokeApiKey(c.req.param("id"))) throw ApiError.notFound("API key not found or already revoked");
|
|
8860
7546
|
return c.json({ success: true });
|
|
8861
7547
|
});
|
|
@@ -8925,194 +7611,6 @@ function createPersonalKeyRoutes(options) {
|
|
|
8925
7611
|
return router;
|
|
8926
7612
|
}
|
|
8927
7613
|
//#endregion
|
|
8928
|
-
//#region src/auth/sql-rate-limit-store.ts
|
|
8929
|
-
var SCHEMA = "rebase";
|
|
8930
|
-
var TABLE_NAME = "rate_limit_hits";
|
|
8931
|
-
var TABLE = `${SCHEMA}.${TABLE_NAME}`;
|
|
8932
|
-
/** How often expired buckets are deleted. */
|
|
8933
|
-
var DEFAULT_SWEEP_MS = 300 * 1e3;
|
|
8934
|
-
/**
|
|
8935
|
-
* Build the store, or `undefined` if this driver cannot back it.
|
|
8936
|
-
*
|
|
8937
|
-
* Returning `undefined` rather than throwing matches `createCronStore`: a
|
|
8938
|
-
* deployment on a driver with no SQL admin should degrade to per-process counts
|
|
8939
|
-
* with a warning, not refuse to serve.
|
|
8940
|
-
*/
|
|
8941
|
-
function createSqlRateLimitStore(driver, options = {}) {
|
|
8942
|
-
const admin = driver.admin;
|
|
8943
|
-
if (!isSQLAdmin(admin)) {
|
|
8944
|
-
logger.warn("⚠️ [rate-limit] The configured driver has no SQL admin, so rate-limit counts cannot be shared between processes. Falling back to this process's memory — each replica will enforce the limit independently.");
|
|
8945
|
-
return;
|
|
8946
|
-
}
|
|
8947
|
-
const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
|
|
8948
|
-
const ddl = createDdlBootstrapper(exec, "rate-limit-store");
|
|
8949
|
-
const now = options.now ?? Date.now;
|
|
8950
|
-
const sweepMs = options.sweepMs ?? DEFAULT_SWEEP_MS;
|
|
8951
|
-
let sweepTimer;
|
|
8952
|
-
let ready;
|
|
8953
|
-
/**
|
|
8954
|
-
* Create the table once, and remember whether it worked.
|
|
8955
|
-
*
|
|
8956
|
-
* Lazily rather than at boot because the store is constructed while routes
|
|
8957
|
-
* are being mounted, and an `await` there would put a database round trip on
|
|
8958
|
-
* the boot path of every deployment — including the ones that never take a
|
|
8959
|
-
* request. The first request pays for it.
|
|
8960
|
-
*
|
|
8961
|
-
* The result is cached even on failure: a store that cannot create its table
|
|
8962
|
-
* must not retry the DDL on every request, which would turn one broken
|
|
8963
|
-
* permission into a per-request query storm.
|
|
8964
|
-
*
|
|
8965
|
-
* Readiness is decided by reading the table, never by the DDL not throwing —
|
|
8966
|
-
* `ensureObject` routes through `step`, which logs a failure and returns
|
|
8967
|
-
* normally. Keying on the absence of an exception would report a table that
|
|
8968
|
-
* was never created as ready, and every later request would fall into the
|
|
8969
|
-
* per-request error path instead of the one-time fallback.
|
|
8970
|
-
*/
|
|
8971
|
-
async function ensureReady() {
|
|
8972
|
-
if (!ready) ready = (async () => {
|
|
8973
|
-
try {
|
|
8974
|
-
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
8975
|
-
await ddl.ensureObject(`Creating ${TABLE}`, `
|
|
8976
|
-
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
8977
|
-
key TEXT NOT NULL,
|
|
8978
|
-
bucket BIGINT NOT NULL,
|
|
8979
|
-
hits INTEGER NOT NULL DEFAULT 0,
|
|
8980
|
-
expires_at TIMESTAMPTZ NOT NULL,
|
|
8981
|
-
PRIMARY KEY (key, bucket)
|
|
8982
|
-
)
|
|
8983
|
-
`);
|
|
8984
|
-
await ddl.ensureObject("Creating idx_rate_limit_hits_expiry", `
|
|
8985
|
-
CREATE INDEX IF NOT EXISTS idx_rate_limit_hits_expiry
|
|
8986
|
-
ON ${TABLE}(expires_at)
|
|
8987
|
-
`);
|
|
8988
|
-
const readable = await ddl.isReadable(TABLE);
|
|
8989
|
-
if (readable) await ddl.step("Revoking end-user access to rate_limit_hits", () => exec(revokeInternalTableSql(SCHEMA, TABLE_NAME)));
|
|
8990
|
-
else logger.error("[rate-limit] The shared rate-limit table could not be created, so counts stay per-process. Every replica will enforce the limit independently.");
|
|
8991
|
-
return readable;
|
|
8992
|
-
} catch (err) {
|
|
8993
|
-
logger.error("[rate-limit] Could not prepare the shared rate-limit table. Falling back to per-process counts for the life of this process.", { error: err instanceof Error ? err.message : String(err) });
|
|
8994
|
-
return false;
|
|
8995
|
-
}
|
|
8996
|
-
})();
|
|
8997
|
-
return ready;
|
|
8998
|
-
}
|
|
8999
|
-
function startSweeping() {
|
|
9000
|
-
if (sweepTimer) return;
|
|
9001
|
-
sweepTimer = setInterval(() => {
|
|
9002
|
-
exec(`DELETE FROM ${TABLE} WHERE expires_at < now()`).catch((err) => {
|
|
9003
|
-
logger.debug("[rate-limit] Bucket sweep failed", { error: err instanceof Error ? err.message : String(err) });
|
|
9004
|
-
});
|
|
9005
|
-
}, sweepMs);
|
|
9006
|
-
sweepTimer.unref?.();
|
|
9007
|
-
}
|
|
9008
|
-
return {
|
|
9009
|
-
async hit(key, windowMs, limit) {
|
|
9010
|
-
if (!await ensureReady()) return {
|
|
9011
|
-
allowed: true,
|
|
9012
|
-
remaining: limit,
|
|
9013
|
-
retryAfterMs: 0
|
|
9014
|
-
};
|
|
9015
|
-
startSweeping();
|
|
9016
|
-
const at = now();
|
|
9017
|
-
const bucket = Math.floor(at / windowMs);
|
|
9018
|
-
const carry = 1 - (at - bucket * windowMs) / windowMs;
|
|
9019
|
-
const expiresAt = new Date((bucket + 2) * windowMs).toISOString();
|
|
9020
|
-
let current;
|
|
9021
|
-
let previous;
|
|
9022
|
-
try {
|
|
9023
|
-
const row = firstRow(await exec(`WITH bumped AS (
|
|
9024
|
-
INSERT INTO ${TABLE} AS r (key, bucket, hits, expires_at)
|
|
9025
|
-
VALUES ($1, $2, 1, $3)
|
|
9026
|
-
ON CONFLICT (key, bucket)
|
|
9027
|
-
DO UPDATE SET hits = r.hits + 1, expires_at = EXCLUDED.expires_at
|
|
9028
|
-
RETURNING hits
|
|
9029
|
-
)
|
|
9030
|
-
SELECT
|
|
9031
|
-
(SELECT hits FROM bumped) AS current,
|
|
9032
|
-
COALESCE((SELECT hits FROM ${TABLE} WHERE key = $1 AND bucket = $4), 0) AS previous`, { params: [
|
|
9033
|
-
key,
|
|
9034
|
-
bucket,
|
|
9035
|
-
expiresAt,
|
|
9036
|
-
bucket - 1
|
|
9037
|
-
] }));
|
|
9038
|
-
current = Number(row?.current ?? 1);
|
|
9039
|
-
previous = Number(row?.previous ?? 0);
|
|
9040
|
-
} catch (err) {
|
|
9041
|
-
logger.debug("[rate-limit] Shared count unavailable for this request", { error: err instanceof Error ? err.message : String(err) });
|
|
9042
|
-
return {
|
|
9043
|
-
allowed: true,
|
|
9044
|
-
remaining: limit,
|
|
9045
|
-
retryAfterMs: 0
|
|
9046
|
-
};
|
|
9047
|
-
}
|
|
9048
|
-
const estimate = current + previous * carry;
|
|
9049
|
-
if (estimate > limit) {
|
|
9050
|
-
exec(`UPDATE ${TABLE} SET hits = GREATEST(hits - 1, 0) WHERE key = $1 AND bucket = $2`, { params: [key, bucket] }).catch(() => {});
|
|
9051
|
-
return {
|
|
9052
|
-
allowed: false,
|
|
9053
|
-
remaining: 0,
|
|
9054
|
-
retryAfterMs: Math.max(0, (bucket + 1) * windowMs - at)
|
|
9055
|
-
};
|
|
9056
|
-
}
|
|
9057
|
-
return {
|
|
9058
|
-
allowed: true,
|
|
9059
|
-
remaining: Math.max(0, Math.floor(limit - estimate)),
|
|
9060
|
-
retryAfterMs: 0
|
|
9061
|
-
};
|
|
9062
|
-
},
|
|
9063
|
-
dispose() {
|
|
9064
|
-
if (sweepTimer) clearInterval(sweepTimer);
|
|
9065
|
-
sweepTimer = void 0;
|
|
9066
|
-
}
|
|
9067
|
-
};
|
|
9068
|
-
}
|
|
9069
|
-
/**
|
|
9070
|
-
* The first row of whatever shape this driver returns.
|
|
9071
|
-
*
|
|
9072
|
-
* `SqlExec` declares an array of rows, but drivers in this repository have also
|
|
9073
|
-
* returned a `{ rows }` envelope through the same escape hatch. Reading one
|
|
9074
|
-
* shape only is how a store silently counts nothing and reports every caller as
|
|
9075
|
-
* being on their first request — a limiter that never limits, with no error
|
|
9076
|
-
* anywhere. Tolerating both costs one line.
|
|
9077
|
-
*/
|
|
9078
|
-
var firstRow = firstSqlRow;
|
|
9079
|
-
//#endregion
|
|
9080
|
-
//#region src/auth/resolve-rate-limit-store.ts
|
|
9081
|
-
/**
|
|
9082
|
-
* A refusal to boot, phrased as the variable to change.
|
|
9083
|
-
*
|
|
9084
|
-
* Its own class so `bootFromBundle` can present it as a configuration problem
|
|
9085
|
-
* rather than a crash, matching `RoleConfigurationError`.
|
|
9086
|
-
*/
|
|
9087
|
-
var RateLimitStoreConfigurationError = class extends Error {
|
|
9088
|
-
hint;
|
|
9089
|
-
constructor(message, hint) {
|
|
9090
|
-
super(message);
|
|
9091
|
-
this.hint = hint;
|
|
9092
|
-
this.name = "RateLimitStoreConfigurationError";
|
|
9093
|
-
}
|
|
9094
|
-
};
|
|
9095
|
-
/**
|
|
9096
|
-
* Which store this process should build.
|
|
9097
|
-
*
|
|
9098
|
-
* A misspelled value **refuses to boot** rather than falling back to memory.
|
|
9099
|
-
* Every other outcome here is silent — the whole failure mode of this setting is
|
|
9100
|
-
* that nothing looks wrong — so the one moment it can be caught is the moment it
|
|
9101
|
-
* is read. `REBASE_RATE_LIMIT_STORE=postgres` is the obvious thing to type and
|
|
9102
|
-
* would otherwise be a per-replica limit wearing the shape of a shared one.
|
|
9103
|
-
*
|
|
9104
|
-
* A blank or whitespace-only value is *unset*, not invalid:
|
|
9105
|
-
* `REBASE_RATE_LIMIT_STORE=${SOMETHING}` with `SOMETHING` undefined is the
|
|
9106
|
-
* ordinary way to write a compose file, and it is how the platform neutralises a
|
|
9107
|
-
* tenant's own variable.
|
|
9108
|
-
*/
|
|
9109
|
-
function resolveRateLimitStoreKind(env) {
|
|
9110
|
-
const raw = (env.REBASE_RATE_LIMIT_STORE ?? "").trim().toLowerCase();
|
|
9111
|
-
if (raw === "") return "memory";
|
|
9112
|
-
if (raw === "memory" || raw === "sql") return raw;
|
|
9113
|
-
throw new RateLimitStoreConfigurationError(`REBASE_RATE_LIMIT_STORE="${raw}" is not a rate-limit store.`, "Use \"sql\" to share counts across processes, or \"memory\" (the default) to keep them in this process. A deployment running more than one replica or more than one role wants \"sql\" — otherwise each process enforces the whole limit by itself.");
|
|
9114
|
-
}
|
|
9115
|
-
//#endregion
|
|
9116
7614
|
//#region src/auth/oauth-code-flow.ts
|
|
9117
7615
|
/**
|
|
9118
7616
|
* Build the request schema for an authorization-code provider.
|
|
@@ -10260,6 +8758,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
10260
8758
|
verifyPassword: () => verifyPassword
|
|
10261
8759
|
});
|
|
10262
8760
|
//#endregion
|
|
10263
|
-
export {
|
|
8761
|
+
export { getEmailChangeTemplate as $, createDataRateLimiter as A, SMTPEmailService as B, createJwksRoutes as C, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as D, isBootstrapWindowOpen as E, clearActiveDevEmailSink as F, verifyPassword as G, resolveAuthHooks as H, createDevEmailSink as I, completeUserCreation as J, adapterRefusal as K, devEmailSinkFor as L, setSharedRateLimitStore as M, MemoryRateLimitStore as N, createAuthLimiters as O, activeDevEmailSink as P, getEmailChangeNoticeTemplate as Q, extractLinks as R, createApiKeyStore as S, configureUserPicking as T, hashPassword as U, createEmailService as V, validatePasswordStrength as W, assertEmailLinkBases as X, generateSecurePassword as Y, resolveEmailLinkBase as Z, oauthCodeFlowSchema as _, createBitbucketProvider as a, getWelcomeEmailTemplate as at, createApiKeyRoutes as b, createTwitterProvider as c, escapeHtml as ct, createMicrosoftProvider as d, createSqlRateLimitStore as dt, getEmailOtpTemplate as et, tryVerifyOidcIdToken as f, resolveRateLimitStoreKind as ft, createGoogleProvider as g, createLinkedinProvider as h, createSlackProvider as i, getUserInvitationTemplate as it, createRateLimiter as j, createDataRateLimitCheck as k, createFacebookProvider as l, html as lt, createGitHubProvider as m, createCustomAuthAdapter as n, getMagicLinkTemplate as nt, createGitLabProvider as o, resolveEmailBranding as ot, verifyOidcIdToken as p, securityRuleToConditions as pt, createAdapterAuthMiddleware as q, createSpotifyProvider as r, getPasswordResetTemplate as rt, createDiscordProvider as s, RawHtml as st, auth_exports as t, getEmailVerificationTemplate as tt, createAppleProvider as u, raw as ut, pkceTokenParams as v, createBuiltinAuthAdapter as w, createPersonalKeyRoutes as x, providerVerifiedEmail as y, registerDevEmailSink as z };
|
|
10264
8762
|
|
|
10265
|
-
//# sourceMappingURL=auth-
|
|
8763
|
+
//# sourceMappingURL=auth-izI7AEzC.js.map
|