@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 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 groups. The user update command resolves an email, subject, or internal id and supports `admin` or `none` roles. Production commands should be run through the repository credentials wrapper and reviewed as an administrative change.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "4.1.2",
3
+ "version": "4.1.3",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
@@ -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 selected = [...list.querySelectorAll("input:checked")].map((input) => input.value); const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}/scopes`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scopes: selected }) }); button.disabled = false; this.status.textContent = response.ok ? "Access saved." : "Unable to save access."; }); action.append(button); row.append(identity, grants, action); return row; }
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);