360 lines
9.0 KiB
Markdown
360 lines
9.0 KiB
Markdown
# Auth DB Design
|
|
|
|
## Goal
|
|
|
|
현재 조직도 업무 데이터와 로그인 데이터를 분리한다.
|
|
|
|
분리 원칙:
|
|
- 업무 데이터는 기존 `public.members`, `seat_maps`, `seat_positions` 중심으로 유지
|
|
- 인증/권한 데이터는 별도 `auth` 스키마로 분리
|
|
- 로그인 사용자는 필요할 때만 `members.id` 와 연결
|
|
|
|
이 방식이면 비밀번호, 세션, 감사로그를 업무 데이터와 분리해서 관리할 수 있고,
|
|
엑셀 임포트로 `members` 가 갱신돼도 인증 체계가 직접 흔들리지 않는다.
|
|
|
|
## Scope
|
|
|
|
이번 설계의 대상:
|
|
- 사용자 계정
|
|
- 비밀번호 해시
|
|
- 세션
|
|
- 역할과 권한
|
|
- 로그인 감사로그
|
|
- 사용자와 조직 구성원 연결
|
|
|
|
이번 설계에서 제외:
|
|
- SSO 연동
|
|
- OAuth/OpenID Connect
|
|
- MFA
|
|
- 비밀번호 재설정 메일 발송
|
|
|
|
## Recommended Structure
|
|
|
|
권장 구조는 "같은 PostgreSQL, 다른 스키마" 이다.
|
|
|
|
- 업무 스키마: `public`
|
|
- 인증 스키마: `auth`
|
|
|
|
초기 운영에서는 DB 인스턴스를 분리하지 않아도 된다.
|
|
대신 아래 원칙은 바로 적용한다.
|
|
|
|
- 애플리케이션 계정도 가능하면 읽기/쓰기 범위를 분리
|
|
- 인증 관련 쿼리는 `auth.*` 만 접근
|
|
- 업무 API 는 `public.*` 중심으로 접근
|
|
|
|
## Core Tables
|
|
|
|
### `auth.users`
|
|
|
|
로그인 가능한 계정의 기준 테이블.
|
|
|
|
주요 컬럼:
|
|
- `id BIGSERIAL PRIMARY KEY`
|
|
- `username TEXT NOT NULL UNIQUE`
|
|
- `password_hash TEXT NOT NULL`
|
|
- `display_name TEXT NOT NULL`
|
|
- `email TEXT`
|
|
- `status TEXT NOT NULL DEFAULT 'active'`
|
|
- `member_id INTEGER NULL REFERENCES public.members(id) ON DELETE SET NULL`
|
|
- `last_login_at TIMESTAMPTZ`
|
|
- `password_changed_at TIMESTAMPTZ`
|
|
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
|
- `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
|
|
|
상태값 권장:
|
|
- `active`
|
|
- `locked`
|
|
- `disabled`
|
|
|
|
원칙:
|
|
- `username` 는 로그인 식별자
|
|
- `member_id` 는 선택 연결
|
|
- 구성원이 퇴사하거나 엑셀에서 빠져도 계정 자체는 바로 삭제하지 않음
|
|
|
|
### `auth.roles`
|
|
|
|
권한 묶음 정의.
|
|
|
|
주요 컬럼:
|
|
- `id BIGSERIAL PRIMARY KEY`
|
|
- `code TEXT NOT NULL UNIQUE`
|
|
- `name TEXT NOT NULL`
|
|
- `description TEXT`
|
|
|
|
초기 권장 역할:
|
|
- `super_admin`
|
|
- `org_admin`
|
|
- `viewer`
|
|
|
|
### `auth.user_roles`
|
|
|
|
사용자와 역할의 다대다 연결.
|
|
|
|
주요 컬럼:
|
|
- `user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE`
|
|
- `role_id BIGINT NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE`
|
|
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
|
- `PRIMARY KEY (user_id, role_id)`
|
|
|
|
### `auth.permissions`
|
|
|
|
세분화된 권한 코드 정의.
|
|
|
|
주요 컬럼:
|
|
- `id BIGSERIAL PRIMARY KEY`
|
|
- `code TEXT NOT NULL UNIQUE`
|
|
- `name TEXT NOT NULL`
|
|
- `description TEXT`
|
|
|
|
초기 권장 권한:
|
|
- `member.read`
|
|
- `member.write`
|
|
- `member.import`
|
|
- `seatmap.read`
|
|
- `seatmap.write`
|
|
- `photo.upload`
|
|
- `admin.user.manage`
|
|
|
|
### `auth.role_permissions`
|
|
|
|
역할과 권한의 다대다 연결.
|
|
|
|
주요 컬럼:
|
|
- `role_id BIGINT NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE`
|
|
- `permission_id BIGINT NOT NULL REFERENCES auth.permissions(id) ON DELETE CASCADE`
|
|
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
|
- `PRIMARY KEY (role_id, permission_id)`
|
|
|
|
### `auth.sessions`
|
|
|
|
서버 세션 저장 테이블.
|
|
|
|
주요 컬럼:
|
|
- `id UUID PRIMARY KEY`
|
|
- `user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE`
|
|
- `refresh_token_hash TEXT`
|
|
- `issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
|
- `expires_at TIMESTAMPTZ NOT NULL`
|
|
- `revoked_at TIMESTAMPTZ`
|
|
- `ip_address INET`
|
|
- `user_agent TEXT`
|
|
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
|
|
|
원칙:
|
|
- 브라우저 쿠키에는 세션 식별자만 저장
|
|
- 토큰 자체를 평문으로 DB에 저장하지 않음
|
|
- 만료와 강제 로그아웃을 DB에서 통제 가능하게 함
|
|
|
|
### `auth.login_audit_logs`
|
|
|
|
로그인 시도와 결과 기록.
|
|
|
|
주요 컬럼:
|
|
- `id BIGSERIAL PRIMARY KEY`
|
|
- `username TEXT NOT NULL`
|
|
- `user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL`
|
|
- `success BOOLEAN NOT NULL`
|
|
- `failure_reason TEXT`
|
|
- `ip_address INET`
|
|
- `user_agent TEXT`
|
|
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
|
|
|
용도:
|
|
- 로그인 실패 추적
|
|
- 계정 잠금 기준 판단
|
|
- 보안 감사 대응
|
|
|
|
## Relationship To Current `members`
|
|
|
|
핵심은 `auth.users.member_id -> public.members.id` 연결이다.
|
|
|
|
의미:
|
|
- 로그인 계정과 조직도 인원을 분리한다
|
|
- 로그인하지 않는 구성원은 `members` 에만 있어도 된다
|
|
- 외부 관리자 계정은 `member_id` 없이 운영할 수 있다
|
|
|
|
권장 규칙:
|
|
- 일반 사내 사용자는 `employee_id` 기준으로 계정-구성원 연결
|
|
- 엑셀 동기화 시 `members.id` 유지가 중요하므로 이미 반영한 비교 기반 동기화 방식을 유지
|
|
- `member_id` 연결이 끊긴 계정은 자동 삭제하지 말고 관리자 검토 대상으로 둔다
|
|
|
|
## Login Flow
|
|
|
|
### 1. 로그인 요청
|
|
|
|
입력:
|
|
- `username`
|
|
- `password`
|
|
|
|
처리:
|
|
- `auth.users` 에서 `username` 조회
|
|
- `status != active` 이면 거부
|
|
- `password_hash` 검증
|
|
- 성공 시 `auth.sessions` 생성
|
|
- `auth.login_audit_logs` 기록
|
|
|
|
응답 권장:
|
|
- 사용자 기본 정보
|
|
- 역할 목록
|
|
- 권한 목록
|
|
- 세션 만료 시각
|
|
|
|
### 2. 인증 확인
|
|
|
|
각 보호 API 요청 시:
|
|
- 세션 쿠키 또는 Bearer 토큰 확인
|
|
- `auth.sessions` 조회
|
|
- 만료/폐기 여부 확인
|
|
- 사용자 상태와 역할 재검증
|
|
|
|
### 3. 로그아웃
|
|
|
|
처리:
|
|
- 현재 세션의 `revoked_at` 업데이트
|
|
- 클라이언트 쿠키 제거
|
|
|
|
## Authorization Model
|
|
|
|
초기에는 RBAC 기반으로 충분하다.
|
|
|
|
권장 역할별 범위:
|
|
|
|
`super_admin`
|
|
- 사용자 관리
|
|
- 권한 관리
|
|
- 조직도/사진/자리배치 전체 수정
|
|
|
|
`org_admin`
|
|
- 조직도 조회/수정
|
|
- 엑셀 임포트
|
|
- 사진 업로드
|
|
- 자리배치 수정
|
|
|
|
`viewer`
|
|
- 조직도 조회
|
|
- 자리배치 조회
|
|
|
|
API 보호 예시:
|
|
- `GET /api/members`: `member.read`
|
|
- `POST /api/members/import`: `member.import`
|
|
- `POST /api/uploads/profile-photo`: `photo.upload`
|
|
- `PUT /api/seat-maps/{seat_map_id}/layout`: `seatmap.write`
|
|
- 사용자 관리 API: `admin.user.manage`
|
|
|
|
## Password Policy
|
|
|
|
비밀번호는 평문 저장 금지.
|
|
|
|
권장:
|
|
- `Argon2id` 우선
|
|
- 대안으로 `bcrypt`
|
|
|
|
추가 원칙:
|
|
- 첫 구현부터 해시 알고리즘 버전 정보 포함
|
|
- 비밀번호 변경 시 `password_changed_at` 갱신
|
|
- 실패 횟수 기반 잠금은 앱 로직 또는 별도 컬럼으로 확장 가능
|
|
|
|
## Migration Plan
|
|
|
|
### Phase 1
|
|
|
|
인증 스키마와 기본 테이블만 추가.
|
|
|
|
작업:
|
|
- `CREATE SCHEMA IF NOT EXISTS auth`
|
|
- `auth.users`
|
|
- `auth.roles`
|
|
- `auth.user_roles`
|
|
- `auth.permissions`
|
|
- `auth.role_permissions`
|
|
- `auth.sessions`
|
|
- `auth.login_audit_logs`
|
|
|
|
이 단계에서는 기존 `/api/mock-login` 유지 가능.
|
|
|
|
### Phase 2
|
|
|
|
관리자 1명 이상을 수동 생성하고 실제 로그인 API 추가.
|
|
|
|
권장 추가 API:
|
|
- `POST /api/auth/login`
|
|
- `POST /api/auth/logout`
|
|
- `GET /api/auth/me`
|
|
|
|
### Phase 3
|
|
|
|
기존 프론트엔드의 mock 로그인 제거.
|
|
|
|
변경 대상:
|
|
- [frontend/public/app.js](../frontend/public/app.js)
|
|
- [backend/app/main.py](../backend/app/main.py)
|
|
- [backend/app/config.py](../backend/app/config.py)
|
|
|
|
### Phase 4
|
|
|
|
권한 기반으로 API 보호 적용.
|
|
|
|
우선순위:
|
|
1. 쓰기 API 보호
|
|
2. 업로드 API 보호
|
|
3. 읽기 API 권한 정리
|
|
|
|
## Recommended SQL Skeleton
|
|
|
|
```sql
|
|
CREATE SCHEMA IF NOT EXISTS auth;
|
|
|
|
CREATE TABLE IF NOT EXISTS auth.users (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
email TEXT,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
member_id INTEGER NULL REFERENCES public.members(id) ON DELETE SET NULL,
|
|
last_login_at TIMESTAMPTZ,
|
|
password_changed_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth.roles (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
code TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
description TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth.user_roles (
|
|
user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
role_id BIGINT NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
PRIMARY KEY (user_id, role_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth.sessions (
|
|
id UUID PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
refresh_token_hash TEXT,
|
|
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ,
|
|
ip_address INET,
|
|
user_agent TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
```
|
|
|
|
## Operational Notes
|
|
|
|
- 엑셀 임포트는 계속 `public.members` 기준으로 처리
|
|
- 로그인 계정 생성은 엑셀 업로드와 분리
|
|
- 프로필 사진은 현재처럼 파일 저장 + `members.photo_url` 참조 유지 가능
|
|
- 감사로그와 세션은 삭제보다 보존 기간 정책으로 관리
|
|
|
|
## Decision
|
|
|
|
현재 프로젝트의 권장안은 아래 한 줄로 정리된다.
|
|
|
|
"로그인 DB는 `auth` 스키마로 분리하고, 업무 DB는 `public` 에 유지하며, 두 영역은 `auth.users.member_id` 로만 연결한다."
|