Implement granular permission system with organization and entity-level access control:
Services:
- authorization.service.js: Permission management (grant, revoke, check entity permissions, organization membership)
- organization.service.js: Organization CRUD operations and statistics
Routes:
- organization.routes.js: 9 organization endpoints (create, list, update, delete, members management, stats)
- permission.routes.js: 5 permission endpoints (grant, revoke, list, check permissions)
Features:
- Multi-tenancy with organizations
- 4-tier permission hierarchy (viewer < editor < manager < admin)
- Entity-level granular permissions
- Organization role-based access control
- Permission expiration support
- Super admin delegation (org admins can grant permissions to users for entities)
Middleware Enhancements:
- requireOrganizationMember: Verify org membership
- requireOrganizationRole: Check role level
- requireEntityPermission: Verify entity access
Use Case:
Agency admins can grant specific boat access to technicians, captains, and office staff with different permission levels
Cross-Vertical Compatible:
Works for marine (boats), aviation (aircraft), vehicles, or any entity type
🤖 Generated with Claude Code
484 lines
13 KiB
JavaScript
484 lines
13 KiB
JavaScript
/**
|
|
* Authorization Service
|
|
*
|
|
* Handles permission management for entities and organizations
|
|
* Supports granular access control across multi-tenant architecture
|
|
*/
|
|
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { getDb } from '../config/db.js';
|
|
import { logAuditEvent } from './audit.service.js';
|
|
|
|
// Permission levels (ordered by hierarchy)
|
|
const PERMISSION_LEVELS = {
|
|
viewer: 0,
|
|
editor: 1,
|
|
manager: 2,
|
|
admin: 3
|
|
};
|
|
|
|
const ORGANIZATION_ROLES = {
|
|
viewer: 0,
|
|
member: 1,
|
|
manager: 2,
|
|
admin: 3
|
|
};
|
|
|
|
/**
|
|
* Grant permission to access an entity
|
|
*
|
|
* @param {Object} params - Permission parameters
|
|
* @param {string} params.userId - User receiving permission
|
|
* @param {string} params.entityId - Entity being granted access to
|
|
* @param {string} params.permissionLevel - Permission level (viewer, editor, manager, admin)
|
|
* @param {string} params.grantedBy - User granting the permission
|
|
* @param {number} params.expiresAt - Optional expiration timestamp
|
|
* @returns {Promise<Object>} Created permission
|
|
*/
|
|
export async function grantEntityPermission({ userId, entityId, permissionLevel, grantedBy, expiresAt = null }) {
|
|
const db = getDb();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Validate permission level
|
|
if (!PERMISSION_LEVELS.hasOwnProperty(permissionLevel)) {
|
|
throw new Error(`Invalid permission level: ${permissionLevel}. Must be one of: viewer, editor, manager, admin`);
|
|
}
|
|
|
|
// Check if entity exists
|
|
const entity = db.prepare('SELECT id, organization_id FROM entities WHERE id = ?').get(entityId);
|
|
if (!entity) {
|
|
throw new Error('Entity not found');
|
|
}
|
|
|
|
// Check if user exists
|
|
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
|
|
if (!user) {
|
|
throw new Error('User not found');
|
|
}
|
|
|
|
// Check if permission already exists
|
|
const existing = db.prepare(`
|
|
SELECT id, permission_level FROM entity_permissions
|
|
WHERE user_id = ? AND entity_id = ?
|
|
`).get(userId, entityId);
|
|
|
|
let permissionId;
|
|
|
|
if (existing) {
|
|
// Update existing permission
|
|
permissionId = existing.id;
|
|
db.prepare(`
|
|
UPDATE entity_permissions
|
|
SET permission_level = ?,
|
|
granted_by = ?,
|
|
granted_at = ?,
|
|
expires_at = ?
|
|
WHERE id = ?
|
|
`).run(permissionLevel, grantedBy, now, expiresAt, permissionId);
|
|
} else {
|
|
// Create new permission
|
|
permissionId = uuidv4();
|
|
db.prepare(`
|
|
INSERT INTO entity_permissions (
|
|
id, user_id, entity_id, permission_level, granted_by, granted_at, expires_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`).run(permissionId, userId, entityId, permissionLevel, grantedBy, now, expiresAt);
|
|
}
|
|
|
|
await logAuditEvent({
|
|
userId: grantedBy,
|
|
eventType: 'permission.grant',
|
|
resourceType: 'entity',
|
|
resourceId: entityId,
|
|
status: 'success',
|
|
metadata: JSON.stringify({
|
|
targetUserId: userId,
|
|
permissionLevel,
|
|
expiresAt,
|
|
action: existing ? 'update' : 'create'
|
|
})
|
|
});
|
|
|
|
return {
|
|
id: permissionId,
|
|
userId,
|
|
entityId,
|
|
permissionLevel,
|
|
grantedBy,
|
|
grantedAt: now,
|
|
expiresAt
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Revoke entity permission
|
|
*
|
|
* @param {Object} params - Revocation parameters
|
|
* @param {string} params.userId - User whose permission is being revoked
|
|
* @param {string} params.entityId - Entity to revoke access from
|
|
* @param {string} params.revokedBy - User performing the revocation
|
|
* @returns {Promise<Object>} Revocation result
|
|
*/
|
|
export async function revokeEntityPermission({ userId, entityId, revokedBy }) {
|
|
const db = getDb();
|
|
|
|
const permission = db.prepare(`
|
|
SELECT id, permission_level FROM entity_permissions
|
|
WHERE user_id = ? AND entity_id = ?
|
|
`).get(userId, entityId);
|
|
|
|
if (!permission) {
|
|
throw new Error('Permission not found');
|
|
}
|
|
|
|
db.prepare('DELETE FROM entity_permissions WHERE id = ?').run(permission.id);
|
|
|
|
await logAuditEvent({
|
|
userId: revokedBy,
|
|
eventType: 'permission.revoke',
|
|
resourceType: 'entity',
|
|
resourceId: entityId,
|
|
status: 'success',
|
|
metadata: JSON.stringify({
|
|
targetUserId: userId,
|
|
previousPermissionLevel: permission.permission_level
|
|
})
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
userId,
|
|
entityId,
|
|
revokedPermissionLevel: permission.permission_level
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if user has permission for entity
|
|
*
|
|
* @param {string} userId - User ID
|
|
* @param {string} entityId - Entity ID
|
|
* @param {string} minimumPermission - Minimum required permission level
|
|
* @returns {Promise<Object>} Permission check result
|
|
*/
|
|
export async function checkEntityPermission(userId, entityId, minimumPermission = 'viewer') {
|
|
const db = getDb();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const permission = db.prepare(`
|
|
SELECT permission_level, expires_at
|
|
FROM entity_permissions
|
|
WHERE user_id = ? AND entity_id = ?
|
|
`).get(userId, entityId);
|
|
|
|
if (!permission) {
|
|
return {
|
|
hasPermission: false,
|
|
reason: 'No permission found'
|
|
};
|
|
}
|
|
|
|
// Check if permission is expired
|
|
if (permission.expires_at && permission.expires_at < now) {
|
|
return {
|
|
hasPermission: false,
|
|
reason: 'Permission expired'
|
|
};
|
|
}
|
|
|
|
// Check if permission level is sufficient
|
|
const userLevel = PERMISSION_LEVELS[permission.permission_level] ?? -1;
|
|
const requiredLevel = PERMISSION_LEVELS[minimumPermission] ?? 999;
|
|
|
|
if (userLevel < requiredLevel) {
|
|
return {
|
|
hasPermission: false,
|
|
reason: 'Insufficient permission level',
|
|
currentLevel: permission.permission_level,
|
|
requiredLevel: minimumPermission
|
|
};
|
|
}
|
|
|
|
return {
|
|
hasPermission: true,
|
|
permissionLevel: permission.permission_level,
|
|
expiresAt: permission.expires_at
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all entity permissions for a user
|
|
*
|
|
* @param {string} userId - User ID
|
|
* @param {Object} options - Query options
|
|
* @param {boolean} options.includeExpired - Include expired permissions
|
|
* @returns {Array} User's entity permissions
|
|
*/
|
|
export function getUserEntityPermissions(userId, options = {}) {
|
|
const db = getDb();
|
|
const { includeExpired = false } = options;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
let query = `
|
|
SELECT ep.*, e.name as entity_name, e.type as entity_type
|
|
FROM entity_permissions ep
|
|
JOIN entities e ON ep.entity_id = e.id
|
|
WHERE ep.user_id = ?
|
|
`;
|
|
|
|
if (!includeExpired) {
|
|
query += ` AND (ep.expires_at IS NULL OR ep.expires_at > ${now})`;
|
|
}
|
|
|
|
query += ' ORDER BY ep.granted_at DESC';
|
|
|
|
return db.prepare(query).all(userId);
|
|
}
|
|
|
|
/**
|
|
* Get all permissions for an entity
|
|
*
|
|
* @param {string} entityId - Entity ID
|
|
* @param {Object} options - Query options
|
|
* @param {boolean} options.includeExpired - Include expired permissions
|
|
* @returns {Array} Entity permissions
|
|
*/
|
|
export function getEntityPermissions(entityId, options = {}) {
|
|
const db = getDb();
|
|
const { includeExpired = false } = options;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
let query = `
|
|
SELECT ep.*, u.email as user_email, u.name as user_name
|
|
FROM entity_permissions ep
|
|
JOIN users u ON ep.user_id = u.id
|
|
WHERE ep.entity_id = ?
|
|
`;
|
|
|
|
if (!includeExpired) {
|
|
query += ` AND (ep.expires_at IS NULL OR ep.expires_at > ${now})`;
|
|
}
|
|
|
|
query += ' ORDER BY ep.granted_at DESC';
|
|
|
|
return db.prepare(query).all(entityId);
|
|
}
|
|
|
|
/**
|
|
* Add user to organization
|
|
*
|
|
* @param {Object} params - Membership parameters
|
|
* @param {string} params.userId - User to add
|
|
* @param {string} params.organizationId - Organization ID
|
|
* @param {string} params.role - Organization role (viewer, member, manager, admin)
|
|
* @param {string} params.addedBy - User performing the action
|
|
* @returns {Promise<Object>} Membership result
|
|
*/
|
|
export async function addOrganizationMember({ userId, organizationId, role = 'member', addedBy }) {
|
|
const db = getDb();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Validate role
|
|
if (!ORGANIZATION_ROLES.hasOwnProperty(role)) {
|
|
throw new Error(`Invalid role: ${role}. Must be one of: viewer, member, manager, admin`);
|
|
}
|
|
|
|
// Check if organization exists
|
|
const org = db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(organizationId);
|
|
if (!org) {
|
|
throw new Error('Organization not found');
|
|
}
|
|
|
|
// Check if user exists
|
|
const user = db.prepare('SELECT id, email FROM users WHERE id = ?').get(userId);
|
|
if (!user) {
|
|
throw new Error('User not found');
|
|
}
|
|
|
|
// Check if already a member
|
|
const existing = db.prepare(`
|
|
SELECT role FROM user_organizations
|
|
WHERE user_id = ? AND organization_id = ?
|
|
`).get(userId, organizationId);
|
|
|
|
if (existing) {
|
|
// Update role
|
|
db.prepare(`
|
|
UPDATE user_organizations
|
|
SET role = ?
|
|
WHERE user_id = ? AND organization_id = ?
|
|
`).run(role, userId, organizationId);
|
|
} else {
|
|
// Add as new member
|
|
db.prepare(`
|
|
INSERT INTO user_organizations (user_id, organization_id, role, joined_at)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(userId, organizationId, role, now);
|
|
}
|
|
|
|
await logAuditEvent({
|
|
userId: addedBy,
|
|
eventType: 'organization.member_add',
|
|
resourceType: 'organization',
|
|
resourceId: organizationId,
|
|
status: 'success',
|
|
metadata: JSON.stringify({
|
|
targetUserId: userId,
|
|
targetUserEmail: user.email,
|
|
role,
|
|
action: existing ? 'update' : 'add'
|
|
})
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
userId,
|
|
organizationId,
|
|
role,
|
|
action: existing ? 'updated' : 'added'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove user from organization
|
|
*
|
|
* @param {Object} params - Removal parameters
|
|
* @param {string} params.userId - User to remove
|
|
* @param {string} params.organizationId - Organization ID
|
|
* @param {string} params.removedBy - User performing the action
|
|
* @returns {Promise<Object>} Removal result
|
|
*/
|
|
export async function removeOrganizationMember({ userId, organizationId, removedBy }) {
|
|
const db = getDb();
|
|
|
|
const membership = db.prepare(`
|
|
SELECT role FROM user_organizations
|
|
WHERE user_id = ? AND organization_id = ?
|
|
`).get(userId, organizationId);
|
|
|
|
if (!membership) {
|
|
throw new Error('User is not a member of this organization');
|
|
}
|
|
|
|
db.prepare(`
|
|
DELETE FROM user_organizations
|
|
WHERE user_id = ? AND organization_id = ?
|
|
`).run(userId, organizationId);
|
|
|
|
await logAuditEvent({
|
|
userId: removedBy,
|
|
eventType: 'organization.member_remove',
|
|
resourceType: 'organization',
|
|
resourceId: organizationId,
|
|
status: 'success',
|
|
metadata: JSON.stringify({
|
|
targetUserId: userId,
|
|
previousRole: membership.role
|
|
})
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
userId,
|
|
organizationId,
|
|
removedRole: membership.role
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if user is member of organization
|
|
*
|
|
* @param {string} userId - User ID
|
|
* @param {string} organizationId - Organization ID
|
|
* @param {string} minimumRole - Minimum required role
|
|
* @returns {Object} Membership check result
|
|
*/
|
|
export function checkOrganizationMembership(userId, organizationId, minimumRole = 'viewer') {
|
|
const db = getDb();
|
|
|
|
const membership = db.prepare(`
|
|
SELECT role FROM user_organizations
|
|
WHERE user_id = ? AND organization_id = ?
|
|
`).get(userId, organizationId);
|
|
|
|
if (!membership) {
|
|
return {
|
|
isMember: false,
|
|
reason: 'Not a member'
|
|
};
|
|
}
|
|
|
|
const userRoleLevel = ORGANIZATION_ROLES[membership.role] ?? -1;
|
|
const requiredRoleLevel = ORGANIZATION_ROLES[minimumRole] ?? 999;
|
|
|
|
if (userRoleLevel < requiredRoleLevel) {
|
|
return {
|
|
isMember: true,
|
|
hasSufficientRole: false,
|
|
currentRole: membership.role,
|
|
requiredRole: minimumRole
|
|
};
|
|
}
|
|
|
|
return {
|
|
isMember: true,
|
|
hasSufficientRole: true,
|
|
role: membership.role
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all organization members
|
|
*
|
|
* @param {string} organizationId - Organization ID
|
|
* @returns {Array} Organization members
|
|
*/
|
|
export function getOrganizationMembers(organizationId) {
|
|
const db = getDb();
|
|
|
|
return db.prepare(`
|
|
SELECT uo.*, u.email, u.name, u.email_verified
|
|
FROM user_organizations uo
|
|
JOIN users u ON uo.user_id = u.id
|
|
WHERE uo.organization_id = ?
|
|
ORDER BY uo.joined_at DESC
|
|
`).all(organizationId);
|
|
}
|
|
|
|
/**
|
|
* Get user's organizations
|
|
*
|
|
* @param {string} userId - User ID
|
|
* @returns {Array} User's organizations
|
|
*/
|
|
export function getUserOrganizations(userId) {
|
|
const db = getDb();
|
|
|
|
return db.prepare(`
|
|
SELECT uo.role, uo.joined_at, o.*
|
|
FROM user_organizations uo
|
|
JOIN organizations o ON uo.organization_id = o.id
|
|
WHERE uo.user_id = ?
|
|
ORDER BY uo.joined_at DESC
|
|
`).all(userId);
|
|
}
|
|
|
|
/**
|
|
* Cleanup expired entity permissions
|
|
*
|
|
* @returns {Object} Cleanup result
|
|
*/
|
|
export function cleanupExpiredPermissions() {
|
|
const db = getDb();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const result = db.prepare(`
|
|
DELETE FROM entity_permissions
|
|
WHERE expires_at IS NOT NULL AND expires_at < ?
|
|
`).run(now);
|
|
|
|
return {
|
|
deleted: result.changes,
|
|
timestamp: now
|
|
};
|
|
}
|