6b0c633a75
Add visitor roles, row-level scope, owner ops APIs, MySQL policy index, Turnstile captcha, browser client SDK, publish-panel dataset binding, acceptance tests, and usage documentation. Co-authored-by: Cursor <cursoragent@cursor.com>
150 lines
6.1 KiB
JavaScript
150 lines
6.1 KiB
JavaScript
import { assertSafeSqlIdentifier } from './user-data-space-service.mjs';
|
||
|
||
export const PAGE_DATA_VISITOR_ROLES = new Set(['viewer', 'editor', 'owner']);
|
||
|
||
export const DEFAULT_ROLE_PERMISSIONS = {
|
||
viewer: { read: true, insert: false, update: false, softDelete: false },
|
||
editor: { read: true, insert: true, update: true, softDelete: true },
|
||
owner: { read: true, insert: true, update: true, softDelete: true, hardDelete: false },
|
||
};
|
||
|
||
export function normalizeRowPolicy(raw) {
|
||
if (!raw || typeof raw !== 'object') return null;
|
||
const scope = String(raw.scope ?? 'all_rows').trim();
|
||
if (!['all_rows', 'own_rows', 'owner_only'].includes(scope)) {
|
||
throw Object.assign(new Error(`不支持的 rowPolicy.scope:${scope}`), { code: 'invalid_policy' });
|
||
}
|
||
const ownerColumn = assertSafeSqlIdentifier(
|
||
raw.ownerColumn ?? raw.owner_column ?? 'created_by_user_id',
|
||
'rowPolicy.ownerColumn',
|
||
);
|
||
return { scope, ownerColumn };
|
||
}
|
||
|
||
export function normalizeVisitorAssignments(raw) {
|
||
if (!raw || typeof raw !== 'object') return {};
|
||
const visitors = {};
|
||
for (const [userId, value] of Object.entries(raw)) {
|
||
const visitorId = String(userId ?? '').trim();
|
||
if (!visitorId) continue;
|
||
const role = typeof value === 'string' ? value.trim() : String(value?.role ?? '').trim();
|
||
if (!PAGE_DATA_VISITOR_ROLES.has(role) || role === 'owner') {
|
||
throw Object.assign(new Error(`访问者角色无效:${role}`), { code: 'invalid_policy' });
|
||
}
|
||
visitors[visitorId] = role;
|
||
}
|
||
return visitors;
|
||
}
|
||
|
||
export function normalizeRoleOverrides(raw) {
|
||
if (!raw || typeof raw !== 'object') return {};
|
||
const roles = {};
|
||
for (const [roleName, permissions] of Object.entries(raw)) {
|
||
const role = String(roleName ?? '').trim();
|
||
if (!PAGE_DATA_VISITOR_ROLES.has(role)) {
|
||
throw Object.assign(new Error(`角色定义无效:${role}`), { code: 'invalid_policy' });
|
||
}
|
||
roles[role] = {
|
||
read: Boolean(permissions?.read),
|
||
insert: Boolean(permissions?.insert),
|
||
update: Boolean(permissions?.update),
|
||
softDelete: Boolean(permissions?.softDelete ?? permissions?.soft_delete),
|
||
hardDelete: Boolean(permissions?.hardDelete ?? permissions?.hard_delete),
|
||
};
|
||
}
|
||
return roles;
|
||
}
|
||
|
||
export function resolveVisitorRole(policy, visitorUserId) {
|
||
const visitorId = String(visitorUserId ?? '').trim();
|
||
if (!visitorId) return null;
|
||
if (visitorId === policy.ownerUserId) return 'owner';
|
||
const assigned = policy.visitors?.[visitorId];
|
||
if (assigned) return assigned;
|
||
const defaultRole = String(policy.defaultVisitorRole ?? 'deny').trim();
|
||
if (defaultRole === 'deny') return null;
|
||
if (!PAGE_DATA_VISITOR_ROLES.has(defaultRole)) return null;
|
||
return defaultRole;
|
||
}
|
||
|
||
export function resolveRolePermissions(policy, role) {
|
||
const defaults = DEFAULT_ROLE_PERMISSIONS[role] ?? DEFAULT_ROLE_PERMISSIONS.viewer;
|
||
const overrides = policy.roles?.[role];
|
||
if (!overrides) return { ...defaults };
|
||
return {
|
||
read: overrides.read ?? defaults.read,
|
||
insert: overrides.insert ?? defaults.insert,
|
||
update: overrides.update ?? defaults.update,
|
||
softDelete: overrides.softDelete ?? defaults.softDelete,
|
||
hardDelete: overrides.hardDelete ?? defaults.hardDelete,
|
||
};
|
||
}
|
||
|
||
export function roleAllowsAction(rolePermissions, action) {
|
||
if (!rolePermissions) return false;
|
||
if (action === 'read') return Boolean(rolePermissions.read);
|
||
if (action === 'insert') return Boolean(rolePermissions.insert);
|
||
if (action === 'update') return Boolean(rolePermissions.update);
|
||
if (action === 'soft_delete') return Boolean(rolePermissions.softDelete);
|
||
if (action === 'hard_delete') return Boolean(rolePermissions.hardDelete);
|
||
return false;
|
||
}
|
||
|
||
export function buildEffectiveDatasetForRole(registryDataset, policyDataset, rolePermissions) {
|
||
if (!registryDataset || !policyDataset || !rolePermissions) return null;
|
||
const actions = [];
|
||
if (policyDataset.read && rolePermissions.read) actions.push('read');
|
||
if (policyDataset.insert && rolePermissions.insert) actions.push('insert');
|
||
if (policyDataset.update && rolePermissions.update) actions.push('update');
|
||
if (policyDataset.softDelete && rolePermissions.softDelete) actions.push('soft_delete');
|
||
if (policyDataset.hardDelete && rolePermissions.hardDelete) actions.push('hard_delete');
|
||
if (!actions.length) return null;
|
||
const columns = {
|
||
read: policyDataset.columns?.read ?? registryDataset.columns?.read ?? [],
|
||
insert: policyDataset.columns?.insert ?? registryDataset.columns?.insert ?? [],
|
||
update: policyDataset.columns?.update ?? registryDataset.columns?.update ?? [],
|
||
soft_delete: policyDataset.columns?.soft_delete ?? registryDataset.columns?.soft_delete ?? ['id'],
|
||
};
|
||
return {
|
||
name: registryDataset.name,
|
||
table: registryDataset.table,
|
||
description: registryDataset.description,
|
||
actions,
|
||
columns,
|
||
limits: policyDataset.limits ?? registryDataset.limits,
|
||
rowPolicy: policyDataset.rowPolicy ?? null,
|
||
};
|
||
}
|
||
|
||
export function resolveRowAccessScope(rowPolicy, {
|
||
visitorRole,
|
||
visitorUserId,
|
||
ownerUserId,
|
||
tableColumns = [],
|
||
} = {}) {
|
||
if (!rowPolicy || rowPolicy.scope === 'all_rows') {
|
||
return { bypass: true, whereClause: null, ownerColumn: null, visitorUserId };
|
||
}
|
||
const isOwner = visitorRole === 'owner' || visitorUserId === ownerUserId;
|
||
if (rowPolicy.scope === 'owner_only') {
|
||
if (isOwner) return { bypass: true, whereClause: null, ownerColumn: null, visitorUserId };
|
||
throw Object.assign(new Error('当前数据集仅页面所有者可访问'), { code: 'row_not_allowed' });
|
||
}
|
||
if (rowPolicy.scope === 'own_rows') {
|
||
if (isOwner) return { bypass: true, whereClause: null, ownerColumn: rowPolicy.ownerColumn, visitorUserId };
|
||
const column = rowPolicy.ownerColumn;
|
||
const hasColumn = tableColumns.some((item) => item.name === column);
|
||
if (!hasColumn) {
|
||
throw Object.assign(new Error(`行级权限列不存在:${column}`), { code: 'row_policy_invalid' });
|
||
}
|
||
const safeVisitorId = String(visitorUserId ?? '').replace(/'/g, "''");
|
||
return {
|
||
bypass: false,
|
||
whereClause: `"${column}" = '${safeVisitorId}'`,
|
||
ownerColumn: column,
|
||
visitorUserId,
|
||
};
|
||
}
|
||
return { bypass: true, whereClause: null, ownerColumn: null, visitorUserId };
|
||
}
|