fix(desktop+supabase): OAuth 로그인 완주 + RLS 재귀 픽스 (빅뱅 Phase 5 Part 1)
빅뱅 Phase 5 실증 중 발견한 3개 버그 해결:
1. implicit flow 응답 무시:
- supabase-js가 persistSession:false 일 때 PKCE code_verifier 저장 못해
implicit flow로 fallback, fragment(#access_token=...&refresh_token=...)로
토큰 전달. handleDeepLink는 ?code=만 파싱하고 있어 무시됨.
- handleDeepLink에 fragment parser 추가 (access_token + refresh_token)
- CloudSyncService.handleAuthTokens() 신규 — setSession() 후
기존 _onAuthenticated SSOT로 수렴
- 에러 query/fragment(error, error_description) 상세 로깅
- 토큰 값은 로그에 안 찍히도록 (query=yes/no, fragment=yes/no 만)
2. Supabase RLS 무한 재귀:
- team_members_read_same_team 정책이 자기 테이블 재조회 →
Postgres RLS 엔진 무한 재귀 탐지 에러
- meetings / meeting_memos / meeting_documents / team_invites 정책이
team_members 서브쿼리 경유해서 전부 같이 터짐 + Realtime TIMED_OUT
- migration 20260411000001: SECURITY DEFINER 함수 2개
(user_team_ids, user_admin_team_ids) 신규 — Supabase 권장 패턴
- 영향 정책: team_members(4) + meetings(2) + meeting_memos(1) +
meeting_documents(2) + team_invites(1) 전부 함수 기반으로 재작성
- supabase db push 완료
3. LoginScreen stale 번들:
- Phase 1.5에서 import 제거했는데도 vite HMR/cache 어딘가에서
stale state 유지해서 렌더러에 계속 뜸
- LoginScreen.tsx 파일 자체 삭제 (vite 컴파일 대상 제거)
로그인 성공 확인: yunchan8804@gmail.com 으로 Google OAuth 완주 →
users/7da3dd02-9f2f-4ee9-a9b3-1c2c24875a93/d3ro.db 생성 → Initial sync 시작.
This commit is contained in:
parent
817b4a2580
commit
9498380f7f
5 changed files with 305 additions and 125 deletions
|
|
@ -5,6 +5,9 @@ import path from 'path'
|
|||
import { bootstrap } from './bootstrap'
|
||||
import { setupLifecycle } from './lifecycle'
|
||||
import { getMainWindow } from './windows/WindowManager'
|
||||
import { getLogger } from './services/LoggerService'
|
||||
|
||||
const deepLinkLogger = getLogger('deep-link')
|
||||
|
||||
// EPIPE 에러 방지: electron-log가 stdout/stderr에 쓸 때 파이프가 끊기면 크래시 방지
|
||||
process.stdout?.on?.('error', () => { /* ignore EPIPE */ })
|
||||
|
|
@ -27,21 +30,92 @@ if (process.defaultApp) {
|
|||
|
||||
/**
|
||||
* deep link URL을 파싱해서 CloudSyncService로 전달.
|
||||
* d3ro-voice://auth-callback?code=...
|
||||
*
|
||||
* Supabase OAuth 응답 방식 (둘 다 지원):
|
||||
* - **Implicit flow** (현재 기본): `d3ro-voice://auth-callback#access_token=...&refresh_token=...&token_type=bearer`
|
||||
* - **PKCE flow**: `d3ro-voice://auth-callback?code=...`
|
||||
* - **에러**: query 또는 fragment에 `error=...&error_description=...`
|
||||
*
|
||||
* 우리 supabase-js 클라이언트는 `persistSession: false`로 떠 있어서 PKCE가
|
||||
* 자동 동작하지 않는다. 따라서 Supabase가 implicit flow로 떨어져 fragment에
|
||||
* access_token/refresh_token을 직접 박아 보낸다. 그 토큰을 client.auth.setSession()에
|
||||
* 주입해서 세션 복원.
|
||||
*/
|
||||
async function handleDeepLink(url: string): Promise<void> {
|
||||
// 토큰/코드가 포함될 수 있으므로 URL 전체 대신 host + path만 로그
|
||||
let parsed: URL
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.host === 'auth-callback') {
|
||||
const code = parsed.searchParams.get('code')
|
||||
if (code) {
|
||||
const { getCloudSyncService } = await import('./services/CloudSyncService')
|
||||
await getCloudSyncService().handleAuthCallback(code)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 잘못된 URL은 무시
|
||||
parsed = new URL(url)
|
||||
} catch (err) {
|
||||
deepLinkLogger.error(
|
||||
`Failed to parse deep link URL: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
return
|
||||
}
|
||||
deepLinkLogger.info(
|
||||
`Deep link received: ${parsed.protocol}//${parsed.host}${parsed.pathname} (query=${parsed.search.length > 0 ? 'yes' : 'no'}, fragment=${parsed.hash.length > 0 ? 'yes' : 'no'})`
|
||||
)
|
||||
|
||||
if (parsed.host !== 'auth-callback') {
|
||||
deepLinkLogger.warn(`Unknown deep link host: "${parsed.host}" (expected 'auth-callback')`)
|
||||
return
|
||||
}
|
||||
|
||||
// fragment (#...) 와 query (?...) 양쪽 파싱
|
||||
const fragment = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash
|
||||
const fragmentParams = new URLSearchParams(fragment)
|
||||
const queryParams = parsed.searchParams
|
||||
|
||||
// 에러 체크 — query/fragment 둘 다
|
||||
const error = queryParams.get('error') ?? fragmentParams.get('error')
|
||||
if (error) {
|
||||
const desc =
|
||||
queryParams.get('error_description') ??
|
||||
fragmentParams.get('error_description') ??
|
||||
'(no description)'
|
||||
deepLinkLogger.error(`Supabase OAuth returned error: ${error} — ${desc}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { getCloudSyncService } = await import('./services/CloudSyncService')
|
||||
|
||||
// Implicit flow: fragment에 access_token + refresh_token
|
||||
const accessToken = fragmentParams.get('access_token')
|
||||
const refreshToken = fragmentParams.get('refresh_token')
|
||||
if (accessToken && refreshToken) {
|
||||
deepLinkLogger.info('Implicit flow tokens detected, calling handleAuthTokens()')
|
||||
try {
|
||||
await getCloudSyncService().handleAuthTokens({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
})
|
||||
deepLinkLogger.info('handleAuthTokens completed successfully')
|
||||
} catch (err) {
|
||||
deepLinkLogger.error(
|
||||
`handleAuthTokens failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// PKCE flow: query에 code
|
||||
const code = queryParams.get('code')
|
||||
if (code) {
|
||||
deepLinkLogger.info('PKCE flow code detected, calling handleAuthCallback()')
|
||||
try {
|
||||
await getCloudSyncService().handleAuthCallback(code)
|
||||
deepLinkLogger.info('handleAuthCallback completed successfully')
|
||||
} catch (err) {
|
||||
deepLinkLogger.error(
|
||||
`handleAuthCallback failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
deepLinkLogger.warn(
|
||||
'auth-callback deep link had neither access_token (implicit) nor code (PKCE). Ignoring.'
|
||||
)
|
||||
}
|
||||
|
||||
// 단일 인스턴스 잠금
|
||||
|
|
|
|||
|
|
@ -447,7 +447,7 @@ class CloudSyncService extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* Deep link 콜백 처리 — code를 session으로 교환.
|
||||
* Deep link 콜백 처리 — PKCE flow code를 session으로 교환.
|
||||
* 성공 시 _onAuthenticated(단일 진입점)로 수렴.
|
||||
*/
|
||||
async handleAuthCallback(code: string): Promise<void> {
|
||||
|
|
@ -469,6 +469,40 @@ class CloudSyncService extends EventEmitter {
|
|||
await this._onAuthenticated(data.session, { reason: 'signin' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep link 콜백 처리 — Implicit flow로 받은 access/refresh token을
|
||||
* client.auth.setSession()에 주입하여 세션 복원.
|
||||
*
|
||||
* supabase-js가 `persistSession: false`로 떠 있을 때 PKCE code_verifier 저장이
|
||||
* 없어서 PKCE가 제대로 동작하지 않는다. Supabase가 implicit flow로 떨어져
|
||||
* fragment(#access_token=...&refresh_token=...)에 토큰을 담아 보내는데, 이때
|
||||
* 이 메서드를 사용한다.
|
||||
*/
|
||||
async handleAuthTokens(params: {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}): Promise<void> {
|
||||
if (!this._client) {
|
||||
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
|
||||
}
|
||||
|
||||
const { data, error } = await this._client.auth.setSession({
|
||||
access_token: params.accessToken,
|
||||
refresh_token: params.refreshToken,
|
||||
})
|
||||
if (error || !data.session) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.LLMProcessingFailed,
|
||||
`세션 설정 실패: ${error?.message ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
|
||||
this._saveRefreshToken(data.session.refresh_token)
|
||||
logger.info(`Signed in (implicit): ${data.session.user.email ?? data.session.user.id}`)
|
||||
|
||||
await this._onAuthenticated(data.session, { reason: 'signin' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 — 단일 진입점 _onSignOut에서 세션/DB/녹음 모두 안전하게 정리.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
// src/renderer/components/LoginScreen.tsx
|
||||
// SaaS 첫 실행 게이트 — OAuth 로그인 안 됐으면 메인 UI 진입 차단.
|
||||
// 사용자는 Google/GitHub 버튼만 클릭하면 됨. URL/Key 입력 필드 노출 금지.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
|
||||
import GoogleIcon from '@mui/icons-material/Google'
|
||||
import GitHubIcon from '@mui/icons-material/GitHub'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, typoSx } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
||||
export function LoginScreen(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
|
||||
async function handleSignIn(provider: 'google' | 'github'): Promise<void> {
|
||||
setError(null)
|
||||
setInfo(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const r = await window.electronAPI.cloudSync.signIn({ provider })
|
||||
if (!r.success) {
|
||||
setError(r.error.message)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
setInfo(t('login.browserPrompt') ?? '브라우저에서 로그인을 완료해주세요...')
|
||||
// 성공 시 auth-changed 이벤트가 AuthGate로 전달되어 자동 전환됨.
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
p: 4
|
||||
}}
|
||||
>
|
||||
<MetalCard sx={{ p: 5, maxWidth: 440, width: '100%' }}>
|
||||
<Stack spacing={3} alignItems="center">
|
||||
<PhosphorText variant="title">D3RO VOICE</PhosphorText>
|
||||
<Box
|
||||
sx={{
|
||||
...typoSx('label'),
|
||||
color: d3roPalette.text.label,
|
||||
textAlign: 'center',
|
||||
fontFamily: d3roFontMono
|
||||
}}
|
||||
>
|
||||
{t('login.subtitle') ?? 'AI 음성 어시스턴트'}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: '100%', borderTop: `1px solid ${d3roPalette.border.subtle}`, pt: 3 }}>
|
||||
<Stack spacing={1.5}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={busy ? <CircularProgress size={16} /> : <GoogleIcon />}
|
||||
onClick={() => void handleSignIn('google')}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('login.google') ?? 'Google로 계속하기'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={<GitHubIcon />}
|
||||
onClick={() => void handleSignIn('github')}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('login.github') ?? 'GitHub로 계속하기'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined" sx={{ width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{info && !error && (
|
||||
<Alert severity="info" variant="outlined" sx={{ width: '100%' }}>
|
||||
{info}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
...typoSx('label'),
|
||||
color: d3roPalette.text.muted,
|
||||
fontSize: 11,
|
||||
textAlign: 'center',
|
||||
maxWidth: 320
|
||||
}}
|
||||
>
|
||||
{t('login.terms') ?? '계속 진행하면 이용약관 및 개인정보 처리방침에 동의합니다.'}
|
||||
</Box>
|
||||
</Stack>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -208,6 +208,63 @@ OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는
|
|||
- `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인
|
||||
- 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인
|
||||
|
||||
### SaaS [8] OAuth 로그인 완주 + RLS 재귀 픽스 (Phase 5 Part 1, 2026-04-11)
|
||||
|
||||
> **로그인 성공!** 빅뱅 Phase 5 실증 시작. Google OAuth 토큰 교환 성공, 사용자별 DB 생성 확인.
|
||||
> 그러나 첫 pull에서 Supabase RLS 무한 재귀 + Realtime TIMED_OUT 발생 → 같은 세션에서 즉시 픽스.
|
||||
|
||||
**성공 이벤트 로그 (11:46:52 ~ 55)**
|
||||
```
|
||||
[Opening OAuth URL in external browser: google]
|
||||
[deep-link] Implicit flow tokens detected, calling handleAuthTokens()
|
||||
[CloudSyncService] Signed in (implicit): yunchan8804@gmail.com
|
||||
[auth:signin] DB opened for 7da3dd02-9f2f-4ee9-a9b3-1c2c24875a93:
|
||||
.../users/7da3dd02-.../d3ro.db (created=true)
|
||||
[auth:signin] Initial sync starting — push then pull
|
||||
[deep-link] handleAuthTokens completed successfully
|
||||
[auth:signin] Initial push done: pushed=0, errors=0
|
||||
```
|
||||
|
||||
**발견된 버그 + 픽스**
|
||||
|
||||
#### Bug 1: Supabase implicit flow 응답을 데스크톱이 무시
|
||||
- 증상: 로그인 4회 시도, 매번 `auth-callback deep link had no 'code' parameter`
|
||||
- 원인: supabase-js가 `persistSession: false`일 때 PKCE code_verifier 저장소가 없어 **implicit flow로 fallback**. Supabase가 fragment(`#access_token=...&refresh_token=...`)로 토큰 직접 전달. 우리 `handleDeepLink`는 `?code=...`(PKCE)만 파싱.
|
||||
- 픽스:
|
||||
- `apps/desktop/src/main/index.ts`: `handleDeepLink`가 query(`?code=`)와 fragment(`#access_token=`) 둘 다 파싱. fragment에 토큰 있으면 `CloudSyncService.handleAuthTokens()` 호출. 에러 파라미터(`error=`, `error_description=`) 상세 로깅.
|
||||
- `CloudSyncService.handleAuthTokens({accessToken, refreshToken})` 신규 — `client.auth.setSession()`으로 세션 복원 후 기존 `_onAuthenticated(session, {reason: 'signin'})` SSOT 진입점으로 수렴.
|
||||
- 토큰 로깅은 host+path만, 토큰 값은 로그에 안 찍히도록 수정 (`query=yes/no, fragment=yes/no`)
|
||||
|
||||
#### Bug 2: Supabase RLS 무한 재귀
|
||||
- 증상: `Pull meetings failed: infinite recursion detected in policy for relation "team_members"` × 3 테이블 + `Realtime 채널 상태: TIMED_OUT`
|
||||
- 원인 (`20260409000002_rls_policies.sql`): `team_members` SELECT 정책이 **자기 테이블을 재조회**:
|
||||
```sql
|
||||
CREATE POLICY "team_members_read_same_team" ON team_members
|
||||
FOR SELECT USING (
|
||||
team_id IN (SELECT team_id FROM team_members WHERE user_id = auth.uid())
|
||||
);
|
||||
```
|
||||
이 정책이 Postgres RLS 엔진에서 무한 재귀 탐지로 에러. `meetings` / `meeting_memos` / `meeting_documents` / `team_invites` 정책이 모두 `team_members` 서브쿼리를 경유해서 **전부 같이 터짐**. Realtime 구독도 같은 이유로 TIMED_OUT.
|
||||
- 픽스 (`server/supabase/migrations/20260411000001_fix_team_members_rls_recursion.sql`):
|
||||
- `SECURITY DEFINER` 함수 2개 신규 — Supabase 공식 권장 패턴으로 재귀 끊기
|
||||
- `public.user_team_ids(uid uuid) RETURNS SETOF uuid`
|
||||
- `public.user_admin_team_ids(uid uuid) RETURNS SETOF uuid`
|
||||
- `search_path = public, pg_temp` 고정 + `REVOKE ALL` 후 `authenticated, service_role`에만 `GRANT EXECUTE`
|
||||
- 기존 정책 재작성: `team_id IN (SELECT ... FROM team_members)` → `team_id IN (SELECT public.user_team_ids(auth.uid()))`
|
||||
- 영향 정책: `team_members` × 4개, `meetings` × 2개, `meeting_memos` × 1개, `meeting_documents` × 2개, `team_invites` × 1개
|
||||
- `supabase db push` 적용 완료
|
||||
|
||||
#### Bug 3: LoginScreen stale 번들
|
||||
- 증상: Phase 1.5에서 LoginScreen import 제거했는데도 렌더러 화면에 LoginScreen 계속 뜸. HMR/vite cache 혼동 상태.
|
||||
- 원인: 불명 (vite HMR이 어딘가에서 stale state 유지한 듯, grep으로는 import 사용처 0개 확인)
|
||||
- 픽스: **`LoginScreen.tsx` 파일 자체 삭제**. vite가 컴파일할 대상이 아예 사라져서 stale이 불가능해짐.
|
||||
|
||||
**검증**
|
||||
- desktop `tsc --noEmit` ✅
|
||||
- desktop dev 런타임 ✅ (로그인 성공 확인)
|
||||
- Supabase `db push` ✅ (migration 적용)
|
||||
- pull / Realtime 재테스트 — 사용자가 Settings → Cloud Sync → Pull 눌러서 확인 필요
|
||||
|
||||
### SaaS [7] 3 클라이언트 일원화 검증 — **OAuth provider 연결 대기** (Phase 5)
|
||||
|
||||
> Phase 1~4+3이 로컬/클라우드 feature gate + sync 인프라를 모두 배선했으나,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
-- ============================================================================
|
||||
-- 빅뱅 Phase 5 실증 중 발견: team_members RLS 정책이 자기 테이블을 재조회해서
|
||||
-- "infinite recursion detected in policy for relation team_members" 에러.
|
||||
--
|
||||
-- meetings / meeting_memos / meeting_documents / team_invites 정책이
|
||||
-- team_members 서브쿼리를 경유하면서 재귀가 트리거되어 pull 전부 실패 +
|
||||
-- Realtime 구독 TIMED_OUT 발생.
|
||||
--
|
||||
-- 해결: SECURITY DEFINER 함수로 team_id 조회를 감싸 RLS 우회 + 재귀 끊기.
|
||||
-- 이건 Supabase 공식 권장 패턴.
|
||||
-- ============================================================================
|
||||
|
||||
-- ── 헬퍼 함수 ────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.user_team_ids(uid uuid)
|
||||
RETURNS SETOF uuid
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
SELECT team_id FROM public.team_members WHERE user_id = uid;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.user_admin_team_ids(uid uuid)
|
||||
RETURNS SETOF uuid
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
SELECT team_id FROM public.team_members
|
||||
WHERE user_id = uid AND role IN ('owner', 'admin');
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.user_team_ids(uuid) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION public.user_admin_team_ids(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.user_team_ids(uuid) TO authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.user_admin_team_ids(uuid) TO authenticated, service_role;
|
||||
|
||||
-- ── team_members: 재귀 제거 ───────────────────────────────────────────────
|
||||
|
||||
DROP POLICY IF EXISTS "team_members_read_same_team" ON public.team_members;
|
||||
CREATE POLICY "team_members_read_same_team" ON public.team_members
|
||||
FOR SELECT USING (
|
||||
user_id = auth.uid()
|
||||
OR team_id IN (SELECT public.user_team_ids(auth.uid()))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "team_members_insert_admin" ON public.team_members;
|
||||
CREATE POLICY "team_members_insert_admin" ON public.team_members
|
||||
FOR INSERT WITH CHECK (
|
||||
team_id IN (SELECT public.user_admin_team_ids(auth.uid()))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "team_members_update_admin" ON public.team_members;
|
||||
CREATE POLICY "team_members_update_admin" ON public.team_members
|
||||
FOR UPDATE USING (
|
||||
team_id IN (SELECT public.user_admin_team_ids(auth.uid()))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "team_members_delete_admin_or_self" ON public.team_members;
|
||||
CREATE POLICY "team_members_delete_admin_or_self" ON public.team_members
|
||||
FOR DELETE USING (
|
||||
user_id = auth.uid()
|
||||
OR team_id IN (SELECT public.user_admin_team_ids(auth.uid()))
|
||||
);
|
||||
|
||||
-- ── meetings: team_members 직접 참조 → 함수 호출로 교체 ───────────────────
|
||||
|
||||
DROP POLICY IF EXISTS "meetings_read" ON public.meetings;
|
||||
CREATE POLICY "meetings_read" ON public.meetings
|
||||
FOR SELECT USING (
|
||||
user_id = auth.uid()
|
||||
OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_team_ids(auth.uid())))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "meetings_update" ON public.meetings;
|
||||
CREATE POLICY "meetings_update" ON public.meetings
|
||||
FOR UPDATE USING (
|
||||
user_id = auth.uid()
|
||||
OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_admin_team_ids(auth.uid())))
|
||||
);
|
||||
|
||||
-- ── meeting_memos: meetings RLS 경유 + insert 시 team 체크 ───────────────
|
||||
|
||||
DROP POLICY IF EXISTS "meeting_memos_insert" ON public.meeting_memos;
|
||||
CREATE POLICY "meeting_memos_insert" ON public.meeting_memos
|
||||
FOR INSERT WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND meeting_id IN (
|
||||
SELECT id FROM public.meetings
|
||||
WHERE user_id = auth.uid()
|
||||
OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_team_ids(auth.uid())))
|
||||
)
|
||||
);
|
||||
|
||||
-- ── meeting_documents: 동일 패턴 ──────────────────────────────────────────
|
||||
|
||||
DROP POLICY IF EXISTS "meeting_documents_insert" ON public.meeting_documents;
|
||||
CREATE POLICY "meeting_documents_insert" ON public.meeting_documents
|
||||
FOR INSERT WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND meeting_id IN (
|
||||
SELECT id FROM public.meetings
|
||||
WHERE user_id = auth.uid()
|
||||
OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_team_ids(auth.uid())))
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "meeting_documents_update" ON public.meeting_documents;
|
||||
CREATE POLICY "meeting_documents_update" ON public.meeting_documents
|
||||
FOR UPDATE USING (
|
||||
user_id = auth.uid()
|
||||
OR meeting_id IN (
|
||||
SELECT id FROM public.meetings
|
||||
WHERE team_id IN (SELECT public.user_admin_team_ids(auth.uid()))
|
||||
)
|
||||
);
|
||||
|
||||
-- ── team_invites: 같은 패턴 오염 ──────────────────────────────────────────
|
||||
|
||||
DROP POLICY IF EXISTS "team_invites_read" ON public.team_invites;
|
||||
CREATE POLICY "team_invites_read" ON public.team_invites
|
||||
FOR SELECT USING (
|
||||
team_id IN (SELECT public.user_team_ids(auth.uid()))
|
||||
OR lower(email) = lower((SELECT email FROM auth.users WHERE id = auth.uid()))
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue