@agilesyndrome/cf-genai-base 4.1.2 → 4.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/authorization.js +46 -1
- package/src/index.js +10 -2
- package/src/ui/index.js +3 -3
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ Use the selected D1 target (local by default) to inspect and update users:
|
|
|
70
70
|
cf-genai user get someone.com --target staging
|
|
71
71
|
cf-genai user update someone.com --roles admin --target production
|
|
72
72
|
|
|
73
|
-
`user:get` also reports scopes and
|
|
73
|
+
`user:get` also reports scopes, groups, and tenant memberships. The shared admin user page displays each user’s tenant memberships and lets an administrator attach or detach tenants. The admin API exposes `GET|POST /api/admin/tenants`, `GET|PUT /api/admin/tenants/:id`, and `GET|PUT /api/admin/users/:id/tenants`. Production commands should be run through the repository credentials wrapper and reviewed as an administrative change.
|
|
74
74
|
|
|
75
75
|
## Scoped data access
|
|
76
76
|
|
package/package.json
CHANGED
package/src/authorization.js
CHANGED
|
@@ -138,7 +138,52 @@ export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
|
|
|
138
138
|
export async function listAuthorizationUsers(env, { who = "system:read" } = {}) {
|
|
139
139
|
const db = createD1(env, { who });
|
|
140
140
|
const { results } = await db.prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} ORDER BY email COLLATE NOCASE`).all();
|
|
141
|
-
return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name) })));
|
|
141
|
+
return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name), tenants: await listUserTenants(env, user.id, { who }) })));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function listAuthorizationTenants(env, { who = "system:read" } = {}) {
|
|
145
|
+
const db = createD1(env, { who });
|
|
146
|
+
const { results } = await db.prepare(`SELECT t.id,t.name,t.created_at,t.updated_at,COUNT(ut.user_id) AS user_count FROM auth_tenants t LEFT JOIN auth_user_tenants ut ON ut.tenant_id=t.id GROUP BY t.id ORDER BY t.name COLLATE NOCASE`).all();
|
|
147
|
+
return results || [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function getAuthorizationTenant(env, tenantId, { who = "system:read" } = {}) {
|
|
151
|
+
const db = createD1(env, { who });
|
|
152
|
+
return db.prepare("SELECT id,name,created_at,updated_at FROM auth_tenants WHERE id=?").bind(String(tenantId)).first();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function createAuthorizationTenant(env, tenantId, name, { who = "system:update" } = {}) {
|
|
156
|
+
const id = String(tenantId || "").trim();
|
|
157
|
+
const tenantName = String(name || "").trim();
|
|
158
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(id)) throw new Error("Tenant id must contain lowercase letters, numbers, hyphens, or underscores.");
|
|
159
|
+
if (!tenantName) throw new Error("Tenant name is required.");
|
|
160
|
+
const db = createD1(env, { who });
|
|
161
|
+
await db.prepare("INSERT INTO auth_tenants (id,name) VALUES (?,?)").bind(id, tenantName).run();
|
|
162
|
+
return getAuthorizationTenant(env, id, { who });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function updateAuthorizationTenant(env, tenantId, name, { who = "system:update" } = {}) {
|
|
166
|
+
const tenantName = String(name || "").trim();
|
|
167
|
+
if (!tenantName) throw new Error("Tenant name is required.");
|
|
168
|
+
const db = createD1(env, { who });
|
|
169
|
+
await db.prepare("UPDATE auth_tenants SET name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(tenantName, String(tenantId)).run();
|
|
170
|
+
return getAuthorizationTenant(env, tenantId, { who });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function replaceUserTenants(env, userId, tenantIds, { who = "system:update" } = {}) {
|
|
174
|
+
const db = createD1(env, { who });
|
|
175
|
+
const requested = [...new Set((tenantIds || []).map((id) => String(id).trim()).filter(Boolean))];
|
|
176
|
+
if (requested.length) {
|
|
177
|
+
const placeholders = requested.map(() => "?").join(",");
|
|
178
|
+
const { results } = await db.prepare(`SELECT id FROM auth_tenants WHERE id IN (${placeholders})`).bind(...requested).all();
|
|
179
|
+
const found = new Set((results || []).map((row) => row.id));
|
|
180
|
+
if (found.size !== requested.length) throw new Error("One or more tenants do not exist.");
|
|
181
|
+
}
|
|
182
|
+
await db.batch([
|
|
183
|
+
db.prepare("DELETE FROM auth_user_tenants WHERE user_id=?").bind(userId),
|
|
184
|
+
...requested.map((tenantId) => db.prepare("INSERT INTO auth_user_tenants (user_id,tenant_id) VALUES (?,?)").bind(userId, tenantId)),
|
|
185
|
+
]);
|
|
186
|
+
return listUserTenants(env, userId, { who });
|
|
142
187
|
}
|
|
143
188
|
|
|
144
189
|
export async function getAuthorizationUser(env, userId, { who = "system:read" } = {}) {
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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 { createImpersonationToken, ensureScopes, ensureSubscriptionManifest, ensureUser, getAuthorizationUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listGroups, listTenantSubscriptions, listUserGroups, listUserGrants, replaceUserGroups, replaceUserGrants, SubscriptionError } from "./authorization.js";
|
|
5
|
+
import { createAuthorizationTenant, createImpersonationToken, ensureScopes, ensureSubscriptionManifest, ensureUser, getAuthorizationTenant, getAuthorizationUser, hasScope, listAuthorizationScopes, listAuthorizationTenants, listAuthorizationUsers, listGroups, listTenantSubscriptions, listUserGroups, listUserTenants, listUserGrants, replaceUserGroups, replaceUserTenants, replaceUserGrants, SubscriptionError, updateAuthorizationTenant } 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";
|
|
@@ -152,11 +152,16 @@ function requiredScopeFor(pathname, routes) {
|
|
|
152
152
|
|
|
153
153
|
async function authorizationApi(request, env, url, state, features = []) {
|
|
154
154
|
const grantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/scopes$/);
|
|
155
|
-
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);
|
|
155
|
+
const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/tenants" || url.pathname.startsWith("/api/admin/tenants/") || 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);
|
|
156
156
|
if (!platformPath) return null;
|
|
157
157
|
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" } });
|
|
158
158
|
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" } });
|
|
159
159
|
if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env, { who: requestActor(state) }) });
|
|
160
|
+
if (url.pathname === "/api/admin/tenants" && request.method === "GET") return Response.json({ tenants: await listAuthorizationTenants(env, { who: requestActor(state) }) });
|
|
161
|
+
if (url.pathname === "/api/admin/tenants" && request.method === "POST") { const body = await request.json().catch(() => null); if (!body?.id || !body?.name) return Response.json({ error: "id and name are required" }, { status: 400 }); try { return Response.json({ tenant: await createAuthorizationTenant(env, body.id, body.name, { who: requestActor(state) }) }, { status: 201 }); } catch (error) { return Response.json({ error: error.message }, { status: 400 }); } }
|
|
162
|
+
const tenantMatch = url.pathname.match(/^\/api\/admin\/tenants\/([^/]+)$/);
|
|
163
|
+
if (tenantMatch && request.method === "GET") return Response.json({ tenant: await getAuthorizationTenant(env, decodeURIComponent(tenantMatch[1]), { who: requestActor(state) }) });
|
|
164
|
+
if (tenantMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body?.name) return Response.json({ error: "name is required" }, { status: 400 }); return Response.json({ tenant: await updateAuthorizationTenant(env, decodeURIComponent(tenantMatch[1]), body.name, { who: requestActor(state) }) }); }
|
|
160
165
|
const impersonateMatch = url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/impersonate$/);
|
|
161
166
|
if (impersonateMatch && request.method === "POST") {
|
|
162
167
|
const target = decodeURIComponent(impersonateMatch[1]);
|
|
@@ -179,6 +184,9 @@ async function authorizationApi(request, env, url, state, features = []) {
|
|
|
179
184
|
const groupsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/groups$/);
|
|
180
185
|
if (groupsMatch && request.method === "GET") return Response.json({ groups: await listUserGroups(env, decodeURIComponent(groupsMatch[1]), { who: requestActor(state) }) });
|
|
181
186
|
if (groupsMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.groups)) return Response.json({ error: "groups must be an array" }, { status: 400 }); return Response.json({ groups: await replaceUserGroups(env, decodeURIComponent(groupsMatch[1]), body.groups, state.authUser && state.authUser.id, { who: requestActor(state) }) }); }
|
|
187
|
+
const tenantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/tenants$/);
|
|
188
|
+
if (tenantsMatch && request.method === "GET") return Response.json({ tenants: await listUserTenants(env, decodeURIComponent(tenantsMatch[1]), { who: requestActor(state) }) });
|
|
189
|
+
if (tenantsMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.tenants)) return Response.json({ error: "tenants must be an array" }, { status: 400 }); return Response.json({ tenants: await replaceUserTenants(env, decodeURIComponent(tenantsMatch[1]), body.tenants, { who: requestActor(state) }) }); }
|
|
182
190
|
if (grantsMatch && request.method === "GET") return Response.json({ grants: await listUserGrants(env, decodeURIComponent(grantsMatch[1]), { who: requestActor(state) }) });
|
|
183
191
|
if (grantsMatch && request.method === "PUT") {
|
|
184
192
|
const body = await request.json().catch(() => null);
|
package/src/ui/index.js
CHANGED
|
@@ -20,10 +20,10 @@ export class CfScopeBadge extends HTMLElement {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export class CfUserManagement extends HTMLElement {
|
|
23
|
-
async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card" part="panel"><h2>User access</h2><p class="status" part="status">Loading users and scopes…</p><table hidden part="table"><thead><tr><th>User</th><th>Scopes</th><th>Save</th></tr></thead><tbody></tbody></table></section>`; try { await this.load(); } catch (error) { this.status.textContent = error.message || "Unable to load access data."; } }
|
|
23
|
+
async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card" part="panel"><h2>User access</h2><p class="status" part="status">Loading users, tenants, and scopes…</p><table hidden part="table"><thead><tr><th>User</th><th>Tenants</th><th>Scopes</th><th>Save</th></tr></thead><tbody></tbody></table></section>`; try { await this.load(); } catch (error) { this.status.textContent = error.message || "Unable to load access data."; } }
|
|
24
24
|
get status() { return this.shadowRoot.querySelector(".status"); }
|
|
25
|
-
async load() { const [usersResponse, scopesResponse] = await Promise.all([fetch("/api/admin/users", { credentials: "same-origin" }), fetch("/api/admin/scopes", { credentials: "same-origin" })]); if (!usersResponse.ok || !scopesResponse.ok) throw new Error("Unable to load user access."); const users = (await usersResponse.json()).users || []; const scopes = (await scopesResponse.json()).scopes || []; const body = this.shadowRoot.querySelector("tbody"); body.replaceChildren(...users.map((user) => this.row(user, scopes))); this.shadowRoot.querySelector("table").hidden = false; this.status.textContent = `${users.length} user${users.length === 1 ? "" : "s"}`; }
|
|
26
|
-
row(user, scopes) { const row = document.createElement("tr"); const identity = document.createElement("td"); identity.textContent = `${user.display_name || user.email || "Unnamed user"} (${user.email || "no email"})`; const grants = document.createElement("td"); const list = document.createElement("div"); list.className = "scope-list"; for (const scope of scopes) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.value = scope.name; input.checked = Boolean((user.scopes || []).includes(scope.name)); const text = document.createElement("span"); text.textContent = scope.label || scope.name; text.title = scope.name; label.append(input, text); list.append(label); } grants.append(list); const action = document.createElement("td"); const button = document.createElement("button"); button.textContent = "Save"; button.addEventListener("click", async () => { button.disabled = true; const
|
|
25
|
+
async load() { const [usersResponse, scopesResponse, tenantsResponse] = await Promise.all([fetch("/api/admin/users", { credentials: "same-origin" }), fetch("/api/admin/scopes", { credentials: "same-origin" }), fetch("/api/admin/tenants", { credentials: "same-origin" })]); if (!usersResponse.ok || !scopesResponse.ok || !tenantsResponse.ok) throw new Error("Unable to load user access."); const users = (await usersResponse.json()).users || []; const scopes = (await scopesResponse.json()).scopes || []; const tenants = (await tenantsResponse.json()).tenants || []; const body = this.shadowRoot.querySelector("tbody"); body.replaceChildren(...users.map((user) => this.row(user, scopes, tenants))); this.shadowRoot.querySelector("table").hidden = false; this.status.textContent = `${users.length} user${users.length === 1 ? "" : "s"}`; }
|
|
26
|
+
row(user, scopes, tenants) { const row = document.createElement("tr"); const identity = document.createElement("td"); identity.textContent = `${user.display_name || user.email || "Unnamed user"} (${user.email || "no email"})`; const tenantCell = document.createElement("td"); const tenantList = document.createElement("div"); tenantList.className = "scope-list"; const currentTenants = new Set((user.tenants || []).map((tenant) => tenant.id)); for (const tenant of tenants) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.value = tenant.id; input.checked = currentTenants.has(tenant.id); const text = document.createElement("span"); text.textContent = tenant.name; text.title = tenant.id; label.append(input, text); tenantList.append(label); } tenantCell.append(tenantList); const grants = document.createElement("td"); const list = document.createElement("div"); list.className = "scope-list"; for (const scope of scopes) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.value = scope.name; input.checked = Boolean((user.scopes || []).includes(scope.name)); const text = document.createElement("span"); text.textContent = scope.label || scope.name; text.title = scope.name; label.append(input, text); list.append(label); } grants.append(list); const action = document.createElement("td"); const button = document.createElement("button"); button.textContent = "Save"; button.addEventListener("click", async () => { button.disabled = true; const selectedScopes = [...list.querySelectorAll("input:checked")].map((input) => input.value); const selectedTenants = [...tenantList.querySelectorAll("input:checked")].map((input) => input.value); const [scopeResponse, tenantResponse] = await Promise.all([fetch(`/api/admin/users/${encodeURIComponent(user.id)}/scopes`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scopes: selectedScopes }) }), fetch(`/api/admin/users/${encodeURIComponent(user.id)}/tenants`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tenants: selectedTenants }) })]); button.disabled = false; this.status.textContent = scopeResponse.ok && tenantResponse.ok ? "Access saved." : "Unable to save access."; }); action.append(button); row.append(identity, tenantCell, grants, action); return row; }
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
if (!customElements.get("cf-admin-shell")) customElements.define("cf-admin-shell", CfAdminShell);
|