@agilesyndrome/cf-genai-base 2.0.1 → 2.1.2
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/CONTRACT.md +6 -4
- package/README.md +21 -6
- package/migrations/0004_tenants.sql +0 -6
- package/migrations/0005_subscription_entitlements.sql +13 -0
- package/package.json +1 -1
- package/src/authorization.js +80 -5
- package/src/data.js +79 -14
- package/src/index.js +22 -4
package/CONTRACT.md
CHANGED
|
@@ -18,7 +18,7 @@ is optional and must use `ctx.waitUntil` for background work.
|
|
|
18
18
|
- `scopes` registers an application scope manifest. `scopeRoutes` associates route prefixes or match functions with required scopes.
|
|
19
19
|
- `adminPage` optionally renders the authorized platform admin browser pages so a site can keep the shared system menu and its own visual shell consistent.
|
|
20
20
|
- `siteAdminPage` optionally renders site-owned pages below `/admin/site/*`, keeping them separate from the reserved platform page paths.
|
|
21
|
-
- Base provides `/api/admin/users`, `/api/admin/scopes`, `/api/admin/groups`, `/api/admin/status`, `/api/admin/features`, `/api/admin/healthchecks`, `/api/admin/circuit-breakers`, and `/api/admin/users/:id/scopes|groups` for platform administrators when the authorization and core migrations are installed. `GET /api/admin/features` returns the installed runtime feature manifests, package names and versions, per-feature health rollups, healthchecks, and circuit breakers. The browser route
|
|
21
|
+
- Base provides `/api/admin/users`, `/api/admin/scopes`, `/api/admin/groups`, `/api/admin/status`, `/api/admin/features`, `/api/admin/healthchecks`, `/api/admin/circuit-breakers`, and `/api/admin/users/:id/scopes|groups` for platform administrators when the authorization and core migrations are installed. It also provides short-lived `/api/admin/users/:id/impersonate` and `/api/admin/impersonate/clear` controls. `GET /api/tenant` returns the authenticated active tenant and validated memberships; invalid `X-Tenant-ID` values return 400. `GET /api/admin/features` returns the installed runtime feature manifests, package names and versions, per-feature health rollups, healthchecks, and circuit breakers. The browser route `/admin/features` renders that catalog. Feature manifests may provide `name`, `displayName`, `packageName`, and `version`. The exported UI includes users, scopes, groups, healthchecks, and circuit-breaker catalogs.
|
|
22
22
|
- Public APIs must be explicitly listed in provider-specific auth configuration.
|
|
23
23
|
- Mutating `/api/*` requests require a same-origin `Origin` header.
|
|
24
24
|
|
|
@@ -61,9 +61,11 @@ must remain in the application router rather than in the shared auth package.
|
|
|
61
61
|
manifest through `feature.dataResources`. Each resource must declare a safe
|
|
62
62
|
name, table, explicit columns, and one scope: `user`, `tenant`, or `system`.
|
|
63
63
|
Resources may also declare allowed operations (`read`, `create`, `update`, and
|
|
64
|
-
`delete`); reads support bounded
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
`delete`); reads support bounded native pagination through
|
|
65
|
+
`reader.page({ limit, offset })` and `reader.count()`, plus safe filtered
|
|
66
|
+
updates and deletes. Anonymous tenant reads require an explicit worker
|
|
67
|
+
`publicTenantId` and a resource-level `publicRead` declaration; object form
|
|
68
|
+
adds a row visibility predicate.
|
|
67
69
|
Request handlers receive `state.data`, whose scope-specific readers apply the
|
|
68
70
|
validated user or tenant predicate. Domain handlers must not use unrestricted
|
|
69
71
|
`env.DB` for registered resources. Base cannot provide row-level security to
|
package/README.md
CHANGED
|
@@ -78,12 +78,27 @@ Features may register D1 resources with `dataResources` and receive the
|
|
|
78
78
|
scoped reader on the request state as `state.data`. Resources declare `user`,
|
|
79
79
|
`tenant`, or `system` scope, their physical table, and an explicit column
|
|
80
80
|
allowlist. Use `state.data.tenant`, `state.data.user`, or `state.data.system`;
|
|
81
|
-
the reader applies ownership predicates, supports bounded
|
|
82
|
-
|
|
83
|
-
operations
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
the reader applies ownership predicates, supports bounded native pagination via
|
|
82
|
+
page with limit/offset, count, and safe bulk updateWhere/deleteWhere
|
|
83
|
+
operations, and never accepts raw SQL. Resources can explicitly restrict
|
|
84
|
+
their operations to read, create, update, and delete.
|
|
85
|
+
|
|
86
|
+
Anonymous tenant reads require both publicTenantId on createWorker and a
|
|
87
|
+
resource-level publicRead declaration. Use publicRead true only when the
|
|
88
|
+
whole resource is public; for opt-in rows use a publicRead column/value
|
|
89
|
+
declaration such as visibility=public. Anonymous reads never grant anonymous
|
|
90
|
+
system access.
|
|
91
|
+
|
|
92
|
+
Applications may pass subscriptionManifest to createWorker to register their
|
|
93
|
+
own subscription IDs and entitlement values. Base exposes
|
|
94
|
+
requireSubscription and requireEntitlement but does not know any
|
|
95
|
+
product-specific subscription such as VIP. Authenticated users can inspect
|
|
96
|
+
their validated active tenant at GET /api/tenant.
|
|
97
|
+
|
|
98
|
+
Administrators can start a short-lived, HttpOnly impersonation session with
|
|
99
|
+
POST /api/admin/users/:id/impersonate and clear it with
|
|
100
|
+
POST /api/admin/impersonate/clear. Impersonation affects scoped data context
|
|
101
|
+
only and does not grant the target user administrator permissions.
|
|
87
102
|
|
|
88
103
|
For example, a tenant-owned resource registers its `tenant_id` column with
|
|
89
104
|
base, while feature code calls `state.data.tenant.list("recipes")` without
|
|
@@ -35,11 +35,5 @@ CREATE INDEX IF NOT EXISTS auth_user_tenants_tenant_idx
|
|
|
35
35
|
INSERT OR IGNORE INTO auth_tenants (id, name)
|
|
36
36
|
VALUES ('easley-family', 'Easley Family');
|
|
37
37
|
|
|
38
|
-
INSERT OR IGNORE INTO auth_subscriptions (id, name)
|
|
39
|
-
VALUES ('vip', 'VIP');
|
|
40
|
-
|
|
41
|
-
INSERT OR IGNORE INTO auth_tenant_subscriptions (tenant_id, subscription_id)
|
|
42
|
-
VALUES ('easley-family', 'vip');
|
|
43
|
-
|
|
44
38
|
INSERT OR IGNORE INTO auth_user_tenants (user_id, tenant_id)
|
|
45
39
|
SELECT id, 'easley-family' FROM auth_users;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Application-defined subscription capabilities. Base stores the contract;
|
|
2
|
+
-- applications decide which subscriptions and entitlement keys they expose.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS auth_subscription_entitlements (
|
|
4
|
+
subscription_id TEXT NOT NULL REFERENCES auth_subscriptions(id) ON DELETE CASCADE,
|
|
5
|
+
entitlement TEXT NOT NULL,
|
|
6
|
+
value_json TEXT NOT NULL DEFAULT 'true',
|
|
7
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
8
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
9
|
+
PRIMARY KEY (subscription_id, entitlement)
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE INDEX IF NOT EXISTS auth_subscription_entitlements_key_idx
|
|
13
|
+
ON auth_subscription_entitlements(entitlement);
|
package/package.json
CHANGED
package/src/authorization.js
CHANGED
|
@@ -5,8 +5,10 @@ export const AUTH_SCOPE_TABLE = "auth_scopes";
|
|
|
5
5
|
export const AUTH_GRANT_TABLE = "auth_user_scopes";
|
|
6
6
|
export const DEFAULT_TENANT_ID = "easley-family";
|
|
7
7
|
export const DEFAULT_TENANT_NAME = "Easley Family";
|
|
8
|
-
|
|
9
|
-
export
|
|
8
|
+
|
|
9
|
+
export class SubscriptionError extends Error {
|
|
10
|
+
constructor(message) { super(message); this.name = "SubscriptionError"; }
|
|
11
|
+
}
|
|
10
12
|
|
|
11
13
|
export function normalizeScopes(scopes = []) {
|
|
12
14
|
return scopes.map((scope) => typeof scope === "string" ? { name: scope, label: scope, description: "", system: false } : scope)
|
|
@@ -44,8 +46,6 @@ export async function ensureUser(env, user, { who = "system:read" } = {}) {
|
|
|
44
46
|
async function ensureDefaultTenantMembership(db, userId) {
|
|
45
47
|
await db.batch([
|
|
46
48
|
db.prepare("INSERT OR IGNORE INTO auth_tenants (id,name) VALUES (?,?)").bind(DEFAULT_TENANT_ID, DEFAULT_TENANT_NAME),
|
|
47
|
-
db.prepare("INSERT OR IGNORE INTO auth_subscriptions (id,name) VALUES (?,?)").bind(DEFAULT_SUBSCRIPTION_ID, DEFAULT_SUBSCRIPTION_NAME),
|
|
48
|
-
db.prepare("INSERT OR IGNORE INTO auth_tenant_subscriptions (tenant_id,subscription_id) VALUES (?,?)").bind(DEFAULT_TENANT_ID, DEFAULT_SUBSCRIPTION_ID),
|
|
49
49
|
db.prepare("INSERT OR IGNORE INTO auth_user_tenants (user_id,tenant_id) VALUES (?,?)").bind(userId, DEFAULT_TENANT_ID)
|
|
50
50
|
]);
|
|
51
51
|
}
|
|
@@ -59,7 +59,70 @@ export async function listUserTenants(env, userId, { who = "system:read" } = {})
|
|
|
59
59
|
export async function listTenantSubscriptions(env, tenantId, { who = "system:read" } = {}) {
|
|
60
60
|
const db = createD1(env, { who });
|
|
61
61
|
const { results } = await db.prepare(`SELECT s.id,s.name,s.created_at,s.updated_at FROM auth_subscriptions s JOIN auth_tenant_subscriptions ts ON ts.subscription_id=s.id WHERE ts.tenant_id=? ORDER BY s.name COLLATE NOCASE`).bind(tenantId).all();
|
|
62
|
-
return results || [];
|
|
62
|
+
return Promise.all((results || []).map(async (subscription) => ({ ...subscription, entitlements: await listSubscriptionEntitlements(env, subscription.id, { who }) })));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function listSubscriptionEntitlements(env, subscriptionId, { who = "system:read" } = {}) {
|
|
66
|
+
const db = createD1(env, { who });
|
|
67
|
+
const { results } = await db.prepare("SELECT entitlement,value_json FROM auth_subscription_entitlements WHERE subscription_id=? ORDER BY entitlement").bind(subscriptionId).all();
|
|
68
|
+
return (results || []).map((row) => ({ entitlement: row.entitlement, value: parseJsonValue(row.value_json) }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function normalizeSubscriptionManifest(manifest = []) {
|
|
72
|
+
return manifest.map((subscription) => ({
|
|
73
|
+
id: String(subscription?.id || "").trim(),
|
|
74
|
+
name: String(subscription?.name || subscription?.id || "").trim(),
|
|
75
|
+
entitlements: Object.fromEntries(Object.entries(subscription?.entitlements || {}).map(([key, value]) => [String(key), value])),
|
|
76
|
+
})).filter((subscription) => /^[a-z0-9][a-z0-9_-]*$/.test(subscription.id) && subscription.name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function ensureSubscriptionManifest(env, manifest = [], { who = "system:update" } = {}) {
|
|
80
|
+
if (!env?.DB) return;
|
|
81
|
+
const db = createD1(env, { who });
|
|
82
|
+
for (const subscription of normalizeSubscriptionManifest(manifest)) {
|
|
83
|
+
await db.prepare("INSERT INTO auth_subscriptions (id,name) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,updated_at=CURRENT_TIMESTAMP").bind(subscription.id, subscription.name).run();
|
|
84
|
+
for (const [entitlement, value] of Object.entries(subscription.entitlements)) {
|
|
85
|
+
await db.prepare("INSERT INTO auth_subscription_entitlements (subscription_id,entitlement,value_json) VALUES (?,?,?) ON CONFLICT(subscription_id,entitlement) DO UPDATE SET value_json=excluded.value_json,updated_at=CURRENT_TIMESTAMP").bind(subscription.id, entitlement, JSON.stringify(value)).run();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function hasSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) {
|
|
91
|
+
const db = createD1(env, { who });
|
|
92
|
+
return Boolean(await db.prepare("SELECT 1 FROM auth_tenant_subscriptions WHERE tenant_id=? AND subscription_id=?").bind(tenantId, subscriptionId).first());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function requireSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) {
|
|
96
|
+
if (!await hasSubscription(env, tenantId, subscriptionId, { who })) throw new SubscriptionError("Required subscription is not active for this tenant.");
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function hasEntitlement(env, tenantId, entitlement, expectedValue, { who = "system:read" } = {}) {
|
|
101
|
+
const db = createD1(env, { who });
|
|
102
|
+
const rows = await db.prepare("SELECT e.value_json FROM auth_subscription_entitlements e JOIN auth_tenant_subscriptions ts ON ts.subscription_id=e.subscription_id WHERE ts.tenant_id=? AND e.entitlement=?").bind(tenantId, entitlement).all();
|
|
103
|
+
return (rows.results || []).some((row) => expectedValue === undefined || deepEqual(parseJsonValue(row.value_json), expectedValue));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function requireEntitlement(env, tenantId, entitlement, expectedValue, { who = "system:read" } = {}) {
|
|
107
|
+
if (!await hasEntitlement(env, tenantId, entitlement, expectedValue, { who })) throw new SubscriptionError(`Required entitlement is not active: ${entitlement}.`);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function createImpersonationToken(env, adminUserId, targetUserId, { ttlSeconds = 900 } = {}) {
|
|
112
|
+
if (!env?.AUTH_SESSION_SECRET) throw new Error("AUTH_SESSION_SECRET is required for impersonation.");
|
|
113
|
+
const payload = { adminUserId: String(adminUserId || "admin"), targetUserId: String(targetUserId), exp: Math.floor(Date.now() / 1000) + Math.min(Math.max(Number(ttlSeconds) || 900, 60), 3600) };
|
|
114
|
+
const encoded = base64url(new TextEncoder().encode(JSON.stringify(payload)));
|
|
115
|
+
return `${encoded}.${await signValue(encoded, env?.AUTH_SESSION_SECRET || "")}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function verifyImpersonationToken(token, env) {
|
|
119
|
+
if (!env?.AUTH_SESSION_SECRET) return null;
|
|
120
|
+
const [encoded, signature] = String(token || "").split(".");
|
|
121
|
+
if (!encoded || !signature || !constantTimeEqual(signature, await signValue(encoded, env?.AUTH_SESSION_SECRET || ""))) return null;
|
|
122
|
+
try {
|
|
123
|
+
const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(encoded)));
|
|
124
|
+
return payload.exp > Date.now() / 1000 && payload.targetUserId ? payload : null;
|
|
125
|
+
} catch { return null; }
|
|
63
126
|
}
|
|
64
127
|
|
|
65
128
|
export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
|
|
@@ -77,6 +140,11 @@ export async function listAuthorizationUsers(env, { who = "system:read" } = {})
|
|
|
77
140
|
return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name) })));
|
|
78
141
|
}
|
|
79
142
|
|
|
143
|
+
export async function getAuthorizationUser(env, userId, { who = "system:read" } = {}) {
|
|
144
|
+
const db = createD1(env, { who });
|
|
145
|
+
return db.prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(userId).first();
|
|
146
|
+
}
|
|
147
|
+
|
|
80
148
|
export async function listAuthorizationScopes(env, { who = "system:read" } = {}) {
|
|
81
149
|
const db = createD1(env, { who });
|
|
82
150
|
const { results } = await db.prepare(`SELECT name,label,description,system FROM ${AUTH_SCOPE_TABLE} ORDER BY name`).all();
|
|
@@ -110,3 +178,10 @@ async function stableId(value) {
|
|
|
110
178
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
111
179
|
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 32);
|
|
112
180
|
}
|
|
181
|
+
|
|
182
|
+
function parseJsonValue(value) { try { return JSON.parse(value); } catch { return value; } }
|
|
183
|
+
function deepEqual(left, right) { return JSON.stringify(left) === JSON.stringify(right); }
|
|
184
|
+
async function signValue(value, secret) { const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(String(secret)), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); return base64url(new Uint8Array(signature)); }
|
|
185
|
+
function base64url(bytes) { return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); }
|
|
186
|
+
function base64urlDecode(value) { const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4); return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); }
|
|
187
|
+
function constantTimeEqual(left, right) { const a = new TextEncoder().encode(String(left)), b = new TextEncoder().encode(String(right)); let result = a.length ^ b.length; for (let index = 0; index < Math.max(a.length, b.length); index += 1) result |= (a[index] || 0) ^ (b[index] || 0); return result === 0; }
|
package/src/data.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { auditLog, createD1 } from "./core.js";
|
|
2
|
-
import { ensureUser, listUserTenants } from "./authorization.js";
|
|
2
|
+
import { ensureUser, getAuthorizationUser, listUserGrants, listUserTenants, verifyImpersonationToken } from "./authorization.js";
|
|
3
3
|
|
|
4
4
|
export const DATA_SCOPES = ["user", "tenant", "system"];
|
|
5
5
|
export const DATA_OPERATIONS = ["read", "create", "update", "delete"];
|
|
@@ -34,9 +34,13 @@ export function normalizeDataResources(resources = []) {
|
|
|
34
34
|
for (const column of [...filterableColumns, ...orderableColumns]) if (!columns.includes(column)) throw new TypeError(`Data resource ${resource.name} references an unselected column`);
|
|
35
35
|
const writableColumns = [...new Set((resource.writableColumns || []).map(String))].filter((column) => column !== ownerColumn && column !== tenantColumn);
|
|
36
36
|
if (writableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unwritable column`);
|
|
37
|
+
const columnScopes = Object.fromEntries(Object.entries(resource.columnScopes || {}).map(([column, scopes]) => [String(column), Array.isArray(scopes) ? scopes.map(String) : [String(scopes)]]));
|
|
38
|
+
if (Object.keys(columnScopes).some((column) => !columns.includes(column) || columnScopes[column].some((scope) => !/^[a-z0-9]+(?::[a-z0-9-]+)+$/.test(scope)))) throw new TypeError(`Data resource ${resource.name} has invalid column scopes`);
|
|
37
39
|
const operations = [...new Set((resource.operations || ["read", ...(writableColumns.length ? ["create", "update", "delete"] : [])]).map((operation) => String(operation).toLowerCase()))];
|
|
38
40
|
if (!operations.length || operations.some((operation) => !DATA_OPERATIONS.includes(operation)) || !operations.includes("read")) throw new TypeError(`Data resource ${name} has invalid operations`);
|
|
39
|
-
|
|
41
|
+
const publicRead = resource.publicRead && typeof resource.publicRead === "object" ? { column: String(resource.publicRead.column || ""), value: resource.publicRead.value } : Boolean(resource.publicRead);
|
|
42
|
+
if (publicRead && typeof publicRead === "object" && (!/^[a-z][a-z0-9_]*$/.test(publicRead.column) || !columns.includes(publicRead.column))) throw new TypeError(`Public resource ${resource.name} requires a selected visibility column`);
|
|
43
|
+
return { ...resource, name, table: String(resource.table), scope, columns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, columnScopes, operations, publicRead };
|
|
40
44
|
});
|
|
41
45
|
}
|
|
42
46
|
|
|
@@ -46,17 +50,20 @@ export function createDataReader(env, { resources = [], context } = {}) {
|
|
|
46
50
|
const scope = (requestedScope) => ({
|
|
47
51
|
list: (name, options) => readList(name, requestedScope, options),
|
|
48
52
|
page: (name, options) => readPage(name, requestedScope, options),
|
|
53
|
+
count: (name, options) => readCount(name, requestedScope, options),
|
|
49
54
|
get: async (name, id) => (await readList(name, requestedScope, { where: { [registry.get(name)?.idColumn || "id"]: id }, limit: 1 }))[0] || null,
|
|
50
55
|
insert: (name, values) => writeInsert(name, requestedScope, values),
|
|
51
56
|
update: (name, id, changes) => writeUpdate(name, requestedScope, id, changes),
|
|
57
|
+
updateWhere: (name, where, changes) => writeUpdateWhere(name, requestedScope, where, changes),
|
|
52
58
|
delete: (name, id) => writeDelete(name, requestedScope, id),
|
|
59
|
+
deleteWhere: (name, where) => writeDeleteWhere(name, requestedScope, where),
|
|
53
60
|
});
|
|
54
61
|
|
|
55
|
-
async function readList(name, requestedScope, { where = {}, limit = 100, orderBy } = {}) {
|
|
56
|
-
return (await readPage(name, requestedScope, { where, limit, orderBy })).rows;
|
|
62
|
+
async function readList(name, requestedScope, { where = {}, limit = 100, orderBy, offset = 0 } = {}) {
|
|
63
|
+
return (await readPage(name, requestedScope, { where, limit, orderBy, offset })).rows;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
async function readPage(name, requestedScope, { where = {}, limit = 100, orderBy, cursor } = {}) {
|
|
66
|
+
async function readPage(name, requestedScope, { where = {}, limit = 100, orderBy, cursor, offset = 0 } = {}) {
|
|
60
67
|
const resource = getResource(name);
|
|
61
68
|
const actor = await getContext();
|
|
62
69
|
if (!isAllowed(resource, requestedScope, actor, "read")) return { rows: [], nextCursor: null };
|
|
@@ -66,7 +73,8 @@ export function createDataReader(env, { resources = [], context } = {}) {
|
|
|
66
73
|
addFilters(resource, where, predicates, bindings);
|
|
67
74
|
if (cursor !== undefined && cursor !== null) { predicates.push(`${quote(resource.idColumn)}>?`); bindings.push(cursor); }
|
|
68
75
|
const safeLimit = Math.min(Math.max(Number(limit) || 100, 1), 1000);
|
|
69
|
-
|
|
76
|
+
const safeOffset = Math.max(Number(offset) || 0, 0);
|
|
77
|
+
let sql = `SELECT ${resource.columns.map(quote).join(",")} FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
|
|
70
78
|
if (orderBy) {
|
|
71
79
|
const [column, direction = "ASC"] = String(orderBy).split(/\s+/, 2);
|
|
72
80
|
if (!resource.orderableColumns.includes(column)) throw new TypeError(`Column ${column} cannot order ${resource.name}`);
|
|
@@ -77,16 +85,30 @@ export function createDataReader(env, { resources = [], context } = {}) {
|
|
|
77
85
|
return { rows, nextCursor: rows.length === safeLimit ? rows[rows.length - 1][resource.idColumn] : null };
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
async function readCount(name, requestedScope, { where = {} } = {}) {
|
|
89
|
+
const resource = getResource(name);
|
|
90
|
+
const actor = await getContext();
|
|
91
|
+
if (!isAllowed(resource, requestedScope, actor, "read")) return 0;
|
|
92
|
+
const predicates = [];
|
|
93
|
+
const bindings = [];
|
|
94
|
+
addScopePredicate(resource, requestedScope, actor, predicates, bindings);
|
|
95
|
+
addFilters(resource, where, predicates, bindings);
|
|
96
|
+
const result = await createD1(env, { who: actorLabel(actor) }).prepare(`SELECT COUNT(*) AS count FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""}`).bind(...bindings).first();
|
|
97
|
+
return Number(result?.count || 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
80
100
|
async function writeInsert(name, requestedScope, values = {}) {
|
|
81
101
|
const resource = getResource(name);
|
|
82
102
|
const actor = await getContext();
|
|
83
103
|
assertWritable(resource, requestedScope, actor, "create");
|
|
84
104
|
const data = cleanWritableValues(resource, values, { includeId: true, includeOwnership: requestedScope === "system" });
|
|
105
|
+
assertColumnScopes(resource, actor, Object.keys(data));
|
|
85
106
|
addOwnedValue(resource, requestedScope, actor, data);
|
|
86
107
|
const columns = Object.keys(data);
|
|
87
108
|
if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
|
|
88
|
-
await createD1(env, { who: actorLabel(actor) }).prepare(`INSERT INTO ${quote(resource.table)} (${columns.map(quote).join(",")}) VALUES (${columns.map(() => "?").join(",")})`).bind(...columns.map((column) => data[column])).run();
|
|
89
|
-
|
|
109
|
+
const result = await createD1(env, { who: actorLabel(actor) }).prepare(`INSERT INTO ${quote(resource.table)} (${columns.map(quote).join(",")}) VALUES (${columns.map(() => "?").join(",")})`).bind(...columns.map((column) => data[column])).run();
|
|
110
|
+
const insertedId = data[resource.idColumn] === undefined ? result?.meta?.last_row_id : data[resource.idColumn];
|
|
111
|
+
return insertedId === undefined ? data : (await readList(name, requestedScope, { where: { [resource.idColumn]: insertedId }, limit: 1 }))[0] || { ...data, [resource.idColumn]: insertedId };
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
async function writeUpdate(name, requestedScope, id, changes = {}) {
|
|
@@ -94,6 +116,7 @@ export function createDataReader(env, { resources = [], context } = {}) {
|
|
|
94
116
|
const actor = await getContext();
|
|
95
117
|
assertWritable(resource, requestedScope, actor, "update");
|
|
96
118
|
const data = cleanWritableValues(resource, changes, { includeOwnership: requestedScope === "system" });
|
|
119
|
+
assertColumnScopes(resource, actor, Object.keys(data));
|
|
97
120
|
const columns = Object.keys(data);
|
|
98
121
|
if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
|
|
99
122
|
const predicates = [`${quote(resource.idColumn)}=?`];
|
|
@@ -113,27 +136,60 @@ export function createDataReader(env, { resources = [], context } = {}) {
|
|
|
113
136
|
return createD1(env, { who: actorLabel(actor) }).prepare(`DELETE FROM ${quote(resource.table)} WHERE ${predicates.join(" AND ")}`).bind(...bindings).run();
|
|
114
137
|
}
|
|
115
138
|
|
|
139
|
+
async function writeUpdateWhere(name, requestedScope, where, changes) {
|
|
140
|
+
const resource = getResource(name);
|
|
141
|
+
const actor = await getContext();
|
|
142
|
+
assertWritable(resource, requestedScope, actor, "update");
|
|
143
|
+
const data = cleanWritableValues(resource, changes, { includeOwnership: requestedScope === "system" });
|
|
144
|
+
assertColumnScopes(resource, actor, Object.keys(data));
|
|
145
|
+
const columns = Object.keys(data);
|
|
146
|
+
if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
|
|
147
|
+
const predicates = [];
|
|
148
|
+
const bindings = [];
|
|
149
|
+
addScopePredicate(resource, requestedScope, actor, predicates, bindings);
|
|
150
|
+
addFilters(resource, where, predicates, bindings);
|
|
151
|
+
await createD1(env, { who: actorLabel(actor) }).prepare(`UPDATE ${quote(resource.table)} SET ${columns.map((column) => `${quote(column)}=?`).join(",")} WHERE ${predicates.join(" AND ")}`).bind(...columns.map((column) => data[column]), ...bindings).run();
|
|
152
|
+
return readCount(name, requestedScope, { where });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function writeDeleteWhere(name, requestedScope, where) {
|
|
156
|
+
const resource = getResource(name);
|
|
157
|
+
const actor = await getContext();
|
|
158
|
+
assertWritable(resource, requestedScope, actor, "delete");
|
|
159
|
+
const predicates = [];
|
|
160
|
+
const bindings = [];
|
|
161
|
+
addScopePredicate(resource, requestedScope, actor, predicates, bindings);
|
|
162
|
+
addFilters(resource, where, predicates, bindings);
|
|
163
|
+
return createD1(env, { who: actorLabel(actor) }).prepare(`DELETE FROM ${quote(resource.table)} WHERE ${predicates.join(" AND ")}`).bind(...bindings).run();
|
|
164
|
+
}
|
|
165
|
+
|
|
116
166
|
function getResource(name) {
|
|
117
167
|
const resource = registry.get(String(name));
|
|
118
168
|
if (!resource) throw new TypeError(`Unknown data resource: ${name}`);
|
|
119
169
|
return resource;
|
|
120
170
|
}
|
|
121
171
|
|
|
122
|
-
return { user: scope("user"), tenant: scope("tenant"), system: scope("system"), resources: [...registry.values()] };
|
|
172
|
+
return { user: scope("user"), tenant: scope("tenant"), system: scope("system"), context: getContext, resources: [...registry.values()] };
|
|
123
173
|
}
|
|
124
174
|
|
|
125
175
|
export async function requestDataContext(env, { state = {}, request, publicTenantId = null } = {}) {
|
|
126
|
-
|
|
176
|
+
let authUser = state.authUser || (state.user ? await ensureUser(env, state.user, { who: `user:${state.user.sub || "unknown"}` }) : null);
|
|
127
177
|
const system = Boolean(state.user?.auth_strategy === "http_basic" || (authUser && authUser.is_admin));
|
|
128
178
|
if (!authUser) return { userId: null, tenantId: publicTenantId, public: Boolean(publicTenantId), system: false };
|
|
129
|
-
const
|
|
179
|
+
const impersonation = system ? await verifyImpersonationToken(request?.headers?.get("X-CF-GenAI-Impersonation") || readCookie(request, "__Host-cfgenai_impersonation"), env) : null;
|
|
180
|
+
if (impersonation) {
|
|
181
|
+
const targetUser = await getAuthorizationUser(env, impersonation.targetUserId, { who: `user:${impersonation.adminUserId}` });
|
|
182
|
+
if (targetUser) authUser = targetUser;
|
|
183
|
+
}
|
|
184
|
+
const who = `user:${authUser.id}`;
|
|
185
|
+
const [tenants, grants] = await Promise.all([listUserTenants(env, authUser.id, { who }), listUserGrants(env, authUser.id, { who })]);
|
|
130
186
|
const requestedTenant = state.tenantId || request?.headers?.get("X-Tenant-ID") || null;
|
|
131
187
|
const tenant = requestedTenant ? tenants.find((item) => item.id === requestedTenant) : tenants.length === 1 ? tenants[0] : null;
|
|
132
|
-
return { userId: authUser.id, tenantId: tenant?.id || null, public: false, system, tenants };
|
|
188
|
+
return { userId: authUser.id, tenantId: tenant?.id || null, public: false, system, scopes: grants.map((grant) => grant.scope_name), tenants, invalidTenant: Boolean(requestedTenant && !tenant), impersonated: Boolean(impersonation), impersonatedBy: impersonation?.adminUserId || null };
|
|
133
189
|
}
|
|
134
190
|
|
|
135
191
|
function isAllowed(resource, requestedScope, actor, operation) {
|
|
136
|
-
|
|
192
|
+
const allowed = requestedScope === "system" ? Boolean(actor.system) : requestedScope === resource.scope && (requestedScope === "user" ? Boolean(actor.userId) : Boolean(actor.tenantId && (actor.userId || (actor.public && resource.publicRead))));
|
|
137
193
|
if (!allowed || !resource.operations.includes(operation)) {
|
|
138
194
|
auditLog({ who: actorLabel(actor), operation: "deny", resource: `data:${resource.name}:${requestedScope}:${operation}` });
|
|
139
195
|
return false;
|
|
@@ -145,9 +201,17 @@ function assertWritable(resource, requestedScope, actor, operation) {
|
|
|
145
201
|
if (!isAllowed(resource, requestedScope, actor, operation)) throw new DataScopeError(`Data scope ${requestedScope} cannot ${operation} resource ${resource.name}`);
|
|
146
202
|
}
|
|
147
203
|
|
|
204
|
+
function assertColumnScopes(resource, actor, columns) {
|
|
205
|
+
if (actor.system) return;
|
|
206
|
+
for (const column of columns) {
|
|
207
|
+
const required = resource.columnScopes[column] || [];
|
|
208
|
+
if (required.some((scope) => !actor.scopes?.includes(scope))) throw new DataScopeError(`Missing scope to write ${resource.name}.${column}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
148
212
|
function addScopePredicate(resource, requestedScope, actor, predicates, bindings) {
|
|
149
213
|
if (requestedScope === "user") { predicates.push(`${quote(resource.ownerColumn)}=?`); bindings.push(actor.userId); }
|
|
150
|
-
if (requestedScope === "tenant") { predicates.push(`${quote(resource.tenantColumn)}=?`); bindings.push(actor.tenantId); }
|
|
214
|
+
if (requestedScope === "tenant") { predicates.push(`${quote(resource.tenantColumn)}=?`); bindings.push(actor.tenantId); if (actor.public && resource.publicRead && typeof resource.publicRead === "object") { predicates.push(`${quote(resource.publicRead.column)}=?`); bindings.push(resource.publicRead.value); } }
|
|
151
215
|
}
|
|
152
216
|
|
|
153
217
|
function addFilters(resource, where, predicates, bindings) {
|
|
@@ -169,3 +233,4 @@ function addOwnedValue(resource, requestedScope, actor, data) {
|
|
|
169
233
|
|
|
170
234
|
function actorLabel(actor) { return actor.system ? "system:data" : `user:${actor.userId || "unknown"}`; }
|
|
171
235
|
function quote(identifier) { return `"${identifier}"`; }
|
|
236
|
+
function readCookie(request, name) { const value = request?.headers?.get("Cookie") || ""; return value.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1) || ""; }
|
package/src/index.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Lean, opinionated Worker composition for Cloudflare sites.
|
|
3
3
|
* Site code owns domain routes and data; this owns lifecycle and edge concerns.
|
|
4
4
|
*/
|
|
5
|
-
import { ensureScopes, ensureUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listGroups, listUserGroups, listUserGrants, replaceUserGroups, replaceUserGrants } from "./authorization.js";
|
|
5
|
+
import { createImpersonationToken, ensureScopes, ensureSubscriptionManifest, ensureUser, getAuthorizationUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listGroups, listTenantSubscriptions, listUserGroups, listUserGrants, replaceUserGroups, replaceUserGrants, SubscriptionError } from "./authorization.js";
|
|
6
6
|
import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureCatalog, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
|
|
7
7
|
import { createDataReader, DataScopeError, normalizeDataResources, requestDataContext } from "./data.js";
|
|
8
8
|
export * from "./core.js";
|
|
9
9
|
export * from "./data.js";
|
|
10
|
-
export
|
|
10
|
+
export * from "./authorization.js";
|
|
11
|
+
export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], subscriptionManifest = [], scopeRoutes = [], middleware = [], features = [], dataResources = [], publicTenantId = null, health, boot, metrics, security = true, adminPage, siteAdminPage }) {
|
|
11
12
|
if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
|
|
12
13
|
const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
|
|
13
14
|
const registeredDataResources = normalizeDataResources([...dataResources, ...features.flatMap((feature) => Array.isArray(feature?.dataResources) ? feature.dataResources : [])]);
|
|
@@ -21,8 +22,10 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
|
|
|
21
22
|
async fetch(request, env, ctx) {
|
|
22
23
|
try {
|
|
23
24
|
if (boot) await boot(env, { request, ctx });
|
|
25
|
+
if (subscriptionManifest.length) await ensureSubscriptionManifest(env, subscriptionManifest, { who: "system:update" });
|
|
24
26
|
const url = new URL(request.url);
|
|
25
27
|
const state = Object.create(null);
|
|
28
|
+
if (provider?.getUser) state.user = await provider.getUser(request, env).catch(() => null);
|
|
26
29
|
state.data = createDataReader(env, { resources: registeredDataResources, context: () => requestDataContext(env, { state, request, publicTenantId }) });
|
|
27
30
|
if (env?.DB && features.some((feature) => typeof feature?.healthcheck === "function" || feature?.healthchecks?.length || feature?.healthChecks?.length || feature?.circuitBreakers?.length || feature?.circuit_breakers?.length)) ctx?.waitUntil?.(registerFeatureManifests(env, features, { who: "system:update" }).then(() => listCircuitBreakers(env, { who: "system:update" }).then((breakers) => Promise.all(breakers.filter(Boolean).map((breaker) => evaluateCircuitBreaker(env, breaker.id, { who: "system:update" }))))).catch((error) => console.error("[EventLog] feature manifest registration failed", error)));
|
|
28
31
|
const dispatch = async (index, currentRequest = request) => {
|
|
@@ -33,6 +36,12 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
|
|
|
33
36
|
const featureHealth = env?.DB ? await listFeatureHealth(env, { who: "system:read" }).catch(() => []) : [];
|
|
34
37
|
return healthResponse(env, featureHealth.length ? { ...details, features: featureHealth } : details);
|
|
35
38
|
}
|
|
39
|
+
if (url.pathname === "/api/tenant" && currentRequest.method === "GET") {
|
|
40
|
+
const context = await state.data.context();
|
|
41
|
+
if (!context.userId) return Response.json({ error: "Authentication is required." }, { status: 401 });
|
|
42
|
+
if (context.invalidTenant) return Response.json({ error: "The requested tenant is not available." }, { status: 400 });
|
|
43
|
+
return Response.json({ tenant: context.tenantId ? { id: context.tenantId, name: context.tenants?.find((tenant) => tenant.id === context.tenantId)?.name || null } : null, tenants: context.tenants || [] });
|
|
44
|
+
}
|
|
36
45
|
return fetch(currentRequest, env, ctx, state);
|
|
37
46
|
}
|
|
38
47
|
if (typeof layer !== "function") throw new TypeError("Worker middleware must be a function");
|
|
@@ -43,7 +52,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
|
|
|
43
52
|
return security ? secureResponse(response) : response;
|
|
44
53
|
} catch (error) {
|
|
45
54
|
console.error("[worker] request failed", error);
|
|
46
|
-
if (error instanceof DataScopeError) return secureResponse(Response.json({ error:
|
|
55
|
+
if (error instanceof DataScopeError || error instanceof SubscriptionError) return secureResponse(Response.json({ error: error.message }, { status: 403, headers: { "Cache-Control": "no-store" } }));
|
|
47
56
|
return secureResponse(Response.json({ error: "Internal server error" }, { status: 500, headers: { "Cache-Control": "no-store" } }));
|
|
48
57
|
}
|
|
49
58
|
},
|
|
@@ -108,10 +117,19 @@ function requiredScopeFor(pathname, routes) {
|
|
|
108
117
|
|
|
109
118
|
async function authorizationApi(request, env, url, state, features = []) {
|
|
110
119
|
const grantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/scopes$/);
|
|
111
|
-
const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/groups" || url.pathname.startsWith("/api/admin/users/") || url.pathname === "/api/admin/status" || url.pathname === "/api/admin/features" || url.pathname === "/api/admin/healthchecks" || url.pathname === "/api/admin/circuit-breakers" || url.pathname.startsWith("/api/admin/healthchecks/") || url.pathname.startsWith("/api/admin/circuit-breakers/") || Boolean(grantsMatch);
|
|
120
|
+
const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/groups" || url.pathname.startsWith("/api/admin/users/") || url.pathname.startsWith("/api/admin/impersonate") || url.pathname === "/api/admin/status" || url.pathname === "/api/admin/features" || url.pathname === "/api/admin/healthchecks" || url.pathname === "/api/admin/circuit-breakers" || url.pathname.startsWith("/api/admin/healthchecks/") || url.pathname.startsWith("/api/admin/circuit-breakers/") || Boolean(grantsMatch);
|
|
112
121
|
if (!platformPath) return null;
|
|
113
122
|
if (!(state.user.auth_strategy === "http_basic" || (state.authUser && state.authUser.is_admin))) return Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
123
|
+
if (url.pathname === "/api/admin/impersonate/clear" && request.method === "POST") return new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json; charset=utf-8", "Set-Cookie": "__Host-cfgenai_impersonation=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax" } });
|
|
114
124
|
if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env, { who: requestActor(state) }) });
|
|
125
|
+
const impersonateMatch = url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/impersonate$/);
|
|
126
|
+
if (impersonateMatch && request.method === "POST") {
|
|
127
|
+
const target = decodeURIComponent(impersonateMatch[1]);
|
|
128
|
+
const targetUser = await getAuthorizationUser(env, target, { who: requestActor(state) });
|
|
129
|
+
if (!targetUser) return Response.json({ error: "User not found." }, { status: 404 });
|
|
130
|
+
const token = await createImpersonationToken(env, state.authUser?.id || state.user?.sub || "admin", targetUser.id);
|
|
131
|
+
return new Response(JSON.stringify({ ok: true, user: { id: targetUser.id, email: targetUser.email, display_name: targetUser.display_name }, expires_in: 900 }), { headers: { "content-type": "application/json; charset=utf-8", "Set-Cookie": `__Host-cfgenai_impersonation=${token}; Max-Age=900; Path=/; Secure; HttpOnly; SameSite=Lax` } });
|
|
132
|
+
}
|
|
115
133
|
if (url.pathname === "/api/admin/scopes" && request.method === "GET") return Response.json({ scopes: await listAuthorizationScopes(env, { who: requestActor(state) }) });
|
|
116
134
|
if (url.pathname === "/api/admin/status" && request.method === "GET") return Response.json({ features: await listFeatureHealth(env, { who: requestActor(state) }) });
|
|
117
135
|
if (url.pathname === "/api/admin/features" && request.method === "GET") return Response.json({ features: await listFeatureCatalog(env, features, { who: requestActor(state) }) });
|