mirror of
https://github.com/AeThex-Corporation/AeThex-OS.git
synced 2026-04-17 22:27:19 +00:00
- Add revenue_events table to track org/project revenue with source tracking - Add Drizzle schema for revenue_events with proper org/project references - Create migration 0006_revenue_events.sql with indexes - Fix migration 0004: Remove FK constraints to profiles.id (auth schema incompatibility) - Document auth.users/profiles.id type mismatch (UUID vs VARCHAR) - Harden profile update authorization (self-update or org admin/owner only) - Complete org-scoping security audit implementation (42 gaps closed)
194 lines
5.3 KiB
TypeScript
194 lines
5.3 KiB
TypeScript
import { Request, Response, NextFunction } from "express";
|
|
import { supabase } from "./supabase.js";
|
|
|
|
// Extend Express Request to include org context
|
|
declare global {
|
|
namespace Express {
|
|
interface Request {
|
|
orgId?: string;
|
|
orgRole?: string;
|
|
orgMemberId?: string;
|
|
orgMembership?: {
|
|
id: string;
|
|
organization_id: string;
|
|
user_id: string;
|
|
role: string;
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Middleware: Attach organization context to request
|
|
* Looks for org ID in header 'x-org-id' or session
|
|
* Non-blocking - sets orgId if found, continues if not
|
|
*/
|
|
export async function attachOrgContext(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const userId = req.session.userId;
|
|
if (!userId) {
|
|
return next(); // No user, no org context
|
|
}
|
|
|
|
// Try to get org ID from header first
|
|
let orgId = req.headers['x-org-id'] as string;
|
|
|
|
// If no header, try session (if we add session-based org selection later)
|
|
if (!orgId && (req.session as any).currentOrgId) {
|
|
orgId = (req.session as any).currentOrgId;
|
|
}
|
|
|
|
// If still no org, try to get user's default/first org
|
|
if (!orgId) {
|
|
const { data: membership } = await supabase
|
|
.from('organization_members')
|
|
.select('organization_id')
|
|
.eq('user_id', userId)
|
|
.limit(1)
|
|
.single();
|
|
|
|
if (membership) {
|
|
orgId = membership.organization_id;
|
|
}
|
|
}
|
|
|
|
// If we have an org, verify membership and attach context
|
|
if (orgId) {
|
|
const { data: membership, error } = await supabase
|
|
.from('organization_members')
|
|
.select('*')
|
|
.eq('organization_id', orgId)
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (!error && membership) {
|
|
req.orgId = orgId;
|
|
req.orgRole = membership.role;
|
|
req.orgMemberId = membership.id;
|
|
req.orgMembership = membership;
|
|
}
|
|
}
|
|
|
|
next();
|
|
} catch (error) {
|
|
console.error('Error attaching org context:', error);
|
|
next(); // Continue even on error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Middleware: Require organization membership
|
|
* Must be used after attachOrgContext
|
|
*/
|
|
export function requireOrgMember(req: Request, res: Response, next: NextFunction) {
|
|
if (!req.orgId || !req.orgRole) {
|
|
return res.status(400).json({
|
|
error: "Organization context required",
|
|
message: "Please select an organization (x-org-id header) to access this resource"
|
|
});
|
|
}
|
|
next();
|
|
}
|
|
|
|
/**
|
|
* Middleware: Require specific org role
|
|
*/
|
|
export function requireOrgRole(minRole: 'owner' | 'admin' | 'member' | 'viewer') {
|
|
const roleHierarchy = ['viewer', 'member', 'admin', 'owner'];
|
|
const minLevel = roleHierarchy.indexOf(minRole);
|
|
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
if (!req.orgRole) {
|
|
return res.status(403).json({ error: "Organization role required" });
|
|
}
|
|
|
|
const userLevel = roleHierarchy.indexOf(req.orgRole);
|
|
if (userLevel < minLevel) {
|
|
return res.status(403).json({
|
|
error: "Insufficient permissions",
|
|
required: minRole,
|
|
current: req.orgRole
|
|
});
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper: Check if user has access to a project
|
|
* Returns true if user is:
|
|
* - Project owner
|
|
* - Project collaborator with sufficient role
|
|
* - Org member (if project is in an org)
|
|
*/
|
|
export async function assertProjectAccess(
|
|
projectId: string,
|
|
userId: string,
|
|
minRole: 'owner' | 'admin' | 'contributor' | 'viewer' = 'viewer'
|
|
): Promise<{ hasAccess: boolean; reason?: string; project?: any }> {
|
|
try {
|
|
// Get project
|
|
const { data: project, error: projectError } = await supabase
|
|
.from('projects')
|
|
.select('*')
|
|
.eq('id', projectId)
|
|
.single();
|
|
|
|
if (projectError || !project) {
|
|
return { hasAccess: false, reason: 'Project not found' };
|
|
}
|
|
|
|
// Check if user is owner
|
|
const ownerId = project.owner_user_id || project.user_id || project.owner_id;
|
|
if (ownerId === userId) {
|
|
return { hasAccess: true, project };
|
|
}
|
|
|
|
// Check collaborator status
|
|
const { data: collab } = await supabase
|
|
.from('project_collaborators')
|
|
.select('role')
|
|
.eq('project_id', projectId)
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (collab) {
|
|
const roleHierarchy = ['viewer', 'contributor', 'admin', 'owner'];
|
|
const userLevel = roleHierarchy.indexOf(collab.role);
|
|
const minLevel = roleHierarchy.indexOf(minRole);
|
|
|
|
if (userLevel >= minLevel) {
|
|
return { hasAccess: true, project };
|
|
}
|
|
}
|
|
|
|
// Check org membership (if project is in an org)
|
|
if (project.organization_id) {
|
|
const { data: orgMember } = await supabase
|
|
.from('organization_members')
|
|
.select('role')
|
|
.eq('organization_id', project.organization_id)
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (orgMember) {
|
|
// Org members can at least view org projects
|
|
if (minRole === 'viewer') {
|
|
return { hasAccess: true, project };
|
|
}
|
|
|
|
// Admin+ can manage
|
|
if (['admin', 'owner'].includes(orgMember.role)) {
|
|
return { hasAccess: true, project };
|
|
}
|
|
}
|
|
}
|
|
|
|
return { hasAccess: false, reason: 'Insufficient permissions' };
|
|
} catch (error) {
|
|
console.error('Error checking project access:', error);
|
|
return { hasAccess: false, reason: 'Access check failed' };
|
|
}
|
|
}
|
|
|