Files
memind/server/portal-access-policy.mjs
2026-07-24 19:18:14 +08:00

420 lines
12 KiB
JavaScript

export const PORTAL_ACCESS_CLASS = Object.freeze({
PUBLIC: 'public',
OPTIONAL_USER: 'optional-user',
AUTHENTICATED: 'authenticated',
INTERNAL: 'internal',
ROUTE_AUTHORIZED: 'route-authorized',
});
export const PORTAL_ACCESS_POLICY_MODE = Object.freeze({
OFF: 'off',
SHADOW: 'shadow',
});
export const PORTAL_ACCESS_ENFORCEMENT_GROUPS = Object.freeze([
'legacy-page-data',
'page-data-public',
'plaza-optional-user',
]);
const DEFAULT_SHADOW_LOG_INTERVAL_MS = 60_000;
const MIN_SHADOW_LOG_INTERVAL_MS = 1_000;
const MAX_SHADOW_LOG_INTERVAL_MS = 60 * 60_000;
function freezeRule(rule) {
return Object.freeze({
...rule,
paths: rule.paths ? Object.freeze([...rule.paths]) : undefined,
methods: rule.methods ? Object.freeze([...rule.methods]) : undefined,
});
}
export const PORTAL_STATIC_ACCESS_RULES = Object.freeze([
freezeRule({
id: 'runtime-status',
accessClass: PORTAL_ACCESS_CLASS.PUBLIC,
legacyDirectBypass: true,
paths: ['/status', '/runtime/status'],
}),
freezeRule({
id: 'blocked-words',
accessClass: PORTAL_ACCESS_CLASS.PUBLIC,
legacyDirectBypass: true,
paths: ['/config/blocked-words'],
}),
freezeRule({
id: 'internal-agent',
accessClass: PORTAL_ACCESS_CLASS.INTERNAL,
legacyDirectBypass: true,
pathPrefix: '/internal/agent/',
}),
freezeRule({
id: 'internal-deep-search-llm',
accessClass: PORTAL_ACCESS_CLASS.INTERNAL,
legacyDirectBypass: true,
paths: ['/internal/deep-search/llm'],
}),
freezeRule({
id: 'mindspace-agent-callback',
accessClass: PORTAL_ACCESS_CLASS.INTERNAL,
legacyDirectBypass: true,
paths: [
'/agent/mindspace_page_patch',
'/agent/mindspace_asset_delete',
'/agent/mindspace_asset_download',
'/agent/mindspace_image_generate',
],
}),
freezeRule({
id: 'image-make-runtime-config',
accessClass: PORTAL_ACCESS_CLASS.INTERNAL,
legacyDirectBypass: true,
paths: ['/internal/image-make/runtime-config'],
}),
freezeRule({
id: 'mindspace-asset-download',
accessClass: PORTAL_ACCESS_CLASS.ROUTE_AUTHORIZED,
legacyDirectBypass: true,
methods: ['GET'],
pathPattern: /^\/mindspace\/v1\/assets\/[^/]+\/download$/,
}),
freezeRule({
id: 'plaza-events',
policyGroup: 'plaza-optional-user',
accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER,
methods: ['POST'],
paths: ['/plaza/v1/events'],
}),
freezeRule({
id: 'plaza-discovery',
policyGroup: 'plaza-optional-user',
accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER,
methods: ['GET'],
paths: ['/plaza/v1/feed', '/plaza/v1/categories', '/plaza/v1/seo/sitemap'],
}),
freezeRule({
id: 'plaza-post-detail',
policyGroup: 'plaza-optional-user',
accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER,
methods: ['GET'],
pathPattern: /^\/plaza\/v1\/posts\/[^/]+$/,
}),
freezeRule({
id: 'plaza-post-comments',
policyGroup: 'plaza-optional-user',
accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER,
methods: ['GET'],
pathPattern: /^\/plaza\/v1\/posts\/[^/]+\/comments$/,
}),
freezeRule({
id: 'plaza-user-profile',
policyGroup: 'plaza-optional-user',
accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER,
methods: ['GET'],
pathPattern: /^\/plaza\/v1\/users\/[^/]+$/,
}),
freezeRule({
id: 'plaza-user-posts',
policyGroup: 'plaza-optional-user',
accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER,
methods: ['GET'],
pathPattern: /^\/plaza\/v1\/users\/[^/]+\/posts$/,
}),
]);
function normalizeRequest({ path, method } = {}) {
return {
path: String(path ?? ''),
method: String(method ?? 'GET').trim().toUpperCase() || 'GET',
};
}
function ruleMatches(rule, request) {
if (rule.methods && !rule.methods.includes(request.method)) return false;
if (rule.paths?.includes(request.path)) return true;
if (rule.pathPrefix && request.path.startsWith(rule.pathPrefix)) return true;
return Boolean(rule.pathPattern?.test(request.path));
}
export function findPortalStaticAccessRule(requestInput) {
const request = normalizeRequest(requestInput);
return PORTAL_STATIC_ACCESS_RULES.find((rule) => ruleMatches(rule, request)) ?? null;
}
export function resolvePortalAccessPolicyMode(env = process.env) {
const raw = String(env.MEMIND_PORTAL_ACCESS_POLICY_MODE ?? '').trim().toLowerCase();
return raw === PORTAL_ACCESS_POLICY_MODE.SHADOW
? PORTAL_ACCESS_POLICY_MODE.SHADOW
: PORTAL_ACCESS_POLICY_MODE.OFF;
}
export function resolvePortalAccessShadowLogIntervalMs(env = process.env) {
const parsed = Number(env.MEMIND_PORTAL_ACCESS_SHADOW_LOG_INTERVAL_MS);
if (!Number.isFinite(parsed)) return DEFAULT_SHADOW_LOG_INTERVAL_MS;
return Math.min(
MAX_SHADOW_LOG_INTERVAL_MS,
Math.max(MIN_SHADOW_LOG_INTERVAL_MS, Math.trunc(parsed)),
);
}
function isExplicitlyEnabled(value) {
return String(value ?? '').trim() === '1';
}
export function resolvePortalAccessEnforcementConfig(env = process.env) {
const masterEnabled = isExplicitlyEnabled(
env.MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED,
);
const killSwitch = isExplicitlyEnabled(
env.MEMIND_PORTAL_ACCESS_POLICY_KILL_SWITCH,
);
const requestedGroups = [
...new Set(
String(env.MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS ?? '')
.split(',')
.map((group) => group.trim())
.filter(Boolean),
),
];
const unknownGroups = requestedGroups.filter(
(group) => !PORTAL_ACCESS_ENFORCEMENT_GROUPS.includes(group),
);
if (masterEnabled && unknownGroups.length > 0) {
throw new Error(
`Unknown Portal access enforcement group(s): ${unknownGroups.join(', ')}`,
);
}
const activeGroups =
masterEnabled && !killSwitch
? requestedGroups.filter((group) =>
PORTAL_ACCESS_ENFORCEMENT_GROUPS.includes(group))
: [];
return Object.freeze({
masterEnabled,
killSwitch,
enabled: activeGroups.length > 0,
requestedGroups: Object.freeze(requestedGroups),
activeGroups: Object.freeze(activeGroups),
unknownGroups: Object.freeze(unknownGroups),
});
}
export function isPortalLegacyDirectGlobalAuthBypass(path, method) {
return Boolean(
findPortalStaticAccessRule({ path, method })?.legacyDirectBypass,
);
}
export function isPortalPlazaOptionalUserPath(path, method) {
return (
findPortalStaticAccessRule({ path, method })?.policyGroup ===
'plaza-optional-user'
);
}
export function resolvePortalApiAccess(
requestInput,
{
isPageDataPublicPath = () => false,
isLegacyPageDataApiPath = () => false,
} = {},
) {
const request = normalizeRequest(requestInput);
const staticRule = findPortalStaticAccessRule(request);
if (staticRule) {
return {
accessClass: staticRule.accessClass,
ruleId: staticRule.id,
policyGroup: staticRule.policyGroup ?? staticRule.id,
};
}
if (isPageDataPublicPath(request.path, request.method)) {
return {
accessClass: PORTAL_ACCESS_CLASS.ROUTE_AUTHORIZED,
ruleId: 'page-data-public',
policyGroup: 'page-data-public',
};
}
if (isLegacyPageDataApiPath(request.path)) {
return {
accessClass: PORTAL_ACCESS_CLASS.PUBLIC,
ruleId: 'legacy-page-data',
policyGroup: 'legacy-page-data',
};
}
return {
accessClass: PORTAL_ACCESS_CLASS.AUTHENTICATED,
ruleId: 'authenticated-default',
policyGroup: 'authenticated-default',
};
}
export function classifyPortalApiAccess(requestInput, predicates = {}) {
return resolvePortalApiAccess(requestInput, predicates).accessClass;
}
export function portalAccessBypassesGlobalAuth(accessClass) {
return accessClass !== PORTAL_ACCESS_CLASS.AUTHENTICATED;
}
export function resolvePortalAccessEnforcementDecision(
requestInput,
predicates,
config,
) {
const proposed = resolvePortalApiAccess(requestInput, predicates);
const activeGroups = config?.activeGroups ?? [];
const selected =
Boolean(config?.enabled) &&
activeGroups.includes(proposed.policyGroup);
let reason = 'group-disabled';
if (config?.killSwitch) reason = 'kill-switch';
else if (!config?.masterEnabled) reason = 'master-disabled';
else if (selected) reason = 'group-enabled';
return {
selected,
bypassGlobalAuth:
selected && portalAccessBypassesGlobalAuth(proposed.accessClass),
reason,
proposedAccessClass: proposed.accessClass,
proposedRuleId: proposed.ruleId,
policyGroup: proposed.policyGroup,
};
}
export function shouldOverridePortalLegacyGlobalAuth(
decision,
currentGlobalAuthBypass,
) {
return (
Boolean(decision?.selected) &&
Boolean(decision?.bypassGlobalAuth) &&
!Boolean(currentGlobalAuthBypass)
);
}
export function comparePortalAccessPolicyShadow(
requestInput,
{
multiUserEnabled = false,
isPageDataPublicPath = () => false,
isLegacyPageDataApiPath = () => false,
} = {},
) {
const request = normalizeRequest(requestInput);
const plazaOptionalUser = isPortalPlazaOptionalUserPath(request.path, request.method);
const pageDataRouteAuthorized = isPageDataPublicPath(request.path, request.method);
const legacyPageDataPublic = isLegacyPageDataApiPath(request.path);
const currentGlobalAuthBypass =
isPortalLegacyDirectGlobalAuthBypass(request.path, request.method) ||
(
Boolean(multiUserEnabled) &&
(plazaOptionalUser || pageDataRouteAuthorized || legacyPageDataPublic)
);
const proposed = resolvePortalApiAccess(request, {
isPageDataPublicPath,
isLegacyPageDataApiPath,
});
const proposedGlobalAuthBypass = portalAccessBypassesGlobalAuth(proposed.accessClass);
return {
path: request.path,
method: request.method,
proposedAccessClass: proposed.accessClass,
proposedRuleId: proposed.ruleId,
policyGroup: proposed.policyGroup,
currentGlobalAuthBypass,
proposedGlobalAuthBypass,
mismatch: currentGlobalAuthBypass !== proposedGlobalAuthBypass,
};
}
export function assessPortalAccessMigrationReadiness(
requests,
{
knownMismatchPolicyGroups = [],
...comparisonOptions
} = {},
) {
const knownGroups = new Set(knownMismatchPolicyGroups);
const comparisons = requests.map((request) =>
comparePortalAccessPolicyShadow(request, comparisonOptions));
const mismatches = comparisons.filter((comparison) => comparison.mismatch);
const knownMismatches = mismatches.filter((comparison) =>
knownGroups.has(comparison.policyGroup));
const unexpectedMismatches = mismatches.filter((comparison) =>
!knownGroups.has(comparison.policyGroup));
return {
contractReady: unexpectedMismatches.length === 0,
safeForBehaviorSwitch: mismatches.length === 0,
total: comparisons.length,
aligned: comparisons.length - mismatches.length,
knownMismatches,
unexpectedMismatches,
};
}
export function createPortalAccessShadowReporter({
intervalMs = DEFAULT_SHADOW_LOG_INTERVAL_MS,
now = Date.now,
emit = () => {},
} = {}) {
const effectiveIntervalMs = Math.min(
MAX_SHADOW_LOG_INTERVAL_MS,
Math.max(MIN_SHADOW_LOG_INTERVAL_MS, Math.trunc(Number(intervalMs) || 0)),
);
const counters = new Map();
function record(event) {
if (!event?.mismatch) return null;
const key = [
event.authMode ?? 'unknown',
event.method ?? 'GET',
event.policyGroup ?? event.proposedRuleId ?? 'unknown',
].join(':');
const timestamp = Number(now());
const counter = counters.get(key) ?? {
key,
totalOccurrences: 0,
suppressedSinceLast: 0,
lastEmittedAt: null,
};
counter.totalOccurrences += 1;
const shouldEmit =
counter.lastEmittedAt === null ||
timestamp - counter.lastEmittedAt >= effectiveIntervalMs;
if (!shouldEmit) {
counter.suppressedSinceLast += 1;
counters.set(key, counter);
return null;
}
const payload = {
...event,
occurrences: counter.totalOccurrences,
suppressedSinceLast: counter.suppressedSinceLast,
shadowLogIntervalMs: effectiveIntervalMs,
};
counter.lastEmittedAt = timestamp;
counter.suppressedSinceLast = 0;
counters.set(key, counter);
emit(payload);
return payload;
}
function snapshot() {
return [...counters.values()].map((counter) => ({ ...counter }));
}
return Object.freeze({ record, snapshot });
}