2026-07-08 12:44:23 +08:00
|
|
|
|
import { assertSafeSqlIdentifier, normalizeDatasetConfig } from './user-data-space-service.mjs';
|
2026-07-08 14:52:49 +08:00
|
|
|
|
import {
|
|
|
|
|
|
normalizeRowPolicy,
|
|
|
|
|
|
normalizeRoleOverrides,
|
|
|
|
|
|
normalizeVisitorAssignments,
|
|
|
|
|
|
} from './page-access-visitor.mjs';
|
2026-07-08 12:44:23 +08:00
|
|
|
|
|
|
|
|
|
|
export const PAGE_DATA_ACCESS_MODES = new Set(['public', 'password', 'login_required']);
|
|
|
|
|
|
|
|
|
|
|
|
export function normalizePolicyDataset(name, raw) {
|
|
|
|
|
|
const datasetName = assertSafeSqlIdentifier(name, 'dataset 名称');
|
|
|
|
|
|
if (!raw || typeof raw !== 'object') {
|
|
|
|
|
|
throw Object.assign(new Error(`dataset 策略无效:${datasetName}`), { code: 'invalid_policy' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const columns = raw.columns && typeof raw.columns === 'object' ? raw.columns : {};
|
|
|
|
|
|
const normalizedColumns = {};
|
|
|
|
|
|
for (const [action, fields] of Object.entries(columns)) {
|
|
|
|
|
|
if (!Array.isArray(fields)) continue;
|
|
|
|
|
|
normalizedColumns[action] = fields.map((field) => assertSafeSqlIdentifier(field, `${action} 字段`));
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: datasetName,
|
|
|
|
|
|
read: Boolean(raw.read),
|
|
|
|
|
|
insert: Boolean(raw.insert),
|
|
|
|
|
|
update: Boolean(raw.update),
|
|
|
|
|
|
softDelete: Boolean(raw.softDelete ?? raw.soft_delete),
|
2026-07-15 10:56:22 +08:00
|
|
|
|
// `delete` is the page-facing spelling for an intentional permanent PG
|
|
|
|
|
|
// delete; retain hardDelete for existing policies.
|
|
|
|
|
|
hardDelete: Boolean(raw.hardDelete ?? raw.hard_delete ?? raw.delete),
|
2026-07-08 12:44:23 +08:00
|
|
|
|
columns: normalizedColumns,
|
|
|
|
|
|
limits:
|
|
|
|
|
|
raw.limits && typeof raw.limits === 'object'
|
|
|
|
|
|
? {
|
|
|
|
|
|
maxRowsPerRead: Number(raw.limits.maxRowsPerRead ?? 100),
|
|
|
|
|
|
maxInsertBytes: Number(raw.limits.maxInsertBytes ?? 8192),
|
|
|
|
|
|
}
|
|
|
|
|
|
: undefined,
|
2026-07-08 14:52:49 +08:00
|
|
|
|
rowPolicy: normalizeRowPolicy(raw.rowPolicy ?? raw.row_policy),
|
|
|
|
|
|
closed: Boolean(raw.closed),
|
|
|
|
|
|
closedAt: raw.closedAt ?? raw.closed_at ?? null,
|
2026-07-08 12:44:23 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function normalizePageAccessPolicy(raw, { fallbackPageId = null, fallbackOwnerUserId = null } = {}) {
|
|
|
|
|
|
if (!raw || typeof raw !== 'object') {
|
|
|
|
|
|
throw Object.assign(new Error('Page Access Policy 无效'), { code: 'invalid_policy' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const pageId = String(raw.pageId ?? fallbackPageId ?? '').trim();
|
|
|
|
|
|
const ownerUserId = String(raw.ownerUserId ?? fallbackOwnerUserId ?? '').trim();
|
|
|
|
|
|
if (!pageId) throw Object.assign(new Error('缺少 pageId'), { code: 'invalid_policy' });
|
|
|
|
|
|
if (!ownerUserId) throw Object.assign(new Error('缺少 ownerUserId'), { code: 'invalid_policy' });
|
|
|
|
|
|
const accessMode = String(raw.accessMode ?? raw.access_mode ?? 'public').trim();
|
|
|
|
|
|
if (!PAGE_DATA_ACCESS_MODES.has(accessMode)) {
|
|
|
|
|
|
throw Object.assign(new Error(`不支持的 accessMode:${accessMode}`), { code: 'invalid_policy' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const datasetsInput = raw.datasets && typeof raw.datasets === 'object' ? raw.datasets : {};
|
|
|
|
|
|
const datasets = {};
|
|
|
|
|
|
for (const [name, config] of Object.entries(datasetsInput)) {
|
|
|
|
|
|
datasets[assertSafeSqlIdentifier(name, 'dataset 名称')] = normalizePolicyDataset(name, config);
|
|
|
|
|
|
}
|
2026-07-08 14:52:49 +08:00
|
|
|
|
const defaultVisitorRole = String(
|
|
|
|
|
|
raw.defaultVisitorRole ??
|
|
|
|
|
|
raw.default_visitor_role ??
|
|
|
|
|
|
(accessMode === 'login_required' ? 'viewer' : 'deny'),
|
|
|
|
|
|
).trim();
|
|
|
|
|
|
if (!['deny', 'viewer', 'editor'].includes(defaultVisitorRole)) {
|
|
|
|
|
|
throw Object.assign(new Error(`不支持的 defaultVisitorRole:${defaultVisitorRole}`), {
|
|
|
|
|
|
code: 'invalid_policy',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-08 12:44:23 +08:00
|
|
|
|
return {
|
|
|
|
|
|
pageId,
|
|
|
|
|
|
ownerUserId,
|
|
|
|
|
|
workspaceRef: String(raw.workspaceRef ?? raw.workspace_ref ?? '').trim() || null,
|
|
|
|
|
|
accessMode,
|
2026-07-08 14:52:49 +08:00
|
|
|
|
defaultVisitorRole,
|
|
|
|
|
|
visitors: normalizeVisitorAssignments(raw.visitors),
|
|
|
|
|
|
roles: normalizeRoleOverrides(raw.roles),
|
2026-07-08 12:44:23 +08:00
|
|
|
|
datasets,
|
|
|
|
|
|
updatedAt: raw.updatedAt ?? new Date().toISOString(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildEffectiveDataset(registryDataset, policyDataset) {
|
|
|
|
|
|
if (!registryDataset || !policyDataset) return null;
|
|
|
|
|
|
const actions = [];
|
|
|
|
|
|
if (policyDataset.read) actions.push('read');
|
|
|
|
|
|
if (policyDataset.insert) actions.push('insert');
|
|
|
|
|
|
if (policyDataset.update) actions.push('update');
|
|
|
|
|
|
if (policyDataset.softDelete) actions.push('soft_delete');
|
|
|
|
|
|
if (policyDataset.hardDelete) actions.push('hard_delete');
|
|
|
|
|
|
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 normalizeDatasetConfig({
|
|
|
|
|
|
name: registryDataset.name,
|
|
|
|
|
|
table: registryDataset.table,
|
|
|
|
|
|
description: registryDataset.description,
|
|
|
|
|
|
actions,
|
|
|
|
|
|
columns,
|
|
|
|
|
|
limits: policyDataset.limits ?? registryDataset.limits,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 18:39:24 +08:00
|
|
|
|
export async function assertPageAccessPolicyMatchesRegistry(policy, userDataSpace) {
|
|
|
|
|
|
if (!userDataSpace?.getDataset) {
|
|
|
|
|
|
throw Object.assign(new Error('缺少 Page Data 数据空间'), {
|
|
|
|
|
|
code: 'feature_disabled',
|
|
|
|
|
|
status: 503,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const actionFlags = [
|
|
|
|
|
|
['read', 'read'],
|
|
|
|
|
|
['insert', 'insert'],
|
|
|
|
|
|
['update', 'update'],
|
|
|
|
|
|
['softDelete', 'soft_delete'],
|
|
|
|
|
|
['hardDelete', 'hard_delete'],
|
|
|
|
|
|
];
|
|
|
|
|
|
const columnGroups = ['read', 'insert', 'update', 'soft_delete'];
|
|
|
|
|
|
|
|
|
|
|
|
for (const [datasetName, policyDataset] of Object.entries(policy?.datasets ?? {})) {
|
|
|
|
|
|
const registryDataset = await userDataSpace.getDataset(datasetName);
|
|
|
|
|
|
if (!registryDataset) {
|
|
|
|
|
|
throw Object.assign(new Error(`dataset 未注册:${datasetName}`), {
|
|
|
|
|
|
code: 'dataset_not_found',
|
|
|
|
|
|
status: 404,
|
|
|
|
|
|
datasetName,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tableColumns = await userDataSpace.listTableColumns?.(registryDataset.table);
|
|
|
|
|
|
if (!Array.isArray(tableColumns) || tableColumns.length === 0) {
|
|
|
|
|
|
throw Object.assign(new Error(`dataset 对应表不存在:${registryDataset.table}`), {
|
|
|
|
|
|
code: 'table_not_found',
|
|
|
|
|
|
status: 404,
|
|
|
|
|
|
datasetName,
|
|
|
|
|
|
table: registryDataset.table,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
const actualColumnNames = new Set(tableColumns.map((column) => column.name));
|
|
|
|
|
|
const missingRegistryColumns = [
|
|
|
|
|
|
...new Set(Object.values(registryDataset.columns ?? {}).flat()),
|
|
|
|
|
|
].filter((column) => !actualColumnNames.has(column));
|
|
|
|
|
|
if (missingRegistryColumns.length) {
|
|
|
|
|
|
throw Object.assign(
|
|
|
|
|
|
new Error(
|
|
|
|
|
|
`dataset「${datasetName}」注册字段不存在:${missingRegistryColumns.join(', ')}`,
|
|
|
|
|
|
),
|
|
|
|
|
|
{
|
|
|
|
|
|
code: 'dataset_schema_mismatch',
|
|
|
|
|
|
status: 409,
|
|
|
|
|
|
datasetName,
|
|
|
|
|
|
columns: missingRegistryColumns,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const registeredActions = new Set(registryDataset.actions ?? []);
|
|
|
|
|
|
for (const [policyFlag, registryAction] of actionFlags) {
|
|
|
|
|
|
if (policyDataset?.[policyFlag] && !registeredActions.has(registryAction)) {
|
|
|
|
|
|
throw Object.assign(
|
|
|
|
|
|
new Error(`dataset「${datasetName}」未注册 ${registryAction} 权限`),
|
|
|
|
|
|
{
|
|
|
|
|
|
code: 'action_not_allowed',
|
|
|
|
|
|
status: 403,
|
|
|
|
|
|
datasetName,
|
|
|
|
|
|
action: registryAction,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const action of columnGroups) {
|
|
|
|
|
|
const requestedColumns = policyDataset?.columns?.[action];
|
|
|
|
|
|
if (!Array.isArray(requestedColumns)) continue;
|
|
|
|
|
|
const registeredColumns = new Set(registryDataset.columns?.[action] ?? []);
|
|
|
|
|
|
const unregisteredColumns = requestedColumns.filter(
|
|
|
|
|
|
(column) => !registeredColumns.has(column),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (unregisteredColumns.length) {
|
|
|
|
|
|
throw Object.assign(
|
|
|
|
|
|
new Error(
|
|
|
|
|
|
`dataset「${datasetName}」的 ${action} 字段未注册:${unregisteredColumns.join(', ')}`,
|
|
|
|
|
|
),
|
|
|
|
|
|
{
|
|
|
|
|
|
code: 'columns_not_allowed',
|
|
|
|
|
|
status: 403,
|
|
|
|
|
|
datasetName,
|
|
|
|
|
|
action,
|
|
|
|
|
|
columns: unregisteredColumns,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return policy;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 12:44:23 +08:00
|
|
|
|
export function defaultDatasetsForAccessMode(accessMode, datasets = {}) {
|
|
|
|
|
|
const normalized = {};
|
|
|
|
|
|
for (const [name, config] of Object.entries(datasets)) {
|
|
|
|
|
|
const base = normalizePolicyDataset(name, config);
|
|
|
|
|
|
if (accessMode === 'public') {
|
|
|
|
|
|
normalized[name] = {
|
|
|
|
|
|
...base,
|
|
|
|
|
|
read: false,
|
|
|
|
|
|
update: false,
|
|
|
|
|
|
softDelete: false,
|
|
|
|
|
|
hardDelete: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
} else if (accessMode === 'password') {
|
|
|
|
|
|
normalized[name] = {
|
|
|
|
|
|
...base,
|
|
|
|
|
|
hardDelete: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
} else {
|
|
|
|
|
|
normalized[name] = {
|
|
|
|
|
|
...base,
|
|
|
|
|
|
hardDelete: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function policyAllowsAction(policy, datasetName, action) {
|
|
|
|
|
|
const dataset = policy?.datasets?.[datasetName];
|
|
|
|
|
|
if (!dataset) return false;
|
2026-07-08 14:52:49 +08:00
|
|
|
|
if (dataset.closed) return false;
|
2026-07-08 12:44:23 +08:00
|
|
|
|
if (action === 'read') return dataset.read;
|
|
|
|
|
|
if (action === 'insert') return dataset.insert;
|
|
|
|
|
|
if (action === 'update') return dataset.update;
|
|
|
|
|
|
if (action === 'soft_delete') return dataset.softDelete;
|
|
|
|
|
|
if (action === 'hard_delete') return dataset.hardDelete;
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-07-08 14:52:49 +08:00
|
|
|
|
|
|
|
|
|
|
export function closePolicyDataset(policy, datasetName) {
|
|
|
|
|
|
const dataset = policy?.datasets?.[datasetName];
|
|
|
|
|
|
if (!dataset) {
|
|
|
|
|
|
throw Object.assign(new Error('dataset 未授权'), { code: 'dataset_not_found' });
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
...policy,
|
|
|
|
|
|
datasets: {
|
|
|
|
|
|
...policy.datasets,
|
|
|
|
|
|
[datasetName]: {
|
|
|
|
|
|
...dataset,
|
|
|
|
|
|
read: false,
|
|
|
|
|
|
insert: false,
|
|
|
|
|
|
update: false,
|
|
|
|
|
|
softDelete: false,
|
|
|
|
|
|
hardDelete: false,
|
|
|
|
|
|
closed: true,
|
|
|
|
|
|
closedAt: new Date().toISOString(),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function mapPublicationAccessModeToPageData(accessMode) {
|
|
|
|
|
|
const mode = String(accessMode ?? '').trim();
|
|
|
|
|
|
if (PAGE_DATA_ACCESS_MODES.has(mode)) return mode;
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildDatasetPolicyFromRegistry(
|
|
|
|
|
|
registryDataset,
|
|
|
|
|
|
{ read = false, insert = false, update = false, softDelete = false, accessMode = 'public' } = {},
|
|
|
|
|
|
) {
|
|
|
|
|
|
const rowPolicy =
|
|
|
|
|
|
accessMode === 'login_required' && (update || softDelete || read)
|
|
|
|
|
|
? { scope: 'own_rows', ownerColumn: 'created_by_user_id' }
|
|
|
|
|
|
: null;
|
|
|
|
|
|
return normalizePolicyDataset(registryDataset?.name ?? 'dataset', {
|
|
|
|
|
|
read: Boolean(read),
|
|
|
|
|
|
insert: Boolean(insert),
|
|
|
|
|
|
update: Boolean(update),
|
|
|
|
|
|
softDelete: Boolean(softDelete),
|
|
|
|
|
|
columns: {
|
|
|
|
|
|
read: registryDataset?.columns?.read ?? [],
|
|
|
|
|
|
insert: registryDataset?.columns?.insert ?? [],
|
|
|
|
|
|
update: registryDataset?.columns?.update ?? [],
|
|
|
|
|
|
soft_delete: registryDataset?.columns?.soft_delete ?? ['id'],
|
|
|
|
|
|
},
|
|
|
|
|
|
rowPolicy,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildPageDataPolicyFromPublishInput({
|
|
|
|
|
|
pageId,
|
|
|
|
|
|
ownerUserId,
|
|
|
|
|
|
accessMode,
|
|
|
|
|
|
datasetName,
|
|
|
|
|
|
registryDataset,
|
|
|
|
|
|
capabilities = {},
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const mappedAccessMode = mapPublicationAccessModeToPageData(accessMode);
|
|
|
|
|
|
if (!mappedAccessMode) {
|
|
|
|
|
|
throw Object.assign(new Error('当前发布访问模式不支持 Page Data'), { code: 'invalid_policy' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const defaults =
|
|
|
|
|
|
mappedAccessMode === 'public'
|
|
|
|
|
|
? { read: false, insert: true, update: false, softDelete: false }
|
|
|
|
|
|
: mappedAccessMode === 'password'
|
|
|
|
|
|
? { read: true, insert: true, update: false, softDelete: false }
|
|
|
|
|
|
: { read: true, insert: true, update: true, softDelete: false };
|
|
|
|
|
|
const datasetPolicy = buildDatasetPolicyFromRegistry(registryDataset, {
|
|
|
|
|
|
read: capabilities.read ?? defaults.read,
|
|
|
|
|
|
insert: capabilities.insert ?? defaults.insert,
|
|
|
|
|
|
update: capabilities.update ?? defaults.update,
|
|
|
|
|
|
softDelete: capabilities.softDelete ?? defaults.softDelete,
|
|
|
|
|
|
accessMode: mappedAccessMode,
|
|
|
|
|
|
});
|
|
|
|
|
|
return normalizePageAccessPolicy(
|
|
|
|
|
|
{
|
|
|
|
|
|
pageId,
|
|
|
|
|
|
ownerUserId,
|
|
|
|
|
|
accessMode: mappedAccessMode,
|
|
|
|
|
|
datasets: {
|
|
|
|
|
|
[datasetName]: datasetPolicy,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{ fallbackPageId: pageId, fallbackOwnerUserId: ownerUserId },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|