caterium-app/supabase/functions/caterium-platform-auth-admin/index.ts
pavlov346346-source aeca9eae99 fix: lock down caterium-platform-auth-admin CORS to allowlisted origins
This Edge Function grants platform-admin power (list every user across
every workspace, ban/unban accounts, trigger password resets for any
user_id) but answered with Access-Control-Allow-Origin: '*', unlike the
sibling caterium-create-employee function which already uses an origin
allowlist. Authorization itself was never bypassable this way (the
function still requires the caller's own Bearer token and re-checks
sun_is_platform_admin() server-side), but a wildcard CORS response
removes a real layer of defense-in-depth if a platform-admin token were
ever exposed to another origin.

Applies the same allowedOrigin()/corsHeaders() pattern already proven in
caterium-create-employee, and extends edge-security-v1774.mjs (which
already asserted the wildcard was gone from create-employee, but never
checked this function) to cover both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 14:09:16 +03:00

102 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'
const PROD_ORIGINS = new Set([
'https://app.caterium.ru',
'https://caterium.ru',
'https://www.caterium.ru',
])
const WORKERS_DEV_ORIGIN = /^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev$/i
const LOCAL_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d{1,5})?$/i
function allowedOrigin(origin: string) {
if (!origin) return true
return PROD_ORIGINS.has(origin) || WORKERS_DEV_ORIGIN.test(origin) || LOCAL_ORIGIN.test(origin)
}
function corsHeaders(req: Request) {
const origin = req.headers.get('Origin') || ''
const headers: Record<string, string> = {
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Content-Type': 'application/json',
'Vary': 'Origin',
}
if (origin && allowedOrigin(origin)) headers['Access-Control-Allow-Origin'] = origin
return headers
}
const json = (req: Request, body: unknown, status = 200) => new Response(JSON.stringify(body), {status, headers: corsHeaders(req)})
Deno.serve(async (req) => {
const origin = req.headers.get('Origin') || ''
if (origin && !allowedOrigin(origin)) return json(req, {error: 'Origin is not allowed', code: 'origin_not_allowed'}, 403)
if (req.method === 'OPTIONS') return new Response('ok', {headers: corsHeaders(req)})
if (req.method !== 'POST') return json(req, {error:'Method not allowed'},405)
try {
const url = Deno.env.get('SUPABASE_URL')!
const anon = Deno.env.get('SUPABASE_ANON_KEY')!
const service = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
const authHeader = req.headers.get('Authorization') || ''
const userClient = createClient(url, anon, {global:{headers:{Authorization:authHeader}},auth:{persistSession:false,autoRefreshToken:false}})
const {data:{user},error:userError} = await userClient.auth.getUser()
if (userError || !user) return json(req, {error:'Unauthorized'},401)
const {data:isAdmin,error:adminError} = await userClient.rpc('sun_is_platform_admin')
if (adminError || isAdmin !== true) return json(req, {error:'Platform administrator required'},403)
const body = await req.json().catch(()=>({})) as Record<string,unknown>
const action = String(body.action || 'list')
const admin = createClient(url, service, {auth:{persistSession:false,autoRefreshToken:false}})
if (action === 'list') {
const users:any[] = []
for (let page=1; page<=20; page++) {
const {data,error} = await admin.auth.admin.listUsers({page,perPage:1000})
if (error) throw error
users.push(...(data.users||[]))
if ((data.users||[]).length < 1000) break
}
const {data:directory,error:dirError} = await userClient.rpc('sun_platform_list_users')
if (dirError) throw dirError
const byId = new Map<string,any>()
for (const row of (directory||[])) {
const id=String(row.user_id)
if(!byId.has(id))byId.set(id,{is_platform_admin:row.is_platform_admin===true,workspaces:[]})
if(row.workspace_id)byId.get(id).workspaces.push({id:row.workspace_id,name:row.workspace_name,role:row.role,display_name:row.display_name,is_active:row.is_active!==false})
}
return json(req, {users:users.map(u=>({
id:u.id,email:u.email||'',created_at:u.created_at||'',last_sign_in_at:u.last_sign_in_at||'',
email_confirmed_at:u.email_confirmed_at||u.confirmed_at||'',banned_until:u.banned_until||'',
is_banned:Boolean(u.banned_until&&new Date(u.banned_until)>new Date()),
is_platform_admin:byId.get(String(u.id))?.is_platform_admin===true,
workspaces:byId.get(String(u.id))?.workspaces||[]
}))})
}
const targetId = String(body.user_id || '')
if (!targetId) return json(req, {error:'user_id required'},400)
if (targetId === user.id && action === 'set_ban') return json(req, {error:'Нельзя заблокировать собственный аккаунт разработчика.'},400)
const {data:{user:target},error:targetError}=await admin.auth.admin.getUserById(targetId)
if(targetError||!target)return json(req, {error:'User not found'},404)
if (action === 'set_ban') {
const banned = body.banned === true
const {error}=await admin.auth.admin.updateUserById(targetId,{ban_duration:banned?'876000h':'none'})
if(error)throw error
await userClient.rpc('sun_platform_log_event',{p_action:banned?'account.ban':'account.unban',p_workspace:null,p_user:targetId,p_details:{email:target.email||''}})
return json(req, {ok:true,banned})
}
if (action === 'send_recovery') {
if(!target.email)return json(req, {error:'У пользователя нет email.'},400)
const publicClient=createClient(url,anon,{auth:{persistSession:false,autoRefreshToken:false}})
const {error}=await publicClient.auth.resetPasswordForEmail(target.email)
if(error)throw error
await userClient.rpc('sun_platform_log_event',{p_action:'account.recovery_sent',p_workspace:null,p_user:targetId,p_details:{email:target.email}})
return json(req, {ok:true})
}
return json(req, {error:'Unknown action'},400)
} catch (e) {
console.error(e)
return json(req, {error:e instanceof Error?e.message:String(e)},500)
}
})