Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
580 lines
18 KiB
JavaScript
580 lines
18 KiB
JavaScript
/**
|
|
* space/access.js — Space access control checks
|
|
*
|
|
* Extracted from SpaceService.js.
|
|
* Handles checkUserSpaceAccess, getUserAccessData, canAccessColumn,
|
|
* canAccessProject, canAccessTable, canAccessSpace, getEffectiveProjectRole,
|
|
* canAccessSystemDataProject, and internal table-based access helpers.
|
|
*/
|
|
|
|
import { dbGet, dbAll, safeJsonParse } from '../../database/connection.js';
|
|
|
|
/**
|
|
* Check if user has access to a space
|
|
* @param {number} userId - User ID
|
|
* @param {string} userRole - User global role
|
|
* @param {object} space - Space object
|
|
* @param {object} accessControl - Parsed access_control
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export async function checkUserSpaceAccess(userId, userRole, space, accessControl) {
|
|
// 1. Owner always has access
|
|
if (space.owner_id === userId) {
|
|
return true;
|
|
}
|
|
|
|
// 2. Admin/owner role can see admin spaces
|
|
if ((userRole === 'admin' || userRole === 'owner') && space.type === 'admin') {
|
|
return true;
|
|
}
|
|
|
|
// 3. Check user_access_permissions table for explicit grants (ADR-105: AC2)
|
|
// This MUST run before the personal-space gate so that users with explicit
|
|
// grants (e.g. via InvitationService) can access shared personal spaces.
|
|
try {
|
|
const explicitPerm = await dbGet(
|
|
`SELECT access_level FROM user_access_permissions
|
|
WHERE user_id = ? AND space_id = ? AND access_level != 'denied'`,
|
|
[userId, space.id]
|
|
);
|
|
if (explicitPerm) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
// Ignore — fall through to legacy checks
|
|
}
|
|
|
|
// 4. Personal spaces - only owner (after explicit grants check)
|
|
if (space.type === 'personal') {
|
|
return false;
|
|
}
|
|
|
|
// 5. Open spaces — any authenticated user can view (ADR-105: AC2)
|
|
if (space.visibility === 'open') {
|
|
return true;
|
|
}
|
|
|
|
// 4. No access control - only owner
|
|
if (!accessControl) {
|
|
return false;
|
|
}
|
|
|
|
// 5. Old format: members array with user_id
|
|
if (accessControl.members && Array.isArray(accessControl.members)) {
|
|
return accessControl.members.some(m => m.user_id === userId);
|
|
}
|
|
|
|
// 6. New format: users_table_id with role_mappings (from UserAccessPanel)
|
|
if (accessControl.enabled && accessControl.users_table_id) {
|
|
const result = await checkUserAccessViaTableV2(userId, accessControl);
|
|
return result.allowed;
|
|
}
|
|
|
|
// 7. Legacy format: usersTableId with roleMapping
|
|
if (accessControl.enabled && accessControl.usersTableId) {
|
|
const result = await checkUserAccessViaTable(userId, accessControl);
|
|
return result.allowed;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check user access via linked users table - NEW FORMAT (from UserAccessPanel)
|
|
* Config: { enabled, users_table_id, role_column_id, role_mappings: [{ columnValue, accessLevel }] }
|
|
* @param {number} userId - System user ID
|
|
* @param {object} accessControl - Access control config
|
|
* @returns {Promise<{allowed: boolean, accessLevel: string|null}>}
|
|
*/
|
|
export async function checkUserAccessViaTableV2(userId, accessControl) {
|
|
const users_table_id = accessControl.users_table_id || accessControl.usersTableId;
|
|
const role_column_id =
|
|
accessControl.role_column_id ||
|
|
accessControl.roleColumnId ||
|
|
accessControl.role_column ||
|
|
accessControl.roleColumn ||
|
|
'role';
|
|
const user_id_column =
|
|
accessControl.user_id_column ||
|
|
accessControl.userIdColumn ||
|
|
'system_user_id';
|
|
const role_mappings = accessControl.role_mappings;
|
|
|
|
if (!users_table_id || !role_column_id || !role_mappings) {
|
|
return { allowed: false, accessLevel: null };
|
|
}
|
|
|
|
// Get system user email to match
|
|
const systemUser = await dbGet('SELECT id, email FROM users WHERE id = ?', [userId]);
|
|
if (!systemUser) {
|
|
return { allowed: false, accessLevel: null };
|
|
}
|
|
|
|
// Get column info to find the system_user_id or email column
|
|
const columns = await dbAll('SELECT id, column_name FROM table_columns WHERE table_id = ?', [users_table_id]);
|
|
const columnIdMap = {};
|
|
for (const col of columns) {
|
|
columnIdMap[col.id] = col.column_name;
|
|
columnIdMap[col.column_name] = col.id;
|
|
}
|
|
|
|
const getValue = (dataObj, key) => {
|
|
if (dataObj[key] !== undefined) return dataObj[key];
|
|
const asId = columnIdMap[key];
|
|
if (asId && dataObj[asId] !== undefined) return dataObj[asId];
|
|
const asName = columnIdMap[Number(key)] || columnIdMap[String(key)];
|
|
if (asName && dataObj[asName] !== undefined) return dataObj[asName];
|
|
return undefined;
|
|
};
|
|
|
|
// Find user row in the linked table
|
|
const rows = await dbAll(
|
|
'SELECT data FROM table_rows WHERE table_id = ?',
|
|
[users_table_id]
|
|
);
|
|
|
|
for (const row of rows) {
|
|
const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
|
|
// Check if this row is for our user by:
|
|
// 1. system_user_id field matching userId
|
|
// 2. email field matching user email
|
|
let isMatch = false;
|
|
|
|
// Check system_user_id or configured user_id_column (by id or name)
|
|
const userIdVal = getValue(data, user_id_column) ?? getValue(data, 'system_user_id');
|
|
const userIdBound = userIdVal !== undefined && userIdVal !== null && userIdVal !== '';
|
|
if (userIdBound && String(userIdVal) === String(userId)) {
|
|
isMatch = true;
|
|
}
|
|
|
|
// Email fallback only when the row is NOT bound to a specific system_user_id.
|
|
// Otherwise an orphan row (after the original user was deleted) would silently
|
|
// grant access to anyone who later registers with the same email.
|
|
if (!isMatch && !userIdBound) {
|
|
const emailValue = getValue(data, 'email');
|
|
if (emailValue && emailValue.toLowerCase() === systemUser.email.toLowerCase()) {
|
|
isMatch = true;
|
|
}
|
|
}
|
|
|
|
if (!isMatch) continue;
|
|
|
|
// Check if user is active
|
|
const activeColId = columnIdMap['active'];
|
|
const activeValue = data[activeColId] !== undefined ? data[activeColId] : data.active;
|
|
if (activeValue === false || activeValue === 'false' || activeValue === 0) {
|
|
return { allowed: false, accessLevel: 'denied' };
|
|
}
|
|
|
|
// Get user's role from the table using role_column_id
|
|
const userTableRole = getValue(data, role_column_id) ?? getValue(data, 'role');
|
|
if (!userTableRole) {
|
|
continue;
|
|
}
|
|
|
|
// Find matching role_mapping
|
|
const mapping = role_mappings.find(m => m.columnValue === userTableRole);
|
|
|
|
if (mapping) {
|
|
const accessLevel = mapping.accessLevel;
|
|
|
|
// denied = no access
|
|
if (accessLevel === 'denied') {
|
|
return { allowed: false, accessLevel: 'denied' };
|
|
}
|
|
|
|
// Any other access level = allowed
|
|
return { allowed: true, accessLevel };
|
|
}
|
|
|
|
// User is in the table with a role, but role not in mappings
|
|
// Default to viewer access (they were added to the table = intended to have access)
|
|
return { allowed: true, accessLevel: 'viewer' };
|
|
}
|
|
|
|
return { allowed: false, accessLevel: null };
|
|
}
|
|
|
|
/**
|
|
* Check user access via linked users table
|
|
* Role hierarchy: owner > admin > editor > viewer > denied
|
|
* @param {number} userId - System user ID
|
|
* @param {object} accessControl - Access control config
|
|
* @returns {Promise<{allowed: boolean, userData: object|null}>}
|
|
*/
|
|
async function checkUserAccessViaTable(userId, accessControl) {
|
|
const { usersTableId, userIdColumn, roleColumn, roleMapping } = accessControl;
|
|
|
|
if (!usersTableId || !userIdColumn || !roleColumn || !roleMapping) {
|
|
return { allowed: false, userData: null };
|
|
}
|
|
|
|
// Find user row in the linked table
|
|
const rows = await dbAll(
|
|
'SELECT data FROM table_rows WHERE table_id = ?',
|
|
[usersTableId]
|
|
);
|
|
|
|
for (const row of rows) {
|
|
const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
|
|
// Check if this row is for our user
|
|
const rowUserId = data[userIdColumn];
|
|
if (rowUserId !== userId && String(rowUserId) !== String(userId)) {
|
|
continue;
|
|
}
|
|
|
|
// Check if user is active
|
|
if (data.active === false) {
|
|
return { allowed: false, userData: data };
|
|
}
|
|
|
|
// Get user's role from the table
|
|
const userTableRole = data[roleColumn];
|
|
if (!userTableRole) {
|
|
continue;
|
|
}
|
|
|
|
// "denied" role always blocks access at space level
|
|
if (userTableRole === 'denied') {
|
|
return { allowed: false, userData: data };
|
|
}
|
|
|
|
// Check if this role is in roleMapping.denied (space-level block)
|
|
const deniedRoles = roleMapping.denied || [];
|
|
if (deniedRoles.includes(userTableRole)) {
|
|
return { allowed: false, userData: data };
|
|
}
|
|
|
|
// Check if role is in any of the allowed mappings (owner, admin, editor, viewer)
|
|
const allowedRoles = [
|
|
...(roleMapping.owner || []),
|
|
...(roleMapping.admin || []),
|
|
...(roleMapping.editor || []),
|
|
...(roleMapping.viewer || [])
|
|
];
|
|
|
|
if (allowedRoles.includes(userTableRole)) {
|
|
return { allowed: true, userData: data };
|
|
}
|
|
}
|
|
|
|
return { allowed: false, userData: null };
|
|
}
|
|
|
|
/**
|
|
* Get user's granular access data from users table
|
|
* Reads columns by their column IDs from table_columns
|
|
* @param {number} userId - System user ID
|
|
* @param {object} accessControl - Access control config
|
|
* @returns {Promise<object|null>} User data with allowed/denied arrays or null
|
|
*/
|
|
export async function getUserAccessData(userId, accessControl) {
|
|
if (!accessControl?.enabled) {
|
|
return null;
|
|
}
|
|
|
|
const usersTableId =
|
|
accessControl.usersTableId ||
|
|
accessControl.users_table_id ||
|
|
accessControl.users_tableId;
|
|
const userIdColumn =
|
|
accessControl.userIdColumn ||
|
|
accessControl.user_id_column ||
|
|
'system_user_id';
|
|
|
|
if (!usersTableId) {
|
|
return null;
|
|
}
|
|
|
|
// Get column IDs for granular access columns
|
|
const columns = await dbAll(
|
|
'SELECT id, column_name FROM table_columns WHERE table_id = ?',
|
|
[usersTableId]
|
|
);
|
|
|
|
// Map column names to their IDs
|
|
const columnIdMap = {};
|
|
for (const col of columns) {
|
|
columnIdMap[col.column_name] = String(col.id);
|
|
}
|
|
|
|
const rows = await dbAll(
|
|
'SELECT data FROM table_rows WHERE table_id = ?',
|
|
[usersTableId]
|
|
);
|
|
|
|
for (const row of rows) {
|
|
const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
const rowUserId = data[userIdColumn];
|
|
|
|
if (rowUserId === userId || String(rowUserId) === String(userId)) {
|
|
// Helper to get array values from column by name or ID
|
|
const getArrayValue = (colName) => {
|
|
// Try column ID first
|
|
const colId = columnIdMap[colName];
|
|
if (colId && data[colId]) {
|
|
const val = data[colId];
|
|
return Array.isArray(val) ? val : [];
|
|
}
|
|
// Try column name directly
|
|
if (data[colName]) {
|
|
const val = data[colName];
|
|
return Array.isArray(val) ? val : [];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
// Get role - try column name 'role' or column ID
|
|
const roleColId = columnIdMap['role'];
|
|
const role = data[roleColId] || data.role || null;
|
|
|
|
// Get active status
|
|
const activeColId = columnIdMap['active'];
|
|
const active = data[activeColId] !== undefined ? data[activeColId] : (data.active !== undefined ? data.active : true);
|
|
|
|
return {
|
|
role,
|
|
active,
|
|
// Space-level granular access
|
|
allowed_spaces: getArrayValue('allowed_spaces'),
|
|
denied_spaces: getArrayValue('denied_spaces'),
|
|
// Project-level granular access
|
|
allowed_projects: getArrayValue('allowed_projects'),
|
|
denied_projects: getArrayValue('denied_projects'),
|
|
// Table-level granular access
|
|
allowed_tables: getArrayValue('allowed_tables'),
|
|
denied_tables: getArrayValue('denied_tables'),
|
|
// Column-level restrictions
|
|
allowed_columns: getArrayValue('allowed_columns'),
|
|
denied_columns: getArrayValue('denied_columns')
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if user can see a specific column based on granular access
|
|
* @param {object} userAccessData - User's access data from getUserAccessData
|
|
* @param {number} columnId - Column ID to check
|
|
* @returns {boolean}
|
|
*/
|
|
export function canAccessColumn(userAccessData, columnId) {
|
|
if (!userAccessData) return false;
|
|
|
|
// Denied role blocks all access
|
|
if (userAccessData.role === 'denied') return false;
|
|
|
|
// Inactive users have no access
|
|
if (userAccessData.active === false) return false;
|
|
|
|
const columnIdStr = String(columnId);
|
|
const { allowed_columns, denied_columns, role } = userAccessData;
|
|
|
|
// Owner and Admin roles have full access unless explicitly denied
|
|
if ((role === 'owner' || role === 'admin') &&
|
|
(!denied_columns?.length || !denied_columns.includes(columnIdStr))) {
|
|
return true;
|
|
}
|
|
|
|
// Check denied list first (takes priority)
|
|
if (denied_columns?.length && denied_columns.includes(columnIdStr)) {
|
|
return false;
|
|
}
|
|
|
|
// If allowed list is empty - allow all (no restrictions)
|
|
if (!allowed_columns?.length) {
|
|
return true;
|
|
}
|
|
|
|
// Check allowed list
|
|
return allowed_columns.includes(columnIdStr);
|
|
}
|
|
|
|
/**
|
|
* Check if user can access a specific project based on granular access
|
|
* Role hierarchy: owner > admin > editor > viewer > denied
|
|
* @param {object} userAccessData - User's access data from getUserAccessData
|
|
* @param {number} projectId - Project ID to check
|
|
* @returns {boolean}
|
|
*/
|
|
export function canAccessProject(userAccessData, projectId) {
|
|
if (!userAccessData) return false;
|
|
|
|
// Denied role blocks all access
|
|
if (userAccessData.role === 'denied') return false;
|
|
|
|
// Inactive users have no access
|
|
if (userAccessData.active === false) return false;
|
|
|
|
const projectIdStr = String(projectId);
|
|
const { allowed_projects, denied_projects, role } = userAccessData;
|
|
|
|
// Owner and Admin roles have full access unless explicitly denied
|
|
if ((role === 'owner' || role === 'admin') &&
|
|
(!denied_projects?.length || !denied_projects.includes(projectIdStr))) {
|
|
return true;
|
|
}
|
|
|
|
// Check denied list first (takes priority)
|
|
if (denied_projects?.length && denied_projects.includes(projectIdStr)) {
|
|
return false;
|
|
}
|
|
|
|
// If allowed list is empty - allow all (no restrictions)
|
|
if (!allowed_projects?.length) {
|
|
return true;
|
|
}
|
|
|
|
// Check allowed list
|
|
return allowed_projects.includes(projectIdStr);
|
|
}
|
|
|
|
/**
|
|
* Check if user can access a specific table based on granular access
|
|
* Role hierarchy: owner > admin > editor > viewer > denied
|
|
* @param {object} userAccessData - User's access data from getUserAccessData
|
|
* @param {number} tableId - Table ID to check
|
|
* @returns {boolean}
|
|
*/
|
|
export function canAccessTable(userAccessData, tableId) {
|
|
if (!userAccessData) return false;
|
|
|
|
// Denied role blocks all access
|
|
if (userAccessData.role === 'denied') return false;
|
|
|
|
// Inactive users have no access
|
|
if (userAccessData.active === false) return false;
|
|
|
|
const tableIdStr = String(tableId);
|
|
const { allowed_tables, denied_tables, role } = userAccessData;
|
|
|
|
// Owner and Admin roles have full access unless explicitly denied
|
|
if ((role === 'owner' || role === 'admin') &&
|
|
(!denied_tables?.length || !denied_tables.includes(tableIdStr))) {
|
|
return true;
|
|
}
|
|
|
|
// Check denied list first (takes priority)
|
|
if (denied_tables?.length && denied_tables.includes(tableIdStr)) {
|
|
return false;
|
|
}
|
|
|
|
// If allowed list is empty - allow all (no restrictions)
|
|
if (!allowed_tables?.length) {
|
|
return true;
|
|
}
|
|
|
|
// Check allowed list
|
|
return allowed_tables.includes(tableIdStr);
|
|
}
|
|
|
|
/**
|
|
* Get effective role for a project (with System Data role downgrade)
|
|
* System Data projects have stricter access:
|
|
* - admin -> editor
|
|
* - editor -> viewer
|
|
* - viewer -> denied
|
|
* @param {object} userAccessData - User's access data
|
|
* @param {object} project - Project object (needs type field)
|
|
* @returns {string|null} Effective role for this project
|
|
*/
|
|
export function getEffectiveProjectRole(userAccessData, project) {
|
|
if (!userAccessData || !userAccessData.role) return null;
|
|
|
|
const baseRole = userAccessData.role;
|
|
|
|
// If not System Data project, return base role
|
|
if (project?.type !== 'system_data') {
|
|
return baseRole;
|
|
}
|
|
|
|
// System Data: downgrade roles
|
|
// owner stays owner (can manage system data)
|
|
// admin -> editor (can edit but not admin functions)
|
|
// editor -> viewer (can view only)
|
|
// viewer -> denied (no access to system data)
|
|
const roleDowngrade = {
|
|
'owner': 'owner',
|
|
'admin': 'editor',
|
|
'editor': 'viewer',
|
|
'viewer': 'denied',
|
|
'denied': 'denied'
|
|
};
|
|
|
|
return roleDowngrade[baseRole] || baseRole;
|
|
}
|
|
|
|
/**
|
|
* Check if user can access a System Data project
|
|
* Applies role downgrade: admin->editor, editor->viewer, viewer->denied
|
|
* @param {object} userAccessData - User's access data
|
|
* @param {number} projectId - Project ID
|
|
* @returns {Promise<{allowed: boolean, effectiveRole: string|null}>}
|
|
*/
|
|
export async function canAccessSystemDataProject(userAccessData, projectId) {
|
|
if (!userAccessData) {
|
|
return { allowed: false, effectiveRole: null };
|
|
}
|
|
|
|
// Get project info
|
|
const project = await dbGet('SELECT id, type FROM projects WHERE id = ?', [projectId]);
|
|
|
|
if (!project) {
|
|
return { allowed: false, effectiveRole: null };
|
|
}
|
|
|
|
// Get effective role (with downgrade for System Data)
|
|
const effectiveRole = getEffectiveProjectRole(userAccessData, project);
|
|
|
|
// Check if denied
|
|
if (effectiveRole === 'denied') {
|
|
return { allowed: false, effectiveRole };
|
|
}
|
|
|
|
// Check normal project access with effective role
|
|
const canAccess = canAccessProject({ ...userAccessData, role: effectiveRole }, projectId);
|
|
|
|
return { allowed: canAccess, effectiveRole };
|
|
}
|
|
|
|
/**
|
|
* Check if user can access a space based on granular settings
|
|
* @param {object} userAccessData - User's access data from getUserAccessData
|
|
* @param {number} spaceId - Space ID to check
|
|
* @returns {boolean}
|
|
*/
|
|
export function canAccessSpace(userAccessData, spaceId) {
|
|
if (!userAccessData) return false;
|
|
|
|
// Denied role blocks all access
|
|
if (userAccessData.role === 'denied') return false;
|
|
|
|
// Inactive users have no access
|
|
if (userAccessData.active === false) return false;
|
|
|
|
const spaceIdStr = String(spaceId);
|
|
const { allowed_spaces, denied_spaces, role } = userAccessData;
|
|
|
|
// Owner role has full access unless explicitly denied
|
|
if (role === 'owner' &&
|
|
(!denied_spaces?.length || !denied_spaces.includes(spaceIdStr))) {
|
|
return true;
|
|
}
|
|
|
|
// Check denied list first (takes priority)
|
|
if (denied_spaces?.length && denied_spaces.includes(spaceIdStr)) {
|
|
return false;
|
|
}
|
|
|
|
// If allowed list is empty - allow all (no restrictions)
|
|
if (!allowed_spaces?.length) {
|
|
return true;
|
|
}
|
|
|
|
// Check allowed list
|
|
return allowed_spaces.includes(spaceIdStr);
|
|
}
|