Compare commits

...

2 Commits

Author SHA1 Message Date
jigoong
1dba772e62 update configuration docker setup for data platform 2026-05-07 17:57:42 +07:00
jigoong
ce949dcc8f add supavisor config 2026-03-06 17:37:47 +07:00
54 changed files with 6738 additions and 25 deletions

View File

@@ -1,51 +1,76 @@
x-common-configs: &common-config
extra_hosts:
- "dev.sriphat.com:192.168.100.9"
pull_policy: ${DOCKER_PULL_POLICY:-missing}
services:
# nginx-proxy:
# image: jc21/nginx-proxy-manager:latest
# container_name: nginx-proxy-manager
# ports:
# - "8020:80"
# - "8043:443"
# - "8021:81"
# volumes:
# - ./data:/data
# - ./letsencrypt:/etc/letsencrypt
# environment:
# - TZ=${TZ:-Asia/Bangkok}
# env_file:
# - ../.env
# networks:
# - shared_data_network
# restart: unless-stopped
nginx-proxy:
image: jc21/nginx-proxy-manager:latest
image: nginx:latest
container_name: nginx-proxy-manager
ports:
- "8020:80"
- "8043:443"
- "8021:81"
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
environment:
- TZ=${TZ:-Asia/Bangkok}
env_file:
- ../.env.global
volumes:
- ./nginx-configs/default-all.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- shared_data_network
restart: unless-stopped
<<: *common-config
keycloak:
image: quay.io/keycloak/keycloak:23.0
container_name: keycloak
command: start-dev
#command: start-dev
command: start-dev --http-relative-path /keycloak
env_file:
- ../.env.global
- ../.env
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/${KEYCLOAK_DB_NAME}
KC_DB_URL: jdbc:postgresql://postgres:${DB_PORT:-5432}/${KEYCLOAK_DB_NAME}
KC_DB_USERNAME: ${DB_USER}
KC_DB_PASSWORD: ${DB_PASSWORD}
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
KC_PROXY: edge
# passthrough
KC_HTTP_RELATIVE_PATH: "/keycloak"
KC_HOSTNAME_PATH: "/keycloak"
KC_HOSTNAME_STRICT_HTTPS: "true"
ports:
- "8080:8080"
- "8085:8080"
networks:
- shared_data_network
restart: unless-stopped
depends_on:
- postgres
<<: *common-config
postgres:
image: postgres:15-alpine
container_name: postgres
env_file:
- ../.env.global
- ../.env
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USER}
@@ -58,13 +83,57 @@ services:
- shared_data_network
restart: unless-stopped
ports:
- "0.0.0.0:5435:5432"
- "0.0.0.0:${DB_PORT_EXPOSE:-5435}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
redis:
# Redis is limited to 7.2-bookworm due to licencing change
# https://redis.io/blog/redis-adopts-dual-source-available-licensing/
image: redis:7.2-bookworm
expose:
- 6379
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 30s
retries: 50
start_period: 30s
restart: always
networks:
- shared_data_network
dozzle:
image: amir20/dozzle:latest
container_name: dozzle
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./data/dozzle:/data
ports:
- "${DOZZLE_PORT:-9999}:8080"
environment:
DOZZLE_LEVEL: ${DOZZLE_LEVEL:-info}
DOZZLE_BASE: ${DOZZLE_BASE:-/dozzle}
DOZZLE_HOSTNAME: ${DOZZLE_HOSTNAME:-Sriphat Main Server}
DOZZLE_NO_ANALYTICS: "true"
DOZZLE_ENABLE_ACTIONS: "true"
DOZZLE_AUTH_PROVIDER: ${DOZZLE_AUTH_PROVIDER:-none}
DOZZLE_REMOTE_AGENT: ${DOZZLE_REMOTE_AGENT:-}
TZ: ${TZ:-Asia/Bangkok}
networks:
- shared_data_network
restart: ${DOZZLE_RESTART_POLICY:-unless-stopped}
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/dozzle/healthcheck"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
<<: *common-config
networks:
shared_data_network:
external: true

View File

@@ -0,0 +1,5 @@
-- Create Keycloak database
CREATE DATABASE keycloak;
-- Grant privileges to postgres user
GRANT ALL PRIVILEGES ON DATABASE keycloak TO postgres;

View File

@@ -0,0 +1,8 @@
-- Create databases for Airbyte OSS
-- These databases will be used by the Airbyte deployment in 04-ingestion
-- Main Airbyte database
CREATE DATABASE airflow_db;
-- Grant permissions to postgres user
GRANT ALL PRIVILEGES ON DATABASE airflow_db TO postgres;

View File

@@ -0,0 +1,149 @@
# Nginx Proxy Manager - Site Configurations
Nginx site configurations สำหรับ handle subpath routing ของทุก service ใน Sriphat Data Platform
## 📋 Services และ Subpaths
| Service | Subpath | Backend | Port |
|---------|---------|---------|------|
| API Service | `/apiservice` | apiservice:8000 | 8040 |
| Supabase Studio | `/supabase` | sdp-studio:3000 | - |
| Supabase Kong API | `/supabase-api` | sdp-kong:8000 | - |
| Keycloak | `/keycloak` | keycloak:8080 | 8085 |
| Superset | `/superset` | superset:8088 | 8088 |
| Airflow | `/airflow` | airflow-webserver:8080 | - |
| DBT Docs | `/dbt` | dbt-docs:8080 | - |
| Dozzle | `/dozzle` | dozzle:8080 | 9999 |
## 🚀 การใช้งาน
### วิธีที่ 1: ใช้ Nginx Proxy Manager UI (แนะนำ)
1. เข้า Nginx Proxy Manager: `http://your-server:8021`
2. Login (default: admin@example.com / changeme)
3. ไปที่ **Proxy Hosts****Add Proxy Host**
4. กรอกข้อมูล:
- **Domain Names**: `ai.sriphat.com` (หรือ domain ของคุณ)
- **Scheme**: `http`
- **Forward Hostname/IP**: ดูจากตารางด้านบน
- **Forward Port**: ดูจากตารางด้านบน
5. ไปที่ tab **Advanced**
6. Copy config จากไฟล์ที่เกี่ยวข้อง (เช่น `apiservice.conf`) ไปวางใน **Custom Nginx Configuration**
7. กด **Save**
### วิธีที่ 2: Import Config Files โดยตรง
**⚠️ วิธีนี้ต้องการ access ไปยัง Nginx Proxy Manager data directory**
```bash
# 1. Copy config files ไปยัง Nginx Proxy Manager
cd /path/to/01-infra
docker cp nginx-configs/. nginx-proxy-manager:/data/nginx/custom/
# 2. Restart Nginx Proxy Manager
docker restart nginx-proxy-manager
# 3. ตรวจสอบ logs
docker logs nginx-proxy-manager -f
```
## 📝 Config Files
### `apiservice.conf`
FastAPI service with Keycloak authentication
- Handles `/apiservice/*` paths
- Preserves session cookies
- WebSocket support
### `supabase-studio.conf`
Supabase Studio UI
- Handles `/supabase/*` paths
- Rewrites paths for Studio
### `supabase-kong.conf`
Supabase REST API (Kong Gateway)
- Handles `/supabase-api/*` paths
- API key authentication
### `keycloak.conf`
Keycloak SSO
- Handles `/keycloak/*` paths
- Preserves authentication headers
### `superset.conf`
Apache Superset BI
- Handles `/superset/*` paths
- Session management
### `airflow.conf`
Apache Airflow (if deployed)
- Handles `/airflow/*` paths
- WebServer UI
### `dbt.conf`
DBT Documentation (if deployed)
- Handles `/dbt/*` paths
- Static documentation
## 🔧 การปรับแต่ง
### เปลี่ยน Domain
แก้ไข `server_name` ในแต่ละ config file:
```nginx
server_name ai.sriphat.com; # เปลี่ยนเป็น domain ของคุณ
```
### เปลี่ยน Backend Host/Port
แก้ไข `proxy_pass` directive:
```nginx
proxy_pass http://apiservice:8000; # เปลี่ยนตาม service ของคุณ
```
### เพิ่ม SSL/HTTPS
ใช้ Nginx Proxy Manager UI:
1. ไปที่ Proxy Host ที่ต้องการ
2. ไปที่ tab **SSL**
3. เลือก **Request a new SSL Certificate**
4. เลือก **Force SSL**
## 🐛 Troubleshooting
### 502 Bad Gateway
- ตรวจสอบว่า backend service ทำงานอยู่: `docker ps`
- ตรวจสอบ network: `docker network inspect shared_data_network`
- ดู logs: `docker logs <service-name>`
### 404 Not Found
- ตรวจสอบ path rewriting ใน config
- ดู nginx logs: `docker logs nginx-proxy-manager`
### Session/Cookie Issues
- ตรวจสอบ `proxy_cookie_path` directive
- ตรวจสอบ `X-Forwarded-*` headers
### WebSocket Connection Failed
- ตรวจสอบว่ามี WebSocket headers:
```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
## 📚 เอกสารเพิ่มเติม
- [Nginx Proxy Manager Documentation](https://nginxproxymanager.com/guide/)
- [Nginx Reverse Proxy Guide](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/)
- [Nginx Subpath Configuration](https://www.nginx.com/blog/creating-nginx-rewrite-rules/)
## 🔐 Security Notes
1. **ใช้ HTTPS ใน Production** - Request SSL certificate ผ่าน Nginx Proxy Manager
2. **ตั้งค่า Access Lists** - จำกัดการเข้าถึงบาง services (เช่น Keycloak Admin)
3. **Enable Rate Limiting** - ป้องกัน DDoS attacks
4. **Update Regularly** - อัพเดท Nginx Proxy Manager เป็นประจำ
## 📞 Support
หากมีปัญหาหรือข้อสงสัย:
1. ตรวจสอบ logs: `docker logs nginx-proxy-manager -f`
2. ดู Nginx Proxy Manager UI → **Logs**
3. ตรวจสอบ backend service logs

View File

@@ -0,0 +1,67 @@
# Apache Airflow - Workflow Orchestration
# Subpath: /airflow
# Backend: airflow-webserver:8080
location /airflow {
# Remove /airflow prefix before forwarding
rewrite ^/airflow(/.*)$ $1 break;
# Forward to Airflow Webserver
proxy_pass http://airflow-webserver:8080;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Important for Airflow subpath
proxy_set_header X-Script-Name /airflow;
# Session cookie handling
proxy_cookie_path / /airflow/;
# WebSocket support for real-time logs
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts (DAG runs can take time)
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Buffer settings
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# API endpoints
location /airflow/api {
rewrite ^/airflow(/.*)$ $1 break;
proxy_pass http://airflow-webserver:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Script-Name /airflow;
proxy_cookie_path / /airflow/;
}
# Static files
location /airflow/static {
rewrite ^/airflow(/.*)$ $1 break;
proxy_pass http://airflow-webserver:8080;
proxy_set_header Host $host;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, immutable";
}
# Health check
location /airflow/health {
rewrite ^/airflow(/.*)$ $1 break;
proxy_pass http://airflow-webserver:8080;
proxy_set_header Host $host;
}

View File

@@ -0,0 +1,58 @@
# API Service - FastAPI with Keycloak Authentication
# Subpath: /apiservice
# Backend: apiservice:8000
location /apiservice {
# Remove /apiservice prefix before forwarding to backend
rewrite ^/apiservice(/.*)$ $1 break;
# Forward to FastAPI backend
proxy_pass http://apiservice:8000;
# Preserve original host and protocol
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Important: Tell FastAPI about the subpath
proxy_set_header X-Script-Name /apiservice;
# Session cookie handling
proxy_cookie_path / /apiservice/;
# WebSocket support (for future use)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffer settings
proxy_buffering off;
proxy_request_buffering off;
}
# Static files (if any)
location /apiservice/static {
rewrite ^/apiservice/static(/.*)$ /static$1 break;
proxy_pass http://apiservice:8000;
proxy_set_header Host $host;
}
# Admin panel
location /apiservice/admin {
rewrite ^/apiservice(/.*)$ $1 break;
proxy_pass http://apiservice:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Script-Name /apiservice;
proxy_cookie_path / /apiservice/;
}

View File

@@ -0,0 +1,144 @@
# Complete Nginx Configuration Example
# สำหรับ Nginx Proxy Manager - Custom Nginx Configuration
#
# วิธีใช้:
# 1. ไปที่ Nginx Proxy Manager UI (http://your-server:8021)
# 2. สร้าง Proxy Host ใหม่
# 3. กรอก Domain Names: ai.sriphat.com (หรือ domain ของคุณ)
# 4. กรอก Forward Hostname/IP: localhost (dummy, จะใช้ config ด้านล่าง)
# 5. กรอก Forward Port: 80 (dummy)
# 6. ไปที่ tab "Advanced"
# 7. Copy config ด้านล่างนี้ทั้งหมดไปวางใน "Custom Nginx Configuration"
# 8. กด Save
# ============================================================================
# API Service - FastAPI with Keycloak
# ============================================================================
location /apiservice {
rewrite ^/apiservice(/.*)$ $1 break;
proxy_pass http://apiservice:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Script-Name /apiservice;
proxy_cookie_path / /apiservice/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
}
# ============================================================================
# Supabase Studio - Database Management UI
# ============================================================================
location /supabase {
rewrite ^/supabase(/.*)$ $1 break;
proxy_pass http://sdp-studio:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
}
# ============================================================================
# Supabase Kong API - REST API Gateway
# ============================================================================
location /supabase-api {
rewrite ^/supabase-api(/.*)$ $1 break;
proxy_pass http://sdp-kong:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_set_header apikey $http_apikey;
proxy_buffering off;
}
# ============================================================================
# Keycloak - Single Sign-On (SSO)
# ============================================================================
location /keycloak {
rewrite ^/keycloak(/.*)$ $1 break;
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /keycloak;
proxy_cookie_path / /keycloak/;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# ============================================================================
# Apache Superset - Business Intelligence
# ============================================================================
location /superset {
rewrite ^/superset(/.*)$ $1 break;
proxy_pass http://superset:8088;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Script-Name /superset;
proxy_cookie_path / /superset/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# ============================================================================
# Apache Airflow - Workflow Orchestration (Optional)
# ============================================================================
location /airflow {
rewrite ^/airflow(/.*)$ $1 break;
proxy_pass http://airflow-webserver:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Script-Name /airflow;
proxy_cookie_path / /airflow/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# ============================================================================
# DBT Documentation (Optional)
# ============================================================================
location /dbt {
rewrite ^/dbt(/.*)$ $1 break;
proxy_pass http://dbt-docs:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ============================================================================
# Dozzle - Docker Log Viewer & Monitoring
# ============================================================================
location /dozzle {
proxy_pass http://dozzle:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 300s;
}

View File

@@ -0,0 +1,44 @@
# DBT Documentation - Data Transformation Docs
# Subpath: /dbt
# Backend: dbt-docs:8080
location /dbt {
# Remove /dbt prefix before forwarding
rewrite ^/dbt(/.*)$ $1 break;
# Forward to DBT docs server
proxy_pass http://dbt-docs:8080;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Static documentation files
location /dbt/static {
rewrite ^/dbt(/.*)$ $1 break;
proxy_pass http://dbt-docs:8080;
proxy_set_header Host $host;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, immutable";
}
# Catalog and manifest files
location /dbt/catalog.json {
rewrite ^/dbt(/.*)$ $1 break;
proxy_pass http://dbt-docs:8080;
proxy_set_header Host $host;
}
location /dbt/manifest.json {
rewrite ^/dbt(/.*)$ $1 break;
proxy_pass http://dbt-docs:8080;
proxy_set_header Host $host;
}

View File

@@ -0,0 +1,359 @@
server {
listen 80;
server_name dev.sriphat.com;
client_max_body_size 100M;
# redirect to ai web while wait for main protal web in the future
location = / {
return 301 /ai/;
}
location /keycloak/ {
#rewrite ^/keycloak/(.*)$ /$1 break;
proxy_pass http://keycloak:8080;
# Add WebSocket support (Necessary for version 0.5.0 and up)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# (Optional) Disable proxy buffering for better streaming response from models
proxy_buffering off;
# (Optional) Increase max request size for large attachments and long audio messages
#client_max_body_size 20M;
proxy_read_timeout 10m;
}
# location /supabase2/ {
# #rewrite ^/supabase2/(.*)$ /$1 break;
# proxy_pass http://sdp-kong:8000;
# # Add WebSocket support (Necessary for version 0.5.0 and up)
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# # (Optional) Disable proxy buffering for better streaming response from models
# proxy_buffering off;
# # (Optional) Increase max request size for large attachments and long audio messages
# #client_max_body_size 20M;
# proxy_read_timeout 10m;
# }
# location /ai/ {
# proxy_pass http://localhost:3001/ai/;
# # Add WebSocket support (Necessary for version 0.5.0 and up)
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# # (Optional) Disable proxy buffering for better streaming response from models
# proxy_buffering off;
# # (Optional) Increase max request size for large attachments and long audio messages
# #client_max_body_size 20M;
# proxy_read_timeout 10m;
# }
# location /dashboard/ {
# proxy_pass http://localhost:8800;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_cache_bypass $http_upgrade;
# }
# location /dashboard-dev/ {
# proxy_pass http://localhost:8801;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_cache_bypass $http_upgrade;
# }
# location /realtime/ {
# proxy_pass http://sdp-kong:8000/realtime/; # ส่งไปที่ endpoint ของ backend supabase
# # คอนฟิกสำหรับ WebSocket
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "Upgrade";
# # Header สำคัญอื่นๆ
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# # ป้องกันแชทหลุด (Timeout 1 ชั่วโมง)
# proxy_read_timeout 3600s;
# proxy_send_timeout 3600s;
# }
location /apiservice/ {
# ส่งต่อ Request ไปยัง Backend
proxy_pass http://apiservice:8040;
# การตั้งค่า Header มาตรฐาน
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Ensure the sub-path is handled correctly
proxy_set_header X-Forwarded-Prefix /apiservice;
# รองรับการ Upload ไฟล์ขนาดใหญ่ (ป้องกัน Timeout ระหว่างส่งข้อมูล)
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
# ปิดการพักข้อมูลใน Buffer ของ Nginx ชั่วคราวเพื่อให้การ Upload ลื่นไหลขึ้น
proxy_request_buffering off;
proxy_buffering off;
# เพิ่มเติม: รองรับ WebSocket (เผื่อ Doc หรือ API มีการใช้ Real-time)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
# # Superset Analytics Dashboard -- notwork
location /superset {
proxy_pass http://superset:8088;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Standard headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Superset-specific headers for sub-path support
proxy_set_header X-Forwarded-Prefix /superset;
proxy_set_header X-Script-Name /superset;
# ตัวนี้จะช่วยให้ Superset เข้าใจเรื่อง Path ในการสร้างลิงก์ Static
proxy_set_header X-Forwarded-Host $host;
# Timeout settings (for long-running queries and dashboard loading)
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
# Disable buffering for better streaming
proxy_buffering off;
proxy_request_buffering off;
}
location /dozzle {
# Forward to Dozzle
proxy_pass http://dozzle:8080;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket support for real-time logs
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts for long-running log streams
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Disable buffering for real-time streaming
proxy_buffering off;
proxy_request_buffering off;
}
# Static assets
location /dozzle/assets {
proxy_pass http://dozzle:8080;
proxy_set_header Host $host;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, immutable";
}
# API endpoints
location /dozzle/api {
proxy_pass http://dozzle:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
}
# Health check
location /dozzle/healthcheck {
proxy_pass http://dozzle:8080;
proxy_set_header Host $host;
access_log off;
}
# =============================================
# Superset Analytics Dashboard
# Superset routes are at root level (no single prefix)
# We proxy ALL Superset paths directly to port 8088
# =============================================
# # Redirect /superset/ and /superset to welcome page
# location = /superset/ {
# return 302 /superset/welcome/;
# }
# location = /superset {
# return 302 /superset/welcome/;
# }
# Superset views (welcome, dashboard view, explore, etc.)
# location /superset {
# proxy_pass http://localhost:8088;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header Accept-Encoding "";
# proxy_read_timeout 300s;
# proxy_connect_timeout 300s;
# proxy_send_timeout 300s;
# # Rewrite logo brand link in HTML
# sub_filter_once off;
# sub_filter '"path":"/"' '"path":"/superset/welcome/"';
# sub_filter '"path": "/"' '"path": "/superset/welcome/"';
# }
# # Superset API, static files, auth, and all other root-level routes
# # NOTE: /dashboard/ is NOT included here - it's handled by sriphat-dashboard on port 8800
# location ~ ^/(api|static|login|logout|chart|explore|sqllab|savedqueryview|tablemodelview|tableschemaview|tabstateview|tagview|datasource|dataset|databaseview|annotationlayer|csstemplatemodelview|rowlevelsecurity|embedded|dynamic-plugins|lang|theme|healthcheck|ping|roles|users|user_info|userinfoeditview|register|registrations|resetpassword|resetmypassword|groups|list_groups|back|swagger|alert|report|actionlog)(/|$) {
# proxy_pass http://localhost:8088;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header Accept-Encoding "";
# proxy_read_timeout 300s;
# proxy_connect_timeout 300s;
# proxy_send_timeout 300s;
# # Rewrite logo href in HTML/JS responses
# sub_filter_once off;
# sub_filter_types application/javascript;
# sub_filter '"path":"/"' '"path":"/superset/welcome/"';
# sub_filter "'path':'/'" "'path':'/superset/welcome/'";
# }
# location /aiflow/ {
# proxy_pass http://airflow-webserver:8080;
# # WebSocket support
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# # Standard headers
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# # Superset-specific headers for sub-path support
# proxy_set_header X-Forwarded-Prefix /superset;
# proxy_set_header X-Script-Name /superset;
# # Timeout settings (for long-running queries and dashboard loading)
# proxy_read_timeout 300s;
# proxy_connect_timeout 300s;
# proxy_send_timeout 300s;
# # Disable buffering for better streaming
# proxy_buffering off;
# proxy_request_buffering off;
# }
# location /dbt/ {
# proxy_pass http://dbt:8080;
# # WebSocket support
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# # Standard headers
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# # Superset-specific headers for sub-path support
# proxy_set_header X-Forwarded-Prefix /superset;
# proxy_set_header X-Script-Name /superset;
# # Timeout settings (for long-running queries and dashboard loading)
# proxy_read_timeout 300s;
# proxy_connect_timeout 300s;
# proxy_send_timeout 300s;
# # Disable buffering for better streaming
# proxy_buffering off;
# proxy_request_buffering off;
# }
#listen 443 ssl; # managed by sriphat
#ssl_certificate /etc/letsencrypt/live/ai.bda.co.th/fullchain.pem; # managed by Certbot
#ssl_certificate_key /etc/letsencrypt/live/ai.bda.co.th/privkey.pem; # managed by Certbot
#include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
#ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
#server {
# listen 80 default_server;
# server_name ai.bda.co.th;
# #rewrite ^/[old-page]$ https://[domain]/[new-page] permanent;
# return 301 https://$host$request_uri;
#}

View File

@@ -0,0 +1,59 @@
# Dozzle - Docker Log Viewer & Monitoring
# Subpath: /dozzle
# Backend: dozzle:8080
location /dozzle {
# Forward to Dozzle
proxy_pass http://dozzle:8080;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket support for real-time logs
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts for long-running log streams
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Disable buffering for real-time streaming
proxy_buffering off;
proxy_request_buffering off;
}
# Static assets
location /dozzle/assets {
proxy_pass http://dozzle:8080;
proxy_set_header Host $host;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, immutable";
}
# API endpoints
location /dozzle/api {
proxy_pass http://dozzle:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
}
# Health check
location /dozzle/healthcheck {
proxy_pass http://dozzle:8080;
proxy_set_header Host $host;
access_log off;
}

View File

@@ -0,0 +1,68 @@
# Keycloak - Single Sign-On (SSO)
# Subpath: /keycloak
# Backend: keycloak:8080
location /keycloak {
# Remove /keycloak prefix before forwarding
rewrite ^/keycloak(/.*)$ $1 break;
# Forward to Keycloak
proxy_pass http://keycloak:8080;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Important for Keycloak
proxy_set_header X-Forwarded-Prefix /keycloak;
# Session and cookie handling
proxy_cookie_path / /keycloak/;
# Buffer settings
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Admin console
location /keycloak/admin {
rewrite ^/keycloak(/.*)$ $1 break;
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /keycloak;
proxy_cookie_path / /keycloak/;
}
# Realms
location /keycloak/realms {
rewrite ^/keycloak(/.*)$ $1 break;
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /keycloak;
proxy_cookie_path / /keycloak/;
}
# Resources (CSS, JS, images)
location /keycloak/resources {
rewrite ^/keycloak(/.*)$ $1 break;
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, immutable";
}

View File

@@ -0,0 +1,391 @@
# Nginx Proxy Manager - Setup Guide
คู่มือการตั้งค่า Nginx Proxy Manager สำหรับ Sriphat Data Platform
## 📋 ข้อมูล Services
| Service | Subpath | Container Name | Port | Status |
|---------|---------|----------------|------|--------|
| API Service | `/apiservice` | apiservice | 8000 | ✅ Active |
| Supabase Studio | `/supabase` | sdp-studio | 3000 | ✅ Active |
| Supabase API | `/supabase-api` | sdp-kong | 8000 | ✅ Active |
| Keycloak | `/keycloak` | keycloak | 8080 | ✅ Active |
| Superset | `/superset` | superset | 8088 | ✅ Active |
| Airflow | `/airflow` | airflow-webserver | 8080 | ⚠️ Optional |
| DBT Docs | `/dbt` | dbt-docs | 8080 | ⚠️ Optional |
## 🚀 Quick Start
### 1. เข้า Nginx Proxy Manager
```
URL: http://192.168.100.9:8021
Default Login:
Email: admin@example.com
Password: changeme
```
**⚠️ เปลี่ยน password ทันทีหลัง login ครั้งแรก!**
### 2. สร้าง Proxy Host
1. คลิก **Proxy Hosts****Add Proxy Host**
2. กรอกข้อมูล:
**Tab: Details**
```
Domain Names: ai.sriphat.com
Scheme: http
Forward Hostname/IP: localhost
Forward Port: 80
```
**Tab: Advanced**
- Copy config จาก `complete-example.conf` ทั้งหมด
- Paste ลงใน **Custom Nginx Configuration**
3. กด **Save**
### 3. ตั้งค่า SSL (Production)
**Tab: SSL**
```
☑ Request a new SSL Certificate with Let's Encrypt
☑ Force SSL
☑ HTTP/2 Support
☑ HSTS Enabled
Email: your-email@example.com
```
กด **Save**
## 📝 การใช้งานแบบละเอียด
### วิธีที่ 1: ใช้ Complete Config (แนะนำ)
**ข้อดี:**
- ตั้งค่าครั้งเดียว ได้ทุก service
- ง่ายต่อการจัดการ
- Consistent configuration
**ขั้นตอน:**
1. สร้าง Proxy Host ตาม Quick Start
2. Copy `complete-example.conf` ไปวางใน Advanced tab
3. Save
### วิธีที่ 2: แยก Config แต่ละ Service
**ข้อดี:**
- ควบคุมแต่ละ service ได้อิสระ
- ง่ายต่อการ debug
- สามารถตั้งค่า SSL แยกกันได้
**ขั้นตอน:**
#### API Service
```
Domain: api.sriphat.com
Forward: apiservice:8000
Advanced: ใช้ config จาก apiservice.conf
```
#### Supabase Studio
```
Domain: supabase.sriphat.com
Forward: sdp-studio:3000
Advanced: ใช้ config จาก supabase-studio.conf
```
#### Keycloak
```
Domain: auth.sriphat.com
Forward: keycloak:8080
Advanced: ใช้ config จาก keycloak.conf
```
#### Superset
```
Domain: bi.sriphat.com
Forward: superset:8088
Advanced: ใช้ config จาก superset.conf
```
## 🔧 Configuration Details
### API Service (`/apiservice`)
**สิ่งสำคัญ:**
```nginx
proxy_set_header X-Script-Name /apiservice;
proxy_cookie_path / /apiservice/;
```
**ทำไม:**
- FastAPI ต้องรู้ว่าทำงานภายใต้ subpath
- Session cookies ต้อง scope ถูกต้อง
### Keycloak (`/keycloak`)
**สิ่งสำคัญ:**
```nginx
proxy_set_header X-Forwarded-Prefix /keycloak;
proxy_cookie_path / /keycloak/;
```
**ทำไม:**
- Keycloak ใช้ X-Forwarded-Prefix สำหรับ redirect URLs
- Authentication flow ต้องการ cookie path ที่ถูกต้อง
### Supabase Studio (`/supabase`)
**สิ่งสำคัญ:**
```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
**ทำไม:**
- Supabase Studio ใช้ WebSocket สำหรับ real-time features
- ต้อง support HTTP/1.1 upgrade
### Superset (`/superset`)
**สิ่งสำคัญ:**
```nginx
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
```
**ทำไม:**
- Dashboard queries อาจใช้เวลานาน
- ต้องการ timeout ที่สูงกว่าปกติ
## 🐛 Troubleshooting
### 502 Bad Gateway
**สาเหตุ:**
- Backend service ไม่ทำงาน
- Network configuration ผิด
**วิธีแก้:**
```bash
# ตรวจสอบ service
docker ps | grep <service-name>
# ตรวจสอบ network
docker network inspect shared_data_network
# ดู logs
docker logs <service-name>
docker logs nginx-proxy-manager
```
### 404 Not Found
**สาเหตุ:**
- Path rewriting ไม่ถูกต้อง
- Backend ไม่ support subpath
**วิธีแก้:**
```nginx
# ตรวจสอบ rewrite rule
rewrite ^/apiservice(/.*)$ $1 break;
# ดู nginx logs
docker exec nginx-proxy-manager tail -f /data/logs/proxy-host-*.log
```
### Redirect Loop
**สาเหตุ:**
- Cookie path ไม่ถูกต้อง
- X-Forwarded-* headers ขาดหาย
**วิธีแก้:**
```nginx
# เพิ่ม headers
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# ตั้งค่า cookie path
proxy_cookie_path / /apiservice/;
```
### WebSocket Connection Failed
**สาเหตุ:**
- ไม่มี WebSocket headers
- HTTP version ไม่ถูกต้อง
**วิธีแก้:**
```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
### Session Lost After Refresh
**สาเหตุ:**
- Cookie path ไม่ match กับ URL path
- SameSite cookie policy
**วิธีแก้:**
```nginx
proxy_cookie_path / /apiservice/;
proxy_cookie_flags ~ secure samesite=lax;
```
## 🔐 Security Best Practices
### 1. เปลี่ยน Default Password
```
Settings → Users → Edit admin user
```
### 2. ใช้ HTTPS ใน Production
```
SSL Tab → Request a new SSL Certificate
☑ Force SSL
☑ HSTS Enabled
```
### 3. ตั้งค่า Access Lists
```
Access Lists → Add Access List
- Whitelist IP addresses
- Basic authentication
- Apply to sensitive services (Keycloak Admin, Superset)
```
### 4. Enable Rate Limiting
```nginx
# ใน Custom Nginx Configuration
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req zone=api_limit burst=20 nodelay;
```
### 5. Hide Nginx Version
```nginx
# ใน Custom Nginx Configuration
server_tokens off;
```
## 📊 Monitoring
### ดู Logs
**ใน Nginx Proxy Manager UI:**
```
Proxy Hosts → Click on host → Logs tab
```
**ใน Docker:**
```bash
# Nginx Proxy Manager logs
docker logs nginx-proxy-manager -f
# Access logs
docker exec nginx-proxy-manager tail -f /data/logs/proxy-host-*.log
# Error logs
docker exec nginx-proxy-manager tail -f /data/logs/error.log
```
### Health Checks
```bash
# ตรวจสอบ Nginx Proxy Manager
curl http://192.168.100.9:8021
# ตรวจสอบ services ผ่าน proxy
curl http://ai.sriphat.com/apiservice/docs
curl http://ai.sriphat.com/supabase
curl http://ai.sriphat.com/keycloak
```
## 🔄 Backup & Restore
### Backup Configuration
```bash
# Backup Nginx Proxy Manager data
cd /path/to/01-infra
tar -czf npm-backup-$(date +%Y%m%d).tar.gz data/
# Backup specific configs
docker exec nginx-proxy-manager tar -czf /tmp/configs.tar.gz /data/nginx
docker cp nginx-proxy-manager:/tmp/configs.tar.gz ./npm-configs-backup.tar.gz
```
### Restore Configuration
```bash
# Stop Nginx Proxy Manager
docker compose down
# Restore data
tar -xzf npm-backup-YYYYMMDD.tar.gz
# Start Nginx Proxy Manager
docker compose up -d
```
## 📚 Additional Resources
- [Nginx Proxy Manager Docs](https://nginxproxymanager.com/guide/)
- [Nginx Reverse Proxy Guide](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/)
- [Let's Encrypt SSL](https://letsencrypt.org/getting-started/)
## 💡 Tips & Tricks
### 1. Test Config Before Applying
```bash
# Test nginx config
docker exec nginx-proxy-manager nginx -t
```
### 2. Reload Without Restart
```bash
# Reload nginx (no downtime)
docker exec nginx-proxy-manager nginx -s reload
```
### 3. View Current Config
```bash
# View active nginx config
docker exec nginx-proxy-manager cat /etc/nginx/nginx.conf
```
### 4. Debug Mode
```nginx
# เพิ่มใน Custom Nginx Configuration
error_log /data/logs/error.log debug;
```
### 5. Custom Error Pages
```nginx
# เพิ่มใน Custom Nginx Configuration
error_page 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
```
## 🎯 Production Checklist
- [ ] เปลี่ยน default admin password
- [ ] ตั้งค่า SSL certificate (Let's Encrypt)
- [ ] Enable Force SSL
- [ ] Enable HSTS
- [ ] ตั้งค่า Access Lists สำหรับ admin panels
- [ ] Enable rate limiting
- [ ] Hide server tokens
- [ ] ตั้งค่า backup schedule
- [ ] Test all services ผ่าน proxy
- [ ] Monitor logs สำหรับ errors
- [ ] Document custom configurations

View File

@@ -0,0 +1,88 @@
# Supabase Kong API Gateway - REST API
# Subpath: /supabase-api
# Backend: sdp-kong:8000
location /supabase-api {
# Remove /supabase-api prefix before forwarding
rewrite ^/supabase-api(/.*)$ $1 break;
# Forward to Kong Gateway
proxy_pass http://sdp-kong:8000;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# API key and authorization headers
proxy_set_header Authorization $http_authorization;
proxy_set_header apikey $http_apikey;
# CORS headers (if needed)
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, apikey, X-Client-Info" always;
# Handle preflight requests
if ($request_method = 'OPTIONS') {
return 204;
}
# Timeouts for API calls
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# Disable buffering for streaming responses
proxy_buffering off;
}
# REST API endpoints
location /supabase-api/rest {
rewrite ^/supabase-api(/.*)$ $1 break;
proxy_pass http://sdp-kong:8000;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header apikey $http_apikey;
}
# Auth endpoints
location /supabase-api/auth {
rewrite ^/supabase-api(/.*)$ $1 break;
proxy_pass http://sdp-kong:8000;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header apikey $http_apikey;
}
# Storage endpoints
location /supabase-api/storage {
rewrite ^/supabase-api(/.*)$ $1 break;
proxy_pass http://sdp-kong:8000;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header apikey $http_apikey;
# Larger timeouts for file uploads
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
client_max_body_size 100M;
}
# Realtime endpoints (WebSocket)
location /supabase-api/realtime {
rewrite ^/supabase-api(/.*)$ $1 break;
proxy_pass http://sdp-kong:8000;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header apikey $http_apikey;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}

View File

@@ -0,0 +1,50 @@
# Supabase Studio - Database Management UI
# Subpath: /supabase
# Backend: sdp-studio:3000
location /supabase {
# Remove /supabase prefix before forwarding
rewrite ^/supabase(/.*)$ $1 break;
# Forward to Supabase Studio
proxy_pass http://sdp-studio:3000;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket support for real-time features
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Disable buffering for real-time updates
proxy_buffering off;
}
# API endpoints for Studio
location /supabase/api {
rewrite ^/supabase(/.*)$ $1 break;
proxy_pass http://sdp-studio:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Static assets
location /supabase/_next {
rewrite ^/supabase(/.*)$ $1 break;
proxy_pass http://sdp-studio:3000;
proxy_set_header Host $host;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, immutable";
}

View File

@@ -0,0 +1,68 @@
# Apache Superset - Business Intelligence
# Subpath: /superset
# Backend: superset:8088
location /superset {
# Remove /superset prefix before forwarding
rewrite ^/superset(/.*)$ $1 break;
# Forward to Superset
proxy_pass http://superset:8088;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Important for Superset subpath
proxy_set_header X-Script-Name /superset;
# Session cookie handling
proxy_cookie_path / /superset/;
# WebSocket support for real-time dashboards
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts (dashboards can take time to load)
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Buffer settings for large responses
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# API endpoints
location /superset/api {
rewrite ^/superset(/.*)$ $1 break;
proxy_pass http://superset:8088;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Script-Name /superset;
proxy_cookie_path / /superset/;
}
# Static files
location /superset/static {
rewrite ^/superset(/.*)$ $1 break;
proxy_pass http://superset:8088;
proxy_set_header Host $host;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, immutable";
}
# Superset assets
location /superset/superset {
rewrite ^/superset(/.*)$ $1 break;
proxy_pass http://superset:8088;
proxy_set_header Host $host;
proxy_set_header X-Script-Name /superset;
}

View File

@@ -40,6 +40,7 @@ services:
LOGFLARE_URL: http://sdp-analytics:4000
NEXT_PUBLIC_ENABLE_LOGS: true
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
SNIPPETS_MANAGEMENT_FOLDER: "/app/snippets"
volumes:
- ./volumes/snippets:/app/snippets:Z
- ./volumes/functions:/app/supabase/functions:Z
@@ -433,12 +434,16 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
## ecto
DATABASE_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
POOLER_TENANT_ID: ${POOLER_TENANT_ID} # MUST BE ADDED
POOLER_TENANT_ID: ${POOLER_TENANT_ID:-sriphat}
CLUSTER_POSTGRES: true
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
VAULT_ENC_KEY: ${VAULT_ENC_KEY}
API_JWT_SECRET: ${JWT_SECRET}
METRICS_JWT_SECRET: ${JWT_SECRET}
# Ensure these are also mapped if they are in your .env
POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
POOLER_DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE}
networks:
- shared_data_network

View File

@@ -32,3 +32,18 @@ ADMIN_PASSWORD=your-admin-password
# API Key Encryption (for storing encrypted keys in DB)
API_KEY_ENC_SECRET=your-encryption-secret-key-here
# Debug settings (set to true for detailed logging, false for production)
DEBUG_AUTH=false
# Keycloak Authentication (for web pages)
KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=apiservice
KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
KEYCLOAK_REDIRECT_URI=http://localhost:8040/apiservice/auth/callback
# Airflow Integration
AIRFLOW_API_URL=http://airflow-webserver:8080
AIRFLOW_API_TOKEN=your-airflow-api-token
AIRFLOW_DAG_ID_FINANCE=process_finance_excel

View File

@@ -8,3 +8,5 @@ venv/
.mypy_cache/
ruff_cache/
.windsurf/
data/uploads/*
!data/uploads/.gitkeep

View File

@@ -0,0 +1,334 @@
# Airflow Integration Guide
คู่มือการตั้งค่าและใช้งาน Airflow Integration สำหรับ Finance Excel Upload
## 📋 Overview
เมื่อ upload ไฟล์ Excel ผ่านหน้า `/data-management/finance` ระบบจะ:
1. บันทึกไฟล์ลง `/data/uploads/`
2. สร้าง record ใน database (`fastapi.upload_history`)
3. **Trigger Airflow DAG** `process_finance_excel` (พร้อม retry 3 ครั้ง, รอ 10 วิ)
4. Airflow จะ process ไฟล์และอัพเดท status กลับมา
---
## 🔧 Configuration
### 1. สร้าง Airflow API Token
```bash
# เข้า Airflow container
docker exec -it airflow-webserver bash
# สร้าง API token
airflow users create \
--username apiservice \
--firstname API \
--lastname Service \
--role Admin \
--email apiservice@sriphat.com \
--password your-secure-password
# หรือใช้ CLI สร้าง token
python -c "from airflow.api.auth.backend.basic_auth import BasicAuth; print(BasicAuth().get_token('apiservice', 'your-secure-password'))"
```
**หรือใช้ Airflow UI:**
1. เข้า `http://ai.sriphat.com/airflow`
2. ไปที่ **Security****List Users**
3. เลือก user → **Edit**
4. Copy **API Token** (หรือสร้างใหม่)
### 2. อัพเดท Environment Variables
แก้ไข `.env` ใน `03-apiservice/`:
```bash
# Airflow Integration
AIRFLOW_API_URL=http://airflow-webserver:8080
AIRFLOW_API_TOKEN=your-airflow-api-token-here
AIRFLOW_DAG_ID_FINANCE=process_finance_excel
```
### 3. Restart API Service
```bash
cd 03-apiservice
docker compose restart
```
---
## 📊 Database Schema Updates
### New Fields in `fastapi.upload_history`:
| Field | Type | Description |
|-------|------|-------------|
| `airflow_dag_run_id` | String | DAG run ID จาก Airflow |
| `airflow_state` | String | State: queued, running, success, failed |
| `processing_started_at` | DateTime | เวลาเริ่ม process |
| `processing_completed_at` | DateTime | เวลาเสร็จ |
| `error_message` | Text | Error message (ถ้ามี) |
### Migration
```bash
# Restart apiservice เพื่อสร้าง columns ใหม่
cd 03-apiservice
docker compose down
docker compose up -d
```
---
## 🚀 How It Works
### Upload Flow:
```
1. User uploads Excel file
2. API saves file to /data/uploads/
3. Create UploadHistory record (status: pending)
4. Trigger Airflow DAG (with retry logic)
├─ Attempt 1: Try trigger
├─ Wait 10 seconds
├─ Attempt 2: Retry
├─ Wait 10 seconds
└─ Attempt 3: Final retry
5. Update record:
- Success: status=processing, airflow_dag_run_id=xxx
- Failure: status=error, error_message=xxx
6. Return response to frontend
```
### Airflow DAG Flow:
```
validate_file
process_data
load_to_database
update_status
```
---
## 📝 API Endpoints
### Upload File (Triggers Airflow)
```http
POST /data-management/finance/upload
Content-Type: multipart/form-data
file: [Excel file]
description: "Optional description"
```
**Response:**
```json
{
"success": true,
"message": "File 'finance.xlsx' uploaded successfully",
"upload_id": "upload_20260313_173000",
"filename": "20260313_173000_finance.xlsx",
"airflow_triggered": true,
"dag_run_id": "manual__2026-03-13T10:30:00+00:00",
"error": null
}
```
### Get Upload History
```http
GET /data-management/finance/uploads
```
**Response:**
```json
[
{
"id": "upload_20260313_173000",
"filename": "finance.xlsx",
"filepath": "/data/uploads/20260313_173000_finance.xlsx",
"uploaded_at": "2026-03-13T17:30:00",
"description": "Q1 2026 Finance Data",
"status": "processing",
"uploaded_by": "admin",
"airflow_dag_run_id": "manual__2026-03-13T10:30:00+00:00",
"airflow_state": "running",
"processing_started_at": "2026-03-13T17:30:05",
"processing_completed_at": null,
"error_message": null
}
]
```
---
## 🔍 Monitoring
### 1. Check Upload Status
```bash
# ดู logs ของ API Service
docker logs apiservice -f | grep "Airflow"
# ตัวอย่าง logs:
# Triggering Airflow DAG (attempt 1/3)
# Airflow DAG triggered successfully: manual__2026-03-13T10:30:00+00:00
```
### 2. Check Airflow DAG
1. เข้า Airflow UI: `http://ai.sriphat.com/airflow`
2. ไปที่ **DAGs**`process_finance_excel`
3. ดู **DAG Runs** และ **Task Instances**
### 3. Check Database
```sql
-- ดู upload history พร้อม Airflow status
SELECT
upload_id,
filename,
status,
airflow_dag_run_id,
airflow_state,
processing_started_at,
processing_completed_at,
error_message
FROM fastapi.upload_history
ORDER BY uploaded_at DESC
LIMIT 10;
```
---
## 🐛 Troubleshooting
### Issue: "Failed to trigger Airflow after 3 attempts"
**สาเหตุ:**
- Airflow API Token ไม่ถูกต้อง
- Airflow webserver ไม่ทำงาน
- Network connection ล้มเหลว
**วิธีแก้:**
```bash
# 1. ตรวจสอบ Airflow webserver
docker ps | grep airflow-webserver
# 2. ตรวจสอบ network
docker network inspect shared_data_network
# 3. ทดสอบ API token
curl -X GET \
http://airflow-webserver:8080/api/v1/dags \
-H "Authorization: Bearer YOUR_TOKEN"
# 4. ดู logs
docker logs airflow-webserver -f
docker logs apiservice -f
```
### Issue: DAG ไม่ปรากฏใน Airflow UI
**สาเหตุ:**
- DAG file มี syntax error
- DAG ถูก pause
- Airflow scheduler ไม่ทำงาน
**วิธีแก้:**
```bash
# 1. ตรวจสอบ DAG file
docker exec airflow-webserver airflow dags list | grep process_finance_excel
# 2. Test DAG
docker exec airflow-webserver airflow dags test process_finance_excel
# 3. Unpause DAG
docker exec airflow-webserver airflow dags unpause process_finance_excel
# 4. ตรวจสอบ scheduler
docker logs airflow-scheduler -f
```
### Issue: DAG triggered แต่ไม่ทำงาน
**สาเหตุ:**
- Volume mount ไม่ถูกต้อง (Airflow เข้าถึง `/data/uploads` ไม่ได้)
- Dependencies ขาดหาย (pandas, openpyxl)
**วิธีแก้:**
```bash
# 1. ตรวจสอบ volume
docker exec airflow-worker ls -la /data/uploads
# 2. ติดตั้ง dependencies
docker exec airflow-worker pip install pandas openpyxl
# หรือเพิ่มใน docker-compose.yaml:
# _PIP_ADDITIONAL_REQUIREMENTS: "pandas openpyxl"
```
---
## 🔐 Security Notes
1. **API Token Security:**
- เก็บ token ใน `.env` (ไม่ commit ลง git)
- ใช้ token ที่มี scope จำกัด (ไม่ใช่ admin token)
- Rotate token เป็นระยะ
2. **File Access:**
- Airflow ต้องมี read access ไปยัง `/data/uploads`
- ตรวจสอบ file permissions
3. **Network:**
- API Service และ Airflow ต้องอยู่ใน `shared_data_network`
- ใช้ internal network (ไม่ expose Airflow API ออกภายนอก)
---
## 📚 References
- [Airflow REST API Documentation](https://airflow.apache.org/docs/apache-airflow/stable/stable-rest-api-ref.html)
- [Airflow Authentication](https://airflow.apache.org/docs/apache-airflow/stable/security/api.html)
- [FastAPI Background Tasks](https://fastapi.tiangolo.com/tutorial/background-tasks/)
---
## 🎯 Next Steps
1. **Implement DAG Logic:**
- แก้ไข `05-airflow/dags/process_finance_excel.py`
- เพิ่ม data transformation logic
- เพิ่ม database loading logic
2. **Add Callback:**
- สร้าง endpoint `/api/v1/airflow/callback/{upload_id}`
- Airflow จะ callback เมื่อเสร็จ/ล้มเหลว
- อัพเดท `processing_completed_at` และ `airflow_state`
3. **Frontend Updates:**
- เพิ่ม real-time status polling
- แสดง Airflow state badges
- Link ไปยัง Airflow DAG run
4. **Testing:**
- ทดสอบ upload file
- ตรวจสอบ Airflow trigger
- ตรวจสอบ retry logic
- ทดสอบ error handling

View File

@@ -0,0 +1,325 @@
# Keycloak Setup Guide for API Service Authentication
## Prerequisites
1. Keycloak must be running at `http://localhost:8085`
2. PostgreSQL database must have `keycloak` database created
3. API Service must have Keycloak configuration in `.env`
## Step 1: Restart Infrastructure to Create Keycloak Database
```bash
cd 01-infra
docker compose down
docker compose --env-file ../.env.global up -d
```
Wait for Keycloak to initialize (check logs):
```bash
docker logs keycloak -f
```
Look for: `Keycloak ... started`
## Step 2: Access Keycloak Admin Console
1. Open browser: `http://localhost:8085/admin`
2. Login credentials:
- **Username**: `admin`
- **Password**: `admin_secret_pass_2026`
## Step 3: Create Client for API Service
### 3.1 Navigate to Clients
1. In left sidebar, click **Clients**
2. Click **Create client** button
### 3.2 General Settings
- **Client type**: `OpenID Connect`
- **Client ID**: `apiservice`
- Click **Next**
### 3.3 Capability Config
- **Client authentication**: `ON` (toggle to enable)
- **Authorization**: `OFF`
- **Authentication flow**:
- ✅ Standard flow
- ✅ Direct access grants
- ❌ Implicit flow (uncheck)
- ❌ Service accounts roles (uncheck)
- Click **Next**
### 3.4 Login Settings
Fill in the following URLs:
**Root URL**:
```
http://localhost:8040
```
**Home URL**:
```
http://localhost:8040/apiservice/
```
**Valid redirect URIs** (add both):
```
http://localhost:8040/apiservice/auth/callback
https://ai.sriphat.com/apiservice/auth/callback
```
**Valid post logout redirect URIs** (add both):
```
http://localhost:8040/apiservice/
https://ai.sriphat.com/apiservice/
```
**Web origins** (add both):
```
http://localhost:8040
https://ai.sriphat.com
```
Click **Save**
### 3.5 Get Client Secret
1. Go to **Credentials** tab
2. Copy the **Client secret** value
3. Save it - you'll need it for the `.env` file
## Step 4: Configure API Service
### 4.1 Create/Update `.env` file in `03-apiservice`
```bash
cd ../03-apiservice
```
Create `.env` file with:
```bash
# Application
APP_NAME=APIsService
ROOT_PATH=/apiservice
# Timezone
TIMEZONE=Asia/Bangkok
# PostgreSQL Database
DB_HOST=postgres
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=Secure_Hospital_Pass_2026
DB_NAME=postgres
DB_SSLMODE=prefer
# Supabase (if not using, keep dummy values)
SUPABASE_DB_HOST=localhost
SUPABASE_DB_PORT=5432
SUPABASE_DB_USER=postgres
SUPABASE_DB_PASSWORD=dummy
SUPABASE_DB_NAME=postgres
SUPABASE_DB_SSLMODE=disable
SUPABASE_API_URL=http://localhost:8000
SUPABASE_API_KEY=dummy
# Admin Authentication
ADMIN_SECRET_KEY=apiservice_admin_secret_2026
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change_me_2026
# API Key Encryption
API_KEY_ENC_SECRET=dev_encryption_secret_2026
# Keycloak Authentication
KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=apiservice
KEYCLOAK_CLIENT_SECRET=<PASTE_YOUR_CLIENT_SECRET_HERE>
KEYCLOAK_REDIRECT_URI=http://localhost:8040/apiservice/auth/callback
```
**Important**: Replace `<PASTE_YOUR_CLIENT_SECRET_HERE>` with the actual secret from Step 3.5
## Step 5: Create Test User in Keycloak
### 5.1 Create User
1. In Keycloak Admin, go to **Users**
2. Click **Add user**
3. Fill in:
- **Username**: `testuser`
- **Email**: `test@sriphat.local`
- **First name**: `Test`
- **Last name**: `User`
- **Email verified**: `ON` (toggle)
4. Click **Create**
### 5.2 Set Password
1. Go to **Credentials** tab
2. Click **Set password**
3. Fill in:
- **Password**: `password`
- **Password confirmation**: `password`
- **Temporary**: `OFF` (toggle off)
4. Click **Save**
5. Confirm by clicking **Save password**
## Step 6: Rebuild and Restart API Service
```bash
cd 03-apiservice
docker compose down
docker compose --env-file ../.env up --build -d
```
Check logs:
```bash
docker logs apiservice -f
```
Look for successful startup without errors.
## Step 7: Test Authentication Flow
### 7.1 Access Landing Page
1. Open browser: `http://localhost:8040/apiservice/`
2. You should be redirected to Keycloak login page
### 7.2 Login
1. Enter credentials:
- **Username**: `testuser`
- **Password**: `password`
2. Click **Sign In**
### 7.3 Verify Success
1. You should be redirected back to the landing page
2. You should see your name in the top-right corner: "👤 Test User"
3. You should see a **Logout** button
### 7.4 Test Protected Pages
Try accessing:
- `/apiservice/` - Landing page (requires auth)
- `/apiservice/docs` - API docs (requires auth)
- `/apiservice/data-management/finance` - Finance page (requires auth)
All should work without redirecting to login again.
### 7.5 Test API Endpoints (Should NOT require Keycloak auth)
```bash
# API endpoints use API Key authentication, not Keycloak
curl -X GET http://localhost:8040/apiservice/api/v1/health
```
This should work without Keycloak authentication.
### 7.6 Test Logout
1. Click **Logout** button
2. You should be logged out from Keycloak
3. Accessing any protected page should redirect to login again
## Troubleshooting
### Error: "access_denied" in Keycloak logs
**Cause**: Client not configured correctly or client secret mismatch
**Solution**:
1. Verify client ID is exactly `apiservice`
2. Verify client secret in `.env` matches Keycloak
3. Verify redirect URIs are correct
4. Restart API service after changing `.env`
### Error: "SessionMiddleware must be installed"
**Cause**: Middleware order is incorrect
**Solution**: Already fixed in `app/main.py` - SessionMiddleware is added after WebAuthenticationMiddleware
### Error: "Invalid redirect_uri"
**Cause**: Redirect URI not in Keycloak's allowed list
**Solution**:
1. Go to Keycloak → Clients → apiservice → Settings
2. Add the exact redirect URI to "Valid redirect URIs"
3. Make sure it matches `KEYCLOAK_REDIRECT_URI` in `.env`
### Error: Connection refused to Keycloak
**Cause**: Keycloak not running or wrong URL
**Solution**:
1. Check Keycloak is running: `docker ps | grep keycloak`
2. Verify `KEYCLOAK_SERVER_URL=http://keycloak:8080` (use container name, not localhost)
3. Verify both services are on same Docker network
## Production Deployment
For production deployment:
1. **Use HTTPS**:
```bash
KEYCLOAK_REDIRECT_URI=https://ai.sriphat.com/apiservice/auth/callback
```
2. **Update Keycloak Client**:
- Add production redirect URIs
- Remove localhost URIs
3. **Secure Cookies**:
- SessionMiddleware should use `secure=True, httponly=True, samesite='lax'`
4. **Use Nginx Proxy Manager**:
- Configure SSL certificates
- Set up proper reverse proxy rules
5. **Environment Variables**:
- Use Docker secrets or external secret management
- Never commit `.env` with real secrets to git
## Security Notes
1. **Client Secret**: Keep this secret! Never commit to git
2. **Session Secret**: Use strong random value for `ADMIN_SECRET_KEY`
3. **HTTPS**: Always use HTTPS in production
4. **Token Expiration**: Configure appropriate token lifetimes in Keycloak
5. **CORS**: Restrict `allow_origins` to known domains only
## Architecture
```
User Browser
↓ (1) Access protected page
API Service (FastAPI)
↓ (2) Check session - not authenticated
↓ (3) Redirect to Keycloak login
Keycloak (OAuth2/OIDC)
↓ (4) User enters credentials
↓ (5) Redirect back with auth code
API Service
↓ (6) Exchange code for tokens
↓ (7) Store user info in session
↓ (8) Redirect to original page
User sees protected page with user info
```
## API vs Web Authentication
| Type | Authentication Method | Protected Routes |
|------|----------------------|------------------|
| **Web Pages** | Keycloak (OAuth2/OIDC) | `/`, `/docs`, `/data-management/*` |
| **API Endpoints** | API Key (Bearer Token) | `/api/v1/*` |
| **Admin Panel** | SQLAdmin (Basic Auth) | `/admin/*` |
This separation allows:
- Human users to login via Keycloak for web UI
- Applications/services to use API Keys for programmatic access
- Admins to manage API keys via separate admin panel

View File

@@ -0,0 +1,226 @@
# Web Pages Documentation
## Overview
The API service now includes web pages for easy access to all platform services and data management functionality.
## Pages
### 1. Landing Page (`/`)
Main dashboard with navigation to all services:
- **Supabase**: PostgreSQL database with REST API
- **API Docs**: Interactive API documentation
- **Airflow**: Workflow orchestration
- **Airbyte**: Data integration and ETL
- **Data Management**: File upload and processing (with submenu)
- **DBT**: Data transformation
- **Superset**: Business intelligence and visualization
### 2. Finance Excel Upload (`/data-management/finance`)
Upload and process financial Excel files with the following features:
**Upload Form:**
- Drag-and-drop or click to select Excel files (.xlsx, .xls)
- Optional description field
- File validation and size display
**Upload Processing:**
- Files saved to `/data/uploads/` with timestamp prefix
- Unique filename generation to prevent conflicts
- Automatic Airflow job triggering (when configured)
**Upload History:**
- Real-time list of all uploaded files
- Status tracking: pending, processing, success, error
- Expandable accordion for logs and error details
- Auto-refresh every 10 seconds
## API Endpoints
### Page Routes
```
GET / - Landing page
GET /data-management/finance - Finance upload page
POST /data-management/finance/upload - Upload file endpoint
GET /data-management/finance/uploads - Get all uploads
GET /data-management/finance/uploads/{upload_id} - Get specific upload
```
### Upload Endpoint
**POST** `/data-management/finance/upload`
**Form Data:**
- `file`: Excel file (.xlsx or .xls)
- `description`: Optional description text
**Response:**
```json
{
"success": true,
"message": "File 'example.xlsx' uploaded successfully",
"upload_id": "upload_20260306_174500",
"filename": "20260306_174500_example.xlsx"
}
```
**Error Response:**
```json
{
"detail": "Invalid file type. Only .xlsx and .xls files are allowed."
}
```
## File Storage
Uploaded files are stored in:
```
/data/uploads/YYYYMMDD_HHMMSS_original_filename.xlsx
```
Example:
```
/data/uploads/20260306_174530_finance_report.xlsx
```
## Airflow Integration
The upload endpoint includes a placeholder for Airflow integration. To enable:
1. **Configure Airflow endpoint** in `app/routes/pages.py`:
```python
airflow_url = "http://airflow-webserver:8080/api/v1/dags/{dag_id}/dagRuns"
```
2. **Set DAG ID** for the finance processing job
3. **Uncomment the Airflow trigger code** in `trigger_airflow_job()` function
4. **Configure authentication** (username/password or API token)
### Example Airflow Integration
```python
async def trigger_airflow_job(filepath: str, upload_id: str) -> str:
import httpx
airflow_url = "http://airflow-webserver:8080/api/v1/dags/finance_processing/dagRuns"
headers = {"Content-Type": "application/json"}
auth = ("airflow", "airflow")
payload = {
"conf": {
"filepath": filepath,
"upload_id": upload_id
}
}
async with httpx.AsyncClient() as client:
response = await client.post(
airflow_url,
json=payload,
headers=headers,
auth=auth
)
response.raise_for_status()
result = response.json()
return result["dag_run_id"]
```
## Upload Status Tracking
Upload records include:
- `id`: Unique identifier
- `filename`: Original filename
- `filepath`: Full path to saved file
- `uploaded_at`: ISO timestamp
- `description`: User-provided description
- `status`: pending | processing | success | error
- `job_id`: Airflow DAG run ID (when triggered)
- `logs`: Error logs or processing information
## Database Storage
Currently uses in-memory storage (`uploads_db` list). For production:
1. **Create database table:**
```sql
CREATE TABLE uploads (
id VARCHAR(100) PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
filepath VARCHAR(500) NOT NULL,
uploaded_at TIMESTAMP NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL,
job_id VARCHAR(100),
logs TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
```
2. **Replace in-memory storage** with SQLAlchemy model
3. **Add database queries** in route handlers
## Access URLs
- **Local**: http://localhost:8040/apiservice/
- **Production**: https://ai.sriphat.com/apiservice/
## Security Considerations
1. **File validation**: Only .xlsx and .xls files allowed
2. **Filename sanitization**: Spaces replaced with underscores
3. **Unique filenames**: Timestamp prefix prevents conflicts
4. **File size limits**: Configure in nginx/FastAPI if needed
5. **Authentication**: Add authentication middleware for production
## Future Enhancements
1. **Database persistence**: Replace in-memory storage
2. **File preview**: Show Excel data preview before processing
3. **Batch upload**: Support multiple file uploads
4. **Download processed files**: Allow downloading results
5. **User management**: Track uploads by user
6. **Email notifications**: Notify on processing completion
7. **Scheduled jobs**: Automatic periodic processing
8. **Data validation**: Validate Excel structure before processing
## Troubleshooting
### Upload fails with 500 error
- Check `/data/uploads/` directory exists and is writable
- Verify volume mount in docker-compose.yml
- Check container logs: `docker logs apiservice`
### Files not persisting after container restart
- Ensure volume mount is configured: `./data/uploads:/data/uploads`
- Check file permissions on host directory
### Airflow job not triggering
- Verify Airflow is accessible from container
- Check network connectivity: `docker network inspect shared_data_network`
- Verify Airflow credentials and DAG ID
- Check logs in upload history
## Development
To add new data management pages:
1. Create HTML template in `app/templates/`
2. Add route in `app/routes/pages.py`
3. Update landing page menu in `index.html`
4. Add submenu item if needed
Example:
```python
@router.get("/data-management/hr", response_class=HTMLResponse)
async def hr_page(request: Request):
return templates.TemplateResponse(
"data_management_hr.html",
{"request": request, "root_path": settings.ROOT_PATH}
)
```

View File

@@ -9,9 +9,9 @@ from fastapi import APIRouter, Depends
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from app.api.v1.schemas import FeedCheckpointIn, FeedWaitingTimeIn
from app.api.v1.schemas import FeedCheckpointIn, FeedWaitingTimeIn, PatientAppointmentIn
from app.core.config import settings
from app.db.models import RawOpdCheckpoint, RawWaitingTime
from app.db.models import RawOpdCheckpoint, RawWaitingTime, PatientAppointment
from app.security.dependencies import get_db, require_permission
from app.utils.supabase_client import SupabaseAPIError, upsert_to_supabase_sync
@@ -21,6 +21,7 @@ router = APIRouter(prefix="/api/v1")
PERM_FEED_CHECKPOINT_WRITE = "feed.checkpoint:write"
PERM_FEED_OLD_CHECKPOINT_WRITE = "feed.old-checkpoint:write"
PERM_FEED_PATIENT_APPOINTMENT_WRITE = "feed.patient-appointment:write"
def _to_tz(dt):
@@ -220,3 +221,86 @@ def upsert_opd_checkpoint(
"error": supabase_error,
},
}
@router.post("/feed/patient-appointment")
def upsert_patient_appointment(
payload: list[PatientAppointmentIn],
_: Annotated[object, Depends(require_permission(PERM_FEED_PATIENT_APPOINTMENT_WRITE))],
db: Annotated[Session, Depends(get_db)],
):
rows = []
supabase_rows = []
for item in payload:
# Prepare data for local database
row = {
"hn": item.hn,
"txn": item.txn,
"date": item.date, #+' 0:00:00'
"time": _to_tz(datetime.strptime(item.date.strftime("%Y-%m-%d")+' '+item.time,'%Y-%m-%d %H:%M:%S')),
"doctor_code": item.doctor_code,
"period": item.period,
"appointment_type": item.appointment_type,
"updated_at": datetime.now(ZoneInfo(settings.TIMEZONE)),
}
rows.append(row)
# Prepare data for Supabase API (convert datetime to ISO string)
supabase_row = {
"hn": item.hn,
"txn": item.txn,
"date": _to_iso(_to_tz(datetime.strptime(item.date.strftime("%Y-%m-%d")+' 00:00:00+07:00','%Y-%m-%d %H:%M:%S%z'))),
"time": _to_iso(_to_tz(datetime.strptime(item.date.strftime("%Y-%m-%d")+' '+item.time+'+07:00','%Y-%m-%d %H:%M:%S%z'))),
"doctor_code": item.doctor_code,
"period": item.period,
"appointment_type": item.appointment_type,
"updated_at": datetime.now(ZoneInfo(settings.TIMEZONE)).isoformat(),
}
supabase_rows.append(supabase_row)
# Insert/update to local database
stmt = insert(PatientAppointment).values(rows)
update_cols = {
"txn": stmt.excluded.txn,
"doctor_code": stmt.excluded.doctor_code,
"period": stmt.excluded.period,
"appointment_type": stmt.excluded.appointment_type,
"updated_at": stmt.excluded.updated_at,
}
stmt = stmt.on_conflict_do_update(
index_elements=[PatientAppointment.hn, PatientAppointment.date, PatientAppointment.time],
set_=update_cols,
)
result = db.execute(stmt)
db.commit()
# Send data to Supabase via API call
supabase_result = None
supabase_error = None
try:
logger.info(f"Sending {len(supabase_rows)} patient appointment records to Supabase API")
supabase_result = upsert_to_supabase_sync(
table="patient_appointment",
data=supabase_rows,
on_conflict="hn,date,time",
)
logger.info(f"Successfully sent patient appointment data to Supabase: {supabase_result.get('status_code')}")
except SupabaseAPIError as e:
logger.error(f"Failed to send patient appointment data to Supabase: {str(e)}")
supabase_error = str(e)
except Exception as e:
logger.error(f"Unexpected error sending patient appointment data to Supabase: {str(e)}")
supabase_error = f"Unexpected error: {str(e)}"
return {
"upserted": len(rows),
"rowcount": result.rowcount,
"supabase": {
"success": supabase_result is not None,
"result": supabase_result,
"error": supabase_error,
},
}

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, time, date
from pydantic import BaseModel
@@ -27,3 +27,13 @@ class FeedCheckpointIn(BaseModel):
timestamp_out: datetime | None = None
waiting_time: int | None = None
bu: str | None = None
class PatientAppointmentIn(BaseModel):
hn: str
txn: int | None = None
date: date
time: str
doctor_code: str | None = None
period: str | None = None
appointment_type: str | None = None

View File

@@ -33,5 +33,20 @@ class Settings(BaseSettings):
API_KEY_ENC_SECRET: str | None = None
# Debug settings
DEBUG_AUTH: bool = False # Set to True to enable detailed authentication logging
# Keycloak Authentication (for web pages only)
KEYCLOAK_SERVER_URL: str = "http://keycloak:8080"
KEYCLOAK_REALM: str = "master"
KEYCLOAK_CLIENT_ID: str = "apiservice"
KEYCLOAK_CLIENT_SECRET: str = ""
KEYCLOAK_REDIRECT_URI: str = "http://localhost:8040/apiservice/auth/callback"
# Airflow Integration
AIRFLOW_API_URL: str = "http://airflow-webserver:8080"
AIRFLOW_API_TOKEN: str = ""
AIRFLOW_DAG_ID_FINANCE: str = "process_finance_excel"
settings = Settings()

View File

@@ -1,12 +1,54 @@
from sqlalchemy import text
from sqlalchemy.orm import Session
import logging
from app.db.base import Base
from app.db.engine import engine
from app.models.user import User, Role # Import models to ensure they're registered
logger = logging.getLogger(__name__)
def init_db() -> None:
"""Initialize database schemas and tables"""
with engine.begin() as conn:
# Create schemas
conn.execute(text("CREATE SCHEMA IF NOT EXISTS fastapi"))
conn.execute(text("CREATE SCHEMA IF NOT EXISTS operationbi"))
conn.execute(text("CREATE SCHEMA IF NOT EXISTS rawdata"))
# Create all tables
Base.metadata.create_all(bind=conn)
logger.info("Database schemas and tables created")
# Seed default roles
seed_roles()
def seed_roles() -> None:
"""Seed default roles if they don't exist"""
from app.db.session import SessionLocal
db = SessionLocal()
try:
roles_data = [
{"name": "admin", "description": "Full system access - can manage users and access all features"},
{"name": "operation", "description": "Data management access - can upload and manage data"}
]
for role_data in roles_data:
existing = db.query(Role).filter(Role.name == role_data["name"]).first()
if not existing:
role = Role(**role_data)
db.add(role)
logger.info(f"Created role: {role_data['name']}")
else:
logger.info(f"Role already exists: {role_data['name']}")
db.commit()
logger.info("Role seeding completed")
except Exception as e:
logger.error(f"Error seeding roles: {e}")
db.rollback()
finally:
db.close()

View File

@@ -46,6 +46,25 @@ class RawWaitingTime(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class PatientAppointment(Base):
__tablename__ = "patient_appointment"
__table_args__ = (
UniqueConstraint("hn", "date", "time", name="uq_patient_appointment_hn_date_time"),
{"schema": "rawdata"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
hn: Mapped[str] = mapped_column(String(50), nullable=False)
txn: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
doctor_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
period: Mapped[str | None] = mapped_column(String(50), nullable=True)
appointment_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class ApiClient(Base):
__tablename__ = "api_client"
__table_args__ = {"schema": "fastapi"}

View File

@@ -0,0 +1,27 @@
"""
Database session management
"""
from sqlalchemy.orm import sessionmaker, Session
from app.db.engine import engine
# Create SessionLocal class
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db() -> Session:
"""
Dependency to get database session
Usage:
@app.get("/items")
def read_items(db: Session = Depends(get_db)):
...
Yields:
Database session
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -12,6 +12,10 @@ import sqladmin
from app.admin import mount_admin
from app.api.v1.routes import router as v1_router
from app.routes.pages import router as pages_router
from app.routes.auth import router as auth_router
from app.routes.admin_users import router as admin_users_router
from app.middleware.auth_middleware import WebAuthenticationMiddleware
from app.core.config import settings
from app.db.init_db import init_db
@@ -80,8 +84,9 @@ async def global_exception_handler(request, exc):
status_code=500,
content={"detail": "Internal server error", "error": str(exc)}
)
# Middleware order is important! They execute in reverse order (LIFO)
# WebAuthenticationMiddleware needs SessionMiddleware, so SessionMiddleware must be added AFTER
app.add_middleware(ForceHTTPSMiddleware)
app.add_middleware(SessionMiddleware, secret_key=settings.ADMIN_SECRET_KEY)
app.add_middleware(ForwardedProtoMiddleware)
app.add_middleware(
CORSMiddleware,
@@ -90,7 +95,17 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(v1_router)
# Add web authentication middleware (protects /, /docs, /data-management/* only)
# API endpoints (/api/v1/*) continue to use API Key authentication
app.add_middleware(WebAuthenticationMiddleware)
# SessionMiddleware must be added AFTER middlewares that use it (due to LIFO execution)
app.add_middleware(SessionMiddleware, secret_key=settings.ADMIN_SECRET_KEY)
app.include_router(v1_router) # API endpoints - use API Key auth
app.include_router(pages_router) # Web pages - use Keycloak auth
app.include_router(auth_router) # Authentication routes
app.include_router(admin_users_router) # Admin user management API
app.mount("/admin/statics", StaticFiles(directory=statics_path), name="admin_statics")
app.mount("/apiservice/admin/statics", StaticFiles(directory=statics_path), name="proxy_admin_statics")
mount_admin(app)

View File

@@ -0,0 +1 @@
# Middleware package

View File

@@ -0,0 +1,169 @@
"""
Authentication middleware for web pages
Protects web UI routes while allowing API endpoints to use API Key auth
"""
from fastapi import Request
from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
class WebAuthenticationMiddleware(BaseHTTPMiddleware):
"""
Middleware to enforce Keycloak authentication for web pages
Protected routes (require user login):
- / (landing page)
- /docs (API documentation)
- /data-management/* (data management pages)
Excluded routes (no auth required or use different auth):
- /auth/* (authentication endpoints)
- /api/v1/* (API endpoints - use API Key auth)
- /admin/* (SQLAdmin - has its own auth)
- /apiservice/admin/statics/* (admin static files)
- /admin/statics/* (admin static files)
"""
# Routes that require user authentication
PROTECTED_PATHS = [
"/",
"/docs",
"/redoc",
"/openapi.json",
"/data-management"
]
# Routes that are excluded from user authentication
EXCLUDED_PATHS = [
"/auth", # Authentication endpoints
"/api/v1", # API endpoints (use API Key)
"/admin", # SQLAdmin (has own auth)
]
async def dispatch(self, request: Request, call_next):
"""Process request and check authentication if needed"""
# Get the path without root_path prefix
path = request.url.path
# Remove root_path prefix if it exists
if settings.ROOT_PATH and path.startswith(settings.ROOT_PATH):
path = path[len(settings.ROOT_PATH):]
# Ensure path starts with /
if not path.startswith("/"):
path = "/" + path
# Check if path is excluded from authentication
is_excluded = any(path.startswith(excluded) for excluded in self.EXCLUDED_PATHS)
if is_excluded:
# Skip authentication for excluded paths (including /api/v1/*)
return await call_next(request)
# Check if path requires authentication
requires_auth = any(
path == protected or path.startswith(protected + "/")
for protected in self.PROTECTED_PATHS
)
if requires_auth:
# Check if user is authenticated
user = request.session.get("user")
if not user:
# User not authenticated - redirect to login
# Preserve the original path for redirect after login
original_path = request.url.path
# Build login URL with redirect
login_url = f"{settings.ROOT_PATH}/auth/login?redirect_to={original_path}"
logger.info(f"Unauthenticated access to {original_path}, redirecting to login")
return RedirectResponse(url=login_url, status_code=302)
# Role-based access control for specific paths
user_roles = user.get("roles", [])
# /data-management/* requires 'operation' or 'admin' role
if path.startswith("/data-management"):
if not any(role in user_roles for role in ["admin", "operation"]):
logger.warning(
f"Access denied: User {user.get('username')} "
f"(roles: {user_roles}) tried to access {path}"
)
# Return 403 Forbidden page
from fastapi.responses import HTMLResponse
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html>
<head>
<title>Access Denied</title>
<style>
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}}
.container {{
background: white;
padding: 60px;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
max-width: 500px;
}}
h1 {{ color: #ff6b6b; font-size: 48px; margin-bottom: 20px; }}
p {{ color: #666; font-size: 18px; margin: 15px 0; }}
.role-info {{
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
margin: 20px 0;
}}
.required {{ color: #ff6b6b; font-weight: bold; }}
.your-roles {{ color: #667eea; font-weight: bold; }}
a {{
display: inline-block;
margin-top: 30px;
padding: 12px 30px;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s;
}}
a:hover {{ background: #5568d3; transform: translateY(-2px); }}
</style>
</head>
<body>
<div class="container">
<h1>🚫</h1>
<h2>Access Denied</h2>
<p>You don't have permission to access this page.</p>
<div class="role-info">
<p>Required role: <span class="required">operation</span> or <span class="required">admin</span></p>
<p>Your roles: <span class="your-roles">{', '.join(user_roles) if user_roles else 'None'}</span></p>
</div>
<p>Please contact your administrator if you need access.</p>
<a href="{settings.ROOT_PATH}/">← Go to Home</a>
</div>
</body>
</html>
""",
status_code=403
)
# Continue with request
return await call_next(request)

View File

@@ -0,0 +1,7 @@
"""
Database models package
"""
from app.models.user import User, Role
from app.models.upload import UploadHistory
__all__ = ["User", "Role", "UploadHistory"]

View File

@@ -0,0 +1,33 @@
"""
Upload History model for tracking file uploads
"""
from sqlalchemy import Column, Integer, String, DateTime, Text
from sqlalchemy.sql import func
from app.db.base import Base
class UploadHistory(Base):
"""
Upload history tracking
Stores information about uploaded files and their processing status
"""
__tablename__ = "upload_history"
__table_args__ = {'schema': 'fastapi'}
id = Column(Integer, primary_key=True, index=True)
upload_id = Column(String, unique=True, index=True, nullable=False)
filename = Column(String, nullable=False)
filepath = Column(String, nullable=False)
description = Column(Text)
status = Column(String, default="pending")
job_id = Column(String)
logs = Column(Text)
uploaded_by = Column(String)
uploaded_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
airflow_dag_run_id = Column(String)
airflow_state = Column(String)
processing_started_at = Column(DateTime(timezone=True))
processing_completed_at = Column(DateTime(timezone=True))
error_message = Column(Text)

View File

@@ -0,0 +1,55 @@
"""
User and Role models for local user management
Note: This is separate from Keycloak users - used for tracking and audit
"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Table, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base import Base
# Association table for many-to-many relationship
user_roles = Table(
'user_roles',
Base.metadata,
Column('user_id', Integer, ForeignKey('fastapi.users.id'), primary_key=True),
Column('role_id', Integer, ForeignKey('fastapi.roles.id'), primary_key=True),
schema='fastapi'
)
class User(Base):
"""
Local user record (synced from Keycloak)
Used for tracking, audit, and local permissions
"""
__tablename__ = "users"
__table_args__ = {'schema': 'fastapi'}
id = Column(Integer, primary_key=True, index=True)
keycloak_id = Column(String, unique=True, index=True, nullable=False) # Keycloak sub
username = Column(String, unique=True, index=True, nullable=False)
email = Column(String, unique=True, index=True)
full_name = Column(String)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
last_login = Column(DateTime(timezone=True))
# Relationships
roles = relationship("Role", secondary=user_roles, back_populates="users")
class Role(Base):
"""
Roles (synced from Keycloak)
"""
__tablename__ = "roles"
__table_args__ = {'schema': 'fastapi'}
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False, index=True) # admin, operation
description = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
users = relationship("User", secondary=user_roles, back_populates="roles")

View File

@@ -0,0 +1 @@
# Routes package

View File

@@ -0,0 +1,156 @@
"""
User and Role management endpoints (Admin only)
Note: This manages local user records synced from Keycloak
API endpoints (/api/v1/*) are NOT affected and continue using API Key authentication
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime
from app.db.session import get_db
from app.models.user import User, Role
from app.security.permissions import require_role, Roles
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
# Pydantic schemas
class RoleSchema(BaseModel):
id: int
name: str
description: str | None = None
class Config:
from_attributes = True
class UserSchema(BaseModel):
id: int
keycloak_id: str
username: str
email: str | None = None
full_name: str | None = None
is_active: bool
roles: List[RoleSchema] = []
last_login: datetime | None = None
created_at: datetime
class Config:
from_attributes = True
class UserCreateSchema(BaseModel):
keycloak_id: str
username: str
email: str | None = None
full_name: str | None = None
class UserUpdateSchema(BaseModel):
email: str | None = None
full_name: str | None = None
is_active: bool | None = None
role_ids: List[int] | None = None
@router.get("/", response_model=List[UserSchema])
async def list_users(
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""List all users (Admin only)"""
users = db.query(User).all()
return users
@router.get("/{user_id}", response_model=UserSchema)
async def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""Get user by ID (Admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.post("/", response_model=UserSchema)
async def create_user(
user_data: UserCreateSchema,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""Create new user record (Admin only)"""
# Check if user already exists
existing = db.query(User).filter(User.keycloak_id == user_data.keycloak_id).first()
if existing:
raise HTTPException(status_code=400, detail="User already exists")
user = User(**user_data.dict())
db.add(user)
db.commit()
db.refresh(user)
return user
@router.put("/{user_id}", response_model=UserSchema)
async def update_user(
user_id: int,
user_data: UserUpdateSchema,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""Update user (Admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Update fields
if user_data.email is not None:
user.email = user_data.email
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.is_active is not None:
user.is_active = user_data.is_active
# Update roles
if user_data.role_ids is not None:
roles = db.query(Role).filter(Role.id.in_(user_data.role_ids)).all()
user.roles = roles
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""Delete user (Admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()
return {"message": "User deleted successfully"}
@router.get("/roles/", response_model=List[RoleSchema])
async def list_roles(
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""List all roles (Admin only)"""
roles = db.query(Role).all()
return roles

View File

@@ -0,0 +1,252 @@
"""
Authentication routes for Keycloak web login
Note: These routes are ONLY for web UI authentication
API endpoints use API Key authentication separately
"""
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import RedirectResponse
from app.security.keycloak_auth import (
get_keycloak_client,
get_login_url,
get_logout_url,
get_current_user
)
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["authentication"])
@router.get("/login")
async def login(request: Request, redirect_to: str = Query(default="/")):
"""
Redirect to Keycloak login page
Args:
redirect_to: Path to redirect after successful login
"""
# Check if already logged in
user = get_current_user(request)
if user:
return RedirectResponse(url=redirect_to)
# Generate Keycloak login URL
login_url = get_login_url(redirect_to)
return RedirectResponse(url=login_url)
@router.get("/callback")
async def auth_callback(
request: Request,
code: str = Query(...),
state: str = Query(default="/")
):
"""
Handle Keycloak callback after login
Args:
code: Authorization code from Keycloak
state: Original redirect path
"""
try:
if settings.DEBUG_AUTH:
logger.info("=" * 80)
logger.info("AUTHENTICATION CALLBACK RECEIVED")
logger.info(f"Authorization code: {code[:20]}...{code[-20:] if len(code) > 40 else code}")
logger.info(f"State (redirect_to): {state}")
logger.info(f"Request URL: {request.url}")
logger.info(f"Request headers: {dict(request.headers)}")
keycloak_client = get_keycloak_client()
if settings.DEBUG_AUTH:
logger.info("-" * 80)
logger.info("EXCHANGING CODE FOR TOKENS")
logger.info(f"Grant type: authorization_code")
logger.info(f"Code: {code[:20]}...")
logger.info(f"Redirect URI: {settings.KEYCLOAK_REDIRECT_URI}")
# Exchange authorization code for tokens
token_response = keycloak_client.token(
grant_type="authorization_code",
code=code,
redirect_uri=settings.KEYCLOAK_REDIRECT_URI
)
if settings.DEBUG_AUTH:
logger.info("TOKEN RESPONSE RECEIVED")
logger.info(f"Access token: {'*' * 20}...{token_response.get('access_token', '')[-20:] if token_response.get('access_token') else 'NONE'}")
logger.info(f"Refresh token: {'Present' if token_response.get('refresh_token') else 'None'}")
logger.info(f"Token type: {token_response.get('token_type', 'N/A')}")
logger.info(f"Expires in: {token_response.get('expires_in', 'N/A')} seconds")
# Get user information from token
access_token = token_response.get("access_token")
if not access_token:
logger.error("No access token in response!")
if settings.DEBUG_AUTH:
logger.error(f"Full token response: {token_response}")
raise HTTPException(
status_code=400,
detail="No access token received from Keycloak"
)
if settings.DEBUG_AUTH:
logger.info("-" * 80)
logger.info("FETCHING USER INFO")
from jose import jwt
userinfo = jwt.decode(access_token, key="", options={"verify_signature": False, "verify_aud": False})
logger.info(f"Decoded access_token: {userinfo.get('preferred_username')}")
# # 2. ดึง id_token ออกมา (ตัวนี้คือหัวใจของ OIDC)
# id_token = token_response.get("id_token")
# if not id_token:
# logger.error("No id_token in response!")
# if settings.DEBUG_AUTH:
# logger.error(f"Full token response: {token_response}")
# raise HTTPException(
# status_code=400,
# detail="No id_token received from Keycloak"
# )
# # 3. Decode id_token เพื่อเอาข้อมูล User (ไม่ต้องใช้ Key เพราะเราเชื่อถือ Connection นี้)
# userinfo = jwt.decode(
# id_token,
# key="",
# options={"verify_signature": False, "verify_aud": False}
# )
#userinfo = keycloak_client.userinfo(access_token)
# Extract roles from token
roles = []
# 1. Realm roles (roles ระดับ realm)
if "realm_access" in userinfo and "roles" in userinfo["realm_access"]:
roles.extend(userinfo["realm_access"]["roles"])
# 2. Client roles (roles เฉพาะ client apiservice)
if "resource_access" in userinfo and settings.KEYCLOAK_CLIENT_ID in userinfo["resource_access"]:
client_roles = userinfo["resource_access"][settings.KEYCLOAK_CLIENT_ID].get("roles", [])
roles.extend(client_roles)
# Filter to only include our application roles
user_roles = [r for r in roles if r in ["admin", "operation"]]
if settings.DEBUG_AUTH:
logger.info("USER INFO RECEIVED")
logger.info(f"Username: {userinfo.get('preferred_username')}")
logger.info(f"Email: {userinfo.get('email')}")
logger.info(f"Name: {userinfo.get('name')}")
logger.info(f"Sub (User ID): {userinfo.get('sub')}")
logger.info(f"All roles from token: {roles}")
logger.info(f"Filtered user roles: {user_roles}")
logger.info(f"Full userinfo keys: {list(userinfo.keys())}")
# Store user info, roles, and tokens in session
user_session_data = {
"username": userinfo.get("preferred_username"),
"email": userinfo.get("email"),
"name": userinfo.get("name", userinfo.get("preferred_username")),
"sub": userinfo.get("sub"), # User ID
"roles": user_roles, # User roles
"access_token": access_token,
"refresh_token": token_response.get("refresh_token")
}
request.session["user"] = user_session_data
if settings.DEBUG_AUTH:
logger.info("-" * 80)
logger.info("SESSION UPDATED")
logger.info(f"Session user data: {dict((k, v) for k, v in user_session_data.items() if k not in ['access_token', 'refresh_token'])}")
logger.info(f"User {userinfo.get('preferred_username')} logged in successfully")
# Redirect to original destination
redirect_url = state if state else "/"
# Ensure redirect URL starts with root_path if set
if settings.ROOT_PATH and not redirect_url.startswith(settings.ROOT_PATH):
redirect_url = f"{settings.ROOT_PATH}{redirect_url}"
if settings.DEBUG_AUTH:
logger.info(f"Redirecting to: {redirect_url}")
logger.info("=" * 80)
return RedirectResponse(url=redirect_url, status_code=302)
except Exception as e:
logger.error(f"Authentication callback failed: {e}")
if settings.DEBUG_AUTH:
import traceback
logger.error("FULL TRACEBACK:")
logger.error(traceback.format_exc())
logger.error("=" * 80)
raise HTTPException(
status_code=400,
detail=f"Authentication failed: {str(e)}"
)
@router.get("/logout")
async def logout(request: Request):
"""
Logout user and clear session
Redirects to Keycloak logout page
"""
user = get_current_user(request)
# Clear session
request.session.clear()
if user:
logger.info(f"User {user.get('username')} logged out")
# Get Keycloak logout URL
redirect_uri = f"{settings.ROOT_PATH}/" if settings.ROOT_PATH else "/"
logout_url = get_logout_url(redirect_uri)
return RedirectResponse(url=logout_url)
@router.get("/user")
async def get_user_info(request: Request):
"""
Get current authenticated user information
Returns 401 if not authenticated
"""
user = get_current_user(request)
if not user:
raise HTTPException(
status_code=401,
detail="Not authenticated"
)
# Return user info without sensitive tokens
return {
"username": user.get("username"),
"email": user.get("email"),
"name": user.get("name"),
"sub": user.get("sub")
}
@router.get("/status")
async def auth_status(request: Request):
"""
Check authentication status
Returns whether user is logged in
"""
user = get_current_user(request)
return {
"authenticated": user is not None,
"user": {
"username": user.get("username"),
"name": user.get("name")
} if user else None
}

View File

@@ -0,0 +1,305 @@
"""
Web page routes for the application
"""
import os
from datetime import datetime
from pathlib import Path
from typing import List
from fastapi import APIRouter, Request, UploadFile, File, Form, HTTPException, Depends
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy.orm import Session
import asyncio
import logging
from app.core.config import settings
from app.security.permissions import require_role, Roles
from app.db.session import get_db
from app.models.upload import UploadHistory
from app.services.airflow_client import airflow_client
logger = logging.getLogger(__name__)
router = APIRouter()
# Setup templates
templates_dir = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
# Upload directory
UPLOAD_DIR = Path("/data/uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
class UploadRecordSchema(BaseModel):
id: int
upload_id: str
filename: str
filepath: str
description: str | None = None
status: str
job_id: str | None = None
logs: str | None = None
uploaded_by: str | None = None
uploaded_at: datetime
updated_at: datetime | None = None
class Config:
from_attributes = True
@router.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""Landing page with navigation menu"""
user = request.session.get("user")
return templates.TemplateResponse(
"index.html",
{
"request": request,
"root_path": settings.ROOT_PATH,
"user": user
}
)
@router.get("/data-management/finance", response_class=HTMLResponse)
async def finance_page(request: Request):
"""Finance Excel upload page - requires operation or admin role"""
user = request.session.get("user")
return templates.TemplateResponse(
"data_management_finance.html",
{
"request": request,
"root_path": settings.ROOT_PATH,
"user": user
}
)
@router.get("/admin/users", response_class=HTMLResponse)
async def admin_users_page(
request: Request,
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""User management page - Admin only"""
return templates.TemplateResponse(
"admin_users.html",
{
"request": request,
"root_path": settings.ROOT_PATH,
"user": current_user
}
)
@router.post("/data-management/finance/upload")
async def upload_finance_file(
request: Request,
file: UploadFile = File(...),
description: str = Form(None),
db: Session = Depends(get_db)
):
"""
Handle finance Excel file upload
- Saves file to /data/uploads/
- Stores upload record in database
- Triggers Airflow job (to be implemented)
- Returns upload record
"""
# Validate file type
if not file.filename.endswith(('.xlsx', '.xls')):
raise HTTPException(
status_code=400,
detail="Invalid file type. Only .xlsx and .xls files are allowed."
)
# Generate unique filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_filename = file.filename.replace(" ", "_")
unique_filename = f"{timestamp}_{safe_filename}"
filepath = UPLOAD_DIR / unique_filename
# Save file
try:
content = await file.read()
with open(filepath, "wb") as f:
f.write(content)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to save file: {str(e)}"
)
# Get username from session
user = request.session.get("user")
username = user.get("username") if user else "anonymous"
# Create upload record in database
upload_id = f"upload_{timestamp}"
upload_record = UploadHistory(
upload_id=upload_id,
filename=file.filename,
filepath=str(filepath),
description=description,
status="pending",
uploaded_by=username
)
db.add(upload_record)
db.commit()
db.refresh(upload_record)
# Trigger Airflow DAG with retry logic
airflow_triggered = False
dag_run_id = None
error_msg = None
max_retries = 3
retry_delay = 10 # seconds
for attempt in range(max_retries):
try:
logger.info(f"Triggering Airflow DAG (attempt {attempt + 1}/{max_retries})")
result = await airflow_client.trigger_finance_dag(
upload_id=upload_id,
filepath=str(filepath),
filename=file.filename,
uploaded_by=username,
description=description
)
dag_run_id = result.get("dag_run_id")
airflow_triggered = True
# Update upload record with Airflow info
upload_record.airflow_dag_run_id = dag_run_id
upload_record.airflow_state = result.get("state", "queued")
upload_record.status = "processing"
db.commit()
logger.info(f"Airflow DAG triggered successfully: {dag_run_id}")
break
except Exception as e:
error_msg = str(e)
logger.error(f"Failed to trigger Airflow (attempt {attempt + 1}/{max_retries}): {error_msg}")
if attempt < max_retries - 1:
logger.info(f"Retrying in {retry_delay} seconds...")
await asyncio.sleep(retry_delay)
else:
logger.error(f"All {max_retries} attempts failed to trigger Airflow")
upload_record.status = "error"
upload_record.error_message = f"Failed to trigger Airflow after {max_retries} attempts: {error_msg}"
db.commit()
return {
"success": True,
"message": f"File '{file.filename}' uploaded successfully",
"upload_id": upload_id,
"filename": unique_filename,
"airflow_triggered": airflow_triggered,
"dag_run_id": dag_run_id,
"error": error_msg if not airflow_triggered else None
}
@router.get("/data-management/finance/uploads")
async def get_uploads(db: Session = Depends(get_db)):
"""Get list of all uploads with their status"""
uploads = db.query(UploadHistory).order_by(UploadHistory.uploaded_at.desc()).all()
# Convert to dict for JSON response
return [
{
"id": upload.upload_id,
"filename": upload.filename,
"filepath": upload.filepath,
"uploaded_at": upload.uploaded_at.isoformat(),
"description": upload.description,
"status": upload.status,
"job_id": upload.job_id,
"logs": upload.logs,
"uploaded_by": upload.uploaded_by,
"airflow_dag_run_id": upload.airflow_dag_run_id,
"airflow_state": upload.airflow_state,
"processing_started_at": upload.processing_started_at.isoformat() if upload.processing_started_at else None,
"processing_completed_at": upload.processing_completed_at.isoformat() if upload.processing_completed_at else None,
"error_message": upload.error_message
}
for upload in uploads
]
@router.get("/data-management/finance/uploads/{upload_id}")
async def get_upload_status(upload_id: str, db: Session = Depends(get_db)):
"""Get status of a specific upload"""
upload = db.query(UploadHistory).filter(UploadHistory.upload_id == upload_id).first()
if not upload:
raise HTTPException(status_code=404, detail="Upload not found")
return {
"id": upload.upload_id,
"filename": upload.filename,
"filepath": upload.filepath,
"uploaded_at": upload.uploaded_at.isoformat(),
"description": upload.description,
"status": upload.status,
"job_id": upload.job_id,
"logs": upload.logs,
"uploaded_by": upload.uploaded_by,
"airflow_dag_run_id": upload.airflow_dag_run_id,
"airflow_state": upload.airflow_state,
"processing_started_at": upload.processing_started_at.isoformat() if upload.processing_started_at else None,
"processing_completed_at": upload.processing_completed_at.isoformat() if upload.processing_completed_at else None,
"error_message": upload.error_message
}
# Placeholder for Airflow integration
async def trigger_airflow_job(filepath: str, upload_id: str) -> str:
"""
Trigger Airflow DAG to process the uploaded file
Args:
filepath: Path to the uploaded file
upload_id: Unique upload identifier
Returns:
job_id: Airflow job/run ID
This function will be implemented when:
- Airflow DAG ID is provided
- Airflow API endpoint is configured
"""
# TODO: Implement Airflow API call
# Example implementation:
# import httpx
#
# airflow_url = "http://airflow-webserver:8080/api/v1/dags/{dag_id}/dagRuns"
# headers = {"Content-Type": "application/json"}
# auth = ("airflow", "airflow") # Use proper credentials
#
# payload = {
# "conf": {
# "filepath": filepath,
# "upload_id": upload_id
# }
# }
#
# async with httpx.AsyncClient() as client:
# response = await client.post(
# airflow_url,
# json=payload,
# headers=headers,
# auth=auth
# )
# response.raise_for_status()
# result = response.json()
# return result["dag_run_id"]
raise NotImplementedError("Airflow integration pending DAG ID and endpoint")

View File

@@ -0,0 +1,146 @@
"""
Keycloak authentication for web pages
Note: This is ONLY for web UI authentication, NOT for API endpoints
API endpoints use API Key authentication (see app/security/dependencies.py)
"""
from typing import Optional
from fastapi import HTTPException, Request, status
from keycloak import KeycloakOpenID
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
# Initialize Keycloak OpenID client
def get_keycloak_client() -> KeycloakOpenID:
"""Get Keycloak OpenID client instance"""
try:
if settings.DEBUG_AUTH:
logger.info("=" * 80)
logger.info("KEYCLOAK CLIENT INITIALIZATION")
logger.info(f"Server URL: {settings.KEYCLOAK_SERVER_URL}")
logger.info(f"Realm: {settings.KEYCLOAK_REALM}")
logger.info(f"Client ID: {settings.KEYCLOAK_CLIENT_ID}")
logger.info(f"Client Secret: {'*' * len(settings.KEYCLOAK_CLIENT_SECRET) if settings.KEYCLOAK_CLIENT_SECRET else 'NOT SET'}")
logger.info(f"Redirect URI: {settings.KEYCLOAK_REDIRECT_URI}")
logger.info("=" * 80)
return KeycloakOpenID(
server_url=settings.KEYCLOAK_SERVER_URL,
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=settings.KEYCLOAK_REALM,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
verify=True
)
except Exception as e:
logger.error(f"Failed to initialize Keycloak client: {e}")
if settings.DEBUG_AUTH:
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
raise
def get_current_user(request: Request) -> Optional[dict]:
"""
Get current authenticated user from session
Returns None if not authenticated
"""
return request.session.get("user")
def require_user(request: Request) -> dict:
"""
Dependency to require authenticated user
Raises 401 if not authenticated
"""
user = get_current_user(request)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
return user
def get_login_url(redirect_to: str = "/") -> str:
"""
Generate Keycloak login URL
Args:
redirect_to: Path to redirect after successful login
Returns:
Keycloak authorization URL
"""
try:
if settings.DEBUG_AUTH:
logger.info("=" * 80)
logger.info("GENERATING LOGIN URL")
logger.info(f"Redirect to after login: {redirect_to}")
logger.info(f"Keycloak redirect URI: {settings.KEYCLOAK_REDIRECT_URI}")
keycloak_client = get_keycloak_client()
auth_url = keycloak_client.auth_url(
redirect_uri=settings.KEYCLOAK_REDIRECT_URI,
state=redirect_to
)
if settings.DEBUG_AUTH:
logger.info(f"Generated auth URL: {auth_url}")
logger.info("=" * 80)
return auth_url
except Exception as e:
logger.error(f"Failed to generate login URL: {e}")
if settings.DEBUG_AUTH:
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Authentication service unavailable"
)
async def verify_token(token: str) -> dict:
"""
Verify and decode Keycloak access token
Args:
token: Access token from Keycloak
Returns:
User information from token
"""
try:
keycloak_client = get_keycloak_client()
userinfo = keycloak_client.userinfo(token)
return userinfo
except Exception as e:
logger.error(f"Token verification failed: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
def get_logout_url(redirect_uri: str = None) -> str:
"""
Generate Keycloak logout URL
Args:
redirect_uri: Where to redirect after logout
Returns:
Keycloak logout URL
"""
try:
keycloak_client = get_keycloak_client()
if redirect_uri is None:
redirect_uri = f"{settings.ROOT_PATH}/" if settings.ROOT_PATH else "/"
logout_url = keycloak_client.logout_url(redirect_uri=redirect_uri)
return logout_url
except Exception as e:
logger.error(f"Failed to generate logout URL: {e}")
# Return simple redirect if Keycloak logout fails
return redirect_uri or "/"

View File

@@ -0,0 +1,151 @@
"""
Role-based permission system for web pages
Note: API endpoints (/api/v1/*) use API Key authentication and are not affected by this
"""
from typing import List
from fastapi import HTTPException, Request, status
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
class Roles:
"""Role constants"""
ADMIN = "admin"
OPERATION = "operation"
def get_user_roles(request: Request) -> List[str]:
"""
Get roles from session
Args:
request: FastAPI request object
Returns:
List of role names
"""
user = request.session.get("user")
if not user:
return []
return user.get("roles", [])
def has_role(request: Request, required_role: str) -> bool:
"""
Check if user has specific role
Args:
request: FastAPI request object
required_role: Role name to check
Returns:
True if user has the role, False otherwise
"""
user_roles = get_user_roles(request)
return required_role in user_roles
def has_any_role(request: Request, required_roles: List[str]) -> bool:
"""
Check if user has any of the required roles
Args:
request: FastAPI request object
required_roles: List of role names
Returns:
True if user has at least one of the roles, False otherwise
"""
user_roles = get_user_roles(request)
return any(role in user_roles for role in required_roles)
def require_role(required_role: str):
"""
Dependency to require specific role
Args:
required_role: Role name required to access the endpoint
Returns:
Dependency function that checks for the role
Raises:
HTTPException: 401 if not authenticated, 403 if missing required role
"""
def check_role(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
user_roles = user.get("roles", [])
if required_role not in user_roles:
logger.warning(
f"Access denied: User {user.get('username')} "
f"(roles: {user_roles}) tried to access resource requiring '{required_role}'"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required role: {required_role}"
)
return user
return check_role
def require_any_role(required_roles: List[str]):
"""
Dependency to require any of the specified roles
Args:
required_roles: List of role names, user needs at least one
Returns:
Dependency function that checks for roles
Raises:
HTTPException: 401 if not authenticated, 403 if missing all required roles
"""
def check_roles(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
user_roles = user.get("roles", [])
if not any(role in user_roles for role in required_roles):
logger.warning(
f"Access denied: User {user.get('username')} "
f"(roles: {user_roles}) tried to access resource requiring one of: {required_roles}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required roles: {', '.join(required_roles)}"
)
return user
return check_roles
def is_admin(request: Request) -> bool:
"""
Check if user is admin
Args:
request: FastAPI request object
Returns:
True if user has admin role, False otherwise
"""
return has_role(request, Roles.ADMIN)

View File

@@ -0,0 +1,152 @@
"""
Airflow API Client for triggering DAGs
"""
import httpx
import logging
from typing import Dict, Any, Optional
from datetime import datetime
from app.core.config import settings
logger = logging.getLogger(__name__)
class AirflowClient:
"""Client for interacting with Airflow REST API"""
def __init__(self):
self.api_url = settings.AIRFLOW_API_URL.rstrip('/')
self.api_token = settings.AIRFLOW_API_TOKEN
self.headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
if self.api_token:
self.headers["Authorization"] = f"Bearer {self.api_token}"
else:
logger.warning("AIRFLOW_API_TOKEN not set - API calls may fail")
async def trigger_dag(
self,
dag_id: str,
conf: Dict[str, Any],
logical_date: Optional[str] = None
) -> Dict[str, Any]:
"""
Trigger an Airflow DAG run
Args:
dag_id: DAG identifier
conf: Configuration to pass to the DAG (JSON serializable dict)
logical_date: Optional logical date for the DAG run (ISO format)
Returns:
Dict containing dag_run_id and other metadata
Raises:
httpx.HTTPError: If the API call fails
"""
url = f"{self.api_url}/api/v1/dags/{dag_id}/dagRuns"
payload = {
"conf": conf
}
if logical_date:
payload["logical_date"] = logical_date
else:
payload["logical_date"] = datetime.utcnow().isoformat() + "Z"
logger.info(f"Triggering Airflow DAG: {dag_id}")
logger.debug(f"Payload: {payload}")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
url,
json=payload,
headers=self.headers
)
response.raise_for_status()
result = response.json()
logger.info(f"DAG triggered successfully: {result.get('dag_run_id')}")
return result
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error triggering DAG {dag_id}: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Request error triggering DAG {dag_id}: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error triggering DAG {dag_id}: {str(e)}")
raise
async def get_dag_run_status(
self,
dag_id: str,
dag_run_id: str
) -> Dict[str, Any]:
"""
Get the status of a DAG run
Args:
dag_id: DAG identifier
dag_run_id: DAG run identifier
Returns:
Dict containing DAG run status and metadata
"""
url = f"{self.api_url}/api/v1/dags/{dag_id}/dagRuns/{dag_run_id}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=self.headers)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"Error getting DAG run status: {str(e)}")
raise
async def trigger_finance_dag(
self,
upload_id: str,
filepath: str,
filename: str,
uploaded_by: str,
description: Optional[str] = None
) -> Dict[str, Any]:
"""
Trigger the finance Excel processing DAG
Args:
upload_id: Unique upload identifier
filepath: Full path to the uploaded file
filename: Original filename
uploaded_by: Username who uploaded the file
description: Optional description
Returns:
Dict containing dag_run_id and other metadata
"""
conf = {
"upload_id": upload_id,
"filepath": filepath,
"filename": filename,
"uploaded_by": uploaded_by,
"description": description or "",
"triggered_at": datetime.utcnow().isoformat()
}
return await self.trigger_dag(
dag_id=settings.AIRFLOW_DAG_ID_FINANCE,
conf=conf
)
# Singleton instance
airflow_client = AirflowClient()

View File

@@ -0,0 +1,360 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Management - Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
h1 { color: #333; font-size: 32px; }
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.role-badge {
padding: 5px 15px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.role-admin {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
}
.role-operation {
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
color: white;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #667eea;
text-decoration: none;
font-weight: 600;
font-size: 16px;
}
.back-link:hover { text-decoration: underline; }
.alert {
padding: 15px 20px;
margin-bottom: 20px;
border-radius: 8px;
font-weight: 500;
}
.alert-success {
background: #51cf66;
color: white;
}
.alert-error {
background: #ff6b6b;
color: white;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
text-align: center;
}
.stat-number {
font-size: 36px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
background: white;
}
th, td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
position: sticky;
top: 0;
}
tr:hover { background: #f8f9fa; }
.status-active {
color: #51cf66;
font-weight: 600;
}
.status-inactive {
color: #ff6b6b;
font-weight: 600;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
font-weight: 500;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
}
.btn-danger {
background: #ff6b6b;
color: white;
}
.btn-danger:hover {
background: #ee5a52;
transform: translateY(-2px);
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state {
text-align: center;
padding: 60px;
color: #999;
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<a href="{{ root_path }}/" class="back-link">← Back to Dashboard</a>
<div class="header">
<h1>👥 User Management</h1>
{% if user %}
<div class="user-info">
<span>{{ user.name or user.username }}</span>
{% if user.roles %}
{% for role in user.roles %}
<span class="role-badge role-{{ role }}">{{ role }}</span>
{% endfor %}
{% endif %}
</div>
{% endif %}
</div>
<div id="alertContainer"></div>
<div class="stats" id="statsContainer">
<div class="stat-card">
<div class="stat-number" id="totalUsers">-</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-number" id="activeUsers">-</div>
<div class="stat-label">Active Users</div>
</div>
<div class="stat-card">
<div class="stat-number" id="adminUsers">-</div>
<div class="stat-label">Admins</div>
</div>
<div class="stat-card">
<div class="stat-number" id="operationUsers">-</div>
<div class="stat-label">Operations</div>
</div>
</div>
<div id="tableContainer">
<div class="loading">Loading users...</div>
</div>
</div>
<script>
const rootPath = "{{ root_path }}";
async function loadUsers() {
try {
const response = await fetch(`${rootPath}/admin/users/`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const users = await response.json();
// Update stats
document.getElementById('totalUsers').textContent = users.length;
document.getElementById('activeUsers').textContent = users.filter(u => u.is_active).length;
document.getElementById('adminUsers').textContent = users.filter(u =>
u.roles.some(r => r.name === 'admin')
).length;
document.getElementById('operationUsers').textContent = users.filter(u =>
u.roles.some(r => r.name === 'operation')
).length;
// Render table
const tableContainer = document.getElementById('tableContainer');
if (users.length === 0) {
tableContainer.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<h3>No users found</h3>
<p>Users will appear here when they log in for the first time.</p>
</div>
`;
return;
}
tableContainer.innerHTML = `
<table>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Full Name</th>
<th>Roles</th>
<th>Status</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${users.map(user => `
<tr>
<td><strong>${escapeHtml(user.username)}</strong></td>
<td>${user.email ? escapeHtml(user.email) : '-'}</td>
<td>${user.full_name ? escapeHtml(user.full_name) : '-'}</td>
<td>
${user.roles.map(role =>
`<span class="role-badge role-${role.name}">${role.name}</span>`
).join(' ') || '<span style="color: #999;">No roles</span>'}
</td>
<td class="${user.is_active ? 'status-active' : 'status-inactive'}">
${user.is_active ? '✅ Active' : '❌ Inactive'}
</td>
<td>${user.last_login ? formatDate(user.last_login) : 'Never'}</td>
<td>
<button class="btn btn-primary" onclick="editUser(${user.id})">Edit</button>
<button class="btn btn-danger" onclick="deleteUser(${user.id}, '${escapeHtml(user.username)}')">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (error) {
console.error('Error loading users:', error);
document.getElementById('tableContainer').innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<h3>Error loading users</h3>
<p>${escapeHtml(error.message)}</p>
</div>
`;
showAlert('Failed to load users: ' + error.message, 'error');
}
}
function showAlert(message, type) {
const alertContainer = document.getElementById('alertContainer');
alertContainer.innerHTML = `
<div class="alert alert-${type}">
${escapeHtml(message)}
</div>
`;
setTimeout(() => alertContainer.innerHTML = '', 5000);
}
function editUser(userId) {
// TODO: Implement edit modal
showAlert('Edit functionality coming soon!', 'success');
}
async function deleteUser(userId, username) {
if (!confirm(`Are you sure you want to delete user "${username}"?`)) return;
try {
const response = await fetch(`${rootPath}/admin/users/${userId}`, {
method: 'DELETE'
});
if (response.ok) {
showAlert(`User "${username}" deleted successfully`, 'success');
loadUsers();
} else {
const error = await response.json();
showAlert('Failed to delete user: ' + error.detail, 'error');
}
} catch (error) {
console.error('Error deleting user:', error);
showAlert('Failed to delete user: ' + error.message, 'error');
}
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load users on page load
loadUsers();
</script>
</body>
</html>

View File

@@ -0,0 +1,635 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Finance Excel Upload - Sriphat Data Platform</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
position: relative;
}
.header h1 {
color: #333;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.header p {
color: #666;
}
.user-info {
position: absolute;
top: 25px;
right: 25px;
display: flex;
align-items: center;
gap: 15px;
background: #f8f9fa;
padding: 8px 16px;
border-radius: 20px;
border: 1px solid #dee2e6;
}
.user-info .username {
font-size: 0.9rem;
color: #495057;
font-weight: 500;
}
.logout-btn {
background: #667eea;
color: white;
padding: 5px 12px;
border-radius: 12px;
text-decoration: none;
font-size: 0.85rem;
transition: background 0.3s ease;
}
.logout-btn:hover {
background: #5568d3;
}
.role-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.role-admin {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
box-shadow: 0 2px 4px rgba(255,107,107,0.3);
}
.role-operation {
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
color: white;
box-shadow: 0 2px 4px rgba(78,205,196,0.3);
}
.back-link {
display: inline-block;
color: #667eea;
text-decoration: none;
margin-bottom: 15px;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
.upload-section {
background: white;
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.upload-section h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5rem;
}
.upload-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
color: #333;
font-weight: 500;
}
.file-input-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
.file-input {
display: none;
}
.file-input-label {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
border: 2px dashed #667eea;
border-radius: 8px;
background: #f8f9fa;
cursor: pointer;
transition: all 0.3s ease;
}
.file-input-label:hover {
background: #e9ecef;
border-color: #764ba2;
}
.file-input-label.has-file {
border-color: #28a745;
background: #d4edda;
}
.file-info {
display: none;
margin-top: 10px;
padding: 10px;
background: #e9ecef;
border-radius: 6px;
font-size: 0.9rem;
}
.file-info.show {
display: block;
}
.btn {
padding: 12px 30px;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-primary:disabled {
background: #ccc;
cursor: not-allowed;
}
.alert {
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.alert.show {
display: block;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.uploads-list {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.uploads-list h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5rem;
}
.upload-item {
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: 15px;
overflow: hidden;
}
.upload-header {
padding: 15px;
background: #f8f9fa;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s ease;
}
.upload-header:hover {
background: #e9ecef;
}
.upload-info {
flex: 1;
}
.upload-filename {
font-weight: 500;
color: #333;
margin-bottom: 5px;
}
.upload-meta {
font-size: 0.85rem;
color: #666;
}
.upload-status {
display: flex;
align-items: center;
gap: 10px;
}
.status-badge {
padding: 5px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-pending {
background: #fff3cd;
color: #856404;
}
.status-processing {
background: #d1ecf1;
color: #0c5460;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
.expand-icon {
transition: transform 0.3s ease;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
.upload-details {
padding: 15px;
background: white;
border-top: 1px solid #dee2e6;
display: none;
}
.upload-details.show {
display: block;
}
.log-container {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.log-error {
color: #dc3545;
}
.log-info {
color: #17a2b8;
}
.empty-state {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state .icon {
font-size: 3rem;
margin-bottom: 15px;
opacity: 0.5;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(0,0,0,0.1);
border-radius: 50%;
border-top-color: #667eea;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.upload-section, .uploads-list {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<a href="{{ root_path }}/" class="back-link">← Back to Dashboard</a>
<div class="header">
<h1>💰 Finance Excel Upload</h1>
<p>Upload Excel files for financial data processing</p>
{% if user %}
<div class="user-info">
<span class="username">👤 {{ user.name or user.username }}</span>
{% if user.roles %}
{% for role in user.roles %}
<span class="role-badge role-{{ role }}">{{ role }}</span>
{% endfor %}
{% endif %}
<a href="{{ root_path }}/auth/logout" class="logout-btn">Logout</a>
</div>
{% endif %}
</div>
<div id="alertContainer"></div>
<div class="upload-section">
<h2>📤 Upload File</h2>
<form id="uploadForm" class="upload-form" enctype="multipart/form-data">
<div class="form-group">
<label for="file">Select Excel File (.xlsx, .xls)</label>
<div class="file-input-wrapper">
<input type="file" id="file" name="file" class="file-input" accept=".xlsx,.xls" required>
<label for="file" id="fileLabel" class="file-input-label">
<span>📁 Click to select file or drag and drop</span>
</label>
</div>
<div id="fileInfo" class="file-info"></div>
</div>
<div class="form-group">
<label for="description">Description (Optional)</label>
<textarea id="description" name="description" rows="3" style="padding: 10px; border: 1px solid #dee2e6; border-radius: 6px; font-family: inherit;"></textarea>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">
Upload and Process
</button>
</form>
</div>
<div class="uploads-list">
<h2>📋 Upload History</h2>
<div id="uploadsList">
<div class="empty-state">
<div class="icon">📭</div>
<p>No uploads yet</p>
</div>
</div>
</div>
</div>
<script>
const rootPath = '{{ root_path }}';
const fileInput = document.getElementById('file');
const fileLabel = document.getElementById('fileLabel');
const fileInfo = document.getElementById('fileInfo');
const uploadForm = document.getElementById('uploadForm');
const submitBtn = document.getElementById('submitBtn');
const uploadsList = document.getElementById('uploadsList');
const alertContainer = document.getElementById('alertContainer');
// File input handling
fileInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
fileLabel.classList.add('has-file');
fileLabel.innerHTML = `<span>✅ ${file.name}</span>`;
fileInfo.classList.add('show');
fileInfo.textContent = `File: ${file.name} (${formatFileSize(file.size)})`;
}
});
// Drag and drop
fileLabel.addEventListener('dragover', (e) => {
e.preventDefault();
fileLabel.style.borderColor = '#764ba2';
});
fileLabel.addEventListener('dragleave', () => {
fileLabel.style.borderColor = '#667eea';
});
fileLabel.addEventListener('drop', (e) => {
e.preventDefault();
fileLabel.style.borderColor = '#667eea';
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
fileInput.dispatchEvent(new Event('change'));
}
});
// Form submission
uploadForm.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(uploadForm);
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span> Uploading...';
try {
const response = await fetch(`${rootPath}/data-management/finance/upload`, {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
showAlert('success', result.message || 'File uploaded successfully!');
uploadForm.reset();
fileLabel.classList.remove('has-file');
fileLabel.innerHTML = '<span>📁 Click to select file or drag and drop</span>';
fileInfo.classList.remove('show');
// Refresh uploads list
loadUploads();
} else {
showAlert('error', result.detail || 'Upload failed');
}
} catch (error) {
showAlert('error', 'Network error: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Upload and Process';
}
});
// Load uploads list
async function loadUploads() {
try {
const response = await fetch(`${rootPath}/data-management/finance/uploads`);
const uploads = await response.json();
if (uploads.length === 0) {
uploadsList.innerHTML = `
<div class="empty-state">
<div class="icon">📭</div>
<p>No uploads yet</p>
</div>
`;
return;
}
uploadsList.innerHTML = uploads.map(upload => `
<div class="upload-item">
<div class="upload-header" onclick="toggleDetails('${upload.id}')">
<div class="upload-info">
<div class="upload-filename">${upload.filename}</div>
<div class="upload-meta">
Uploaded: ${formatDate(upload.uploaded_at)}
${upload.description ? `${upload.description}` : ''}
</div>
</div>
<div class="upload-status">
<span class="status-badge status-${upload.status}">${upload.status.toUpperCase()}</span>
<span class="expand-icon" id="icon-${upload.id}">▼</span>
</div>
</div>
<div class="upload-details" id="details-${upload.id}">
${upload.job_id ? `<p><strong>Job ID:</strong> ${upload.job_id}</p>` : ''}
${upload.logs ? `
<h4>Logs:</h4>
<div class="log-container ${upload.status === 'error' ? 'log-error' : 'log-info'}">
${upload.logs}
</div>
` : '<p>No logs available</p>'}
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load uploads:', error);
}
}
function toggleDetails(id) {
const details = document.getElementById(`details-${id}`);
const icon = document.getElementById(`icon-${id}`);
if (details.classList.contains('show')) {
details.classList.remove('show');
icon.classList.remove('expanded');
} else {
details.classList.add('show');
icon.classList.add('expanded');
}
}
function showAlert(type, message) {
const alertClass = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
const alert = document.createElement('div');
alert.className = `alert ${alertClass} show`;
alert.textContent = message;
alertContainer.innerHTML = '';
alertContainer.appendChild(alert);
setTimeout(() => {
alert.classList.remove('show');
}, 5000);
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Load uploads on page load
loadUploads();
// Refresh uploads every 10 seconds
setInterval(loadUploads, 10000);
</script>
</body>
</html>

View File

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sriphat Data Platform</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 40px;
padding: 20px;
position: relative;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.1rem;
opacity: 0.9;
}
.user-info {
position: absolute;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 15px;
background: rgba(255,255,255,0.1);
padding: 10px 20px;
border-radius: 25px;
backdrop-filter: blur(10px);
}
.user-info .username {
font-size: 0.95rem;
font-weight: 500;
}
.logout-btn {
background: rgba(255,255,255,0.2);
color: white;
padding: 6px 15px;
border-radius: 15px;
text-decoration: none;
font-size: 0.9rem;
transition: background 0.3s ease;
}
.logout-btn:hover {
background: rgba(255,255,255,0.3);
}
.role-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.role-admin {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
box-shadow: 0 2px 4px rgba(255,107,107,0.3);
}
.role-operation {
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
color: white;
box-shadow: 0 2px 4px rgba(78,205,196,0.3);
}
.menu-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.menu-card {
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
cursor: pointer;
text-decoration: none;
color: inherit;
display: block;
}
.menu-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0,0,0,0.2);
}
.menu-card.has-submenu {
cursor: default;
}
.menu-card .icon {
font-size: 2.5rem;
margin-bottom: 15px;
display: block;
}
.menu-card h3 {
color: #333;
margin-bottom: 10px;
font-size: 1.3rem;
}
.menu-card p {
color: #666;
font-size: 0.9rem;
line-height: 1.5;
}
.submenu {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
display: none;
}
.submenu.active {
display: block;
}
.submenu-item {
display: block;
padding: 10px 15px;
margin: 5px 0;
background: #f8f9fa;
border-radius: 6px;
color: #495057;
text-decoration: none;
transition: background 0.2s ease;
}
.submenu-item:hover {
background: #e9ecef;
}
.submenu-toggle {
display: inline-block;
margin-left: 10px;
font-size: 0.8rem;
color: #667eea;
cursor: pointer;
}
/* Color themes for different services */
.card-supabase { border-left: 4px solid #3ECF8E; }
.card-api { border-left: 4px solid #009688; }
.card-airflow { border-left: 4px solid #017CEE; }
.card-airbyte { border-left: 4px solid #615EFF; }
.card-data { border-left: 4px solid #FF6B6B; }
.card-dbt { border-left: 4px solid #FF694B; }
.card-superset { border-left: 4px solid #20A7C9; }
@media (max-width: 768px) {
.header h1 {
font-size: 2rem;
}
.menu-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🏥 Sriphat Data Platform</h1>
<p>Integrated Data Management & Analytics Platform</p>
{% if user %}
<div class="user-info">
<span class="username">👤 {{ user.name or user.username }}</span>
{% if user.roles %}
{% for role in user.roles %}
<span class="role-badge role-{{ role }}">{{ role }}</span>
{% endfor %}
{% endif %}
<a href="{{ root_path }}/auth/logout" class="logout-btn">Logout</a>
</div>
{% endif %}
</div>
<div class="menu-grid">
<!-- Supabase -->
<a href="https://ai.sriphat.com/supabase" class="menu-card card-supabase" target="_blank">
<span class="icon">🗄️</span>
<h3>Supabase</h3>
<p>PostgreSQL database with real-time capabilities and REST API</p>
</a>
<!-- API Docs -->
<a href="{{ root_path }}/docs" class="menu-card card-api">
<span class="icon">📚</span>
<h3>API Documentation</h3>
<p>Interactive API documentation and testing interface</p>
</a>
<!-- Airflow -->
<a href="https://ai.sriphat.com/airflow" class="menu-card card-airflow" target="_blank">
<span class="icon">🔄</span>
<h3>Airflow</h3>
<p>Workflow orchestration and data pipeline management</p>
</a>
<!-- Airbyte -->
<a href="https://ai.sriphat.com/airbyte" class="menu-card card-airbyte" target="_blank">
<span class="icon">🔌</span>
<h3>Airbyte</h3>
<p>Data integration and ETL platform</p>
</a>
<!-- Data Management -->
<div class="menu-card card-data has-submenu" onclick="toggleSubmenu(this)">
<span class="icon">📊</span>
<h3>Data Management <span class="submenu-toggle"></span></h3>
<p>Upload and manage data files</p>
<div class="submenu">
<a href="{{ root_path }}/data-management/finance" class="submenu-item">
💰 Finance Excel Upload
</a>
</div>
</div>
<!-- DBT -->
<a href="https://ai.sriphat.com/dbt" class="menu-card card-dbt" target="_blank">
<span class="icon">🔧</span>
<h3>DBT</h3>
<p>Data transformation and modeling</p>
</a>
<!-- Superset -->
<a href="https://ai.sriphat.com/superset" class="menu-card card-superset" target="_blank">
<span class="icon">📈</span>
<h3>Superset</h3>
<p>Business intelligence and data visualization</p>
</a>
<!-- User Management (Admin only) -->
{% if user and user.roles and 'admin' in user.roles %}
<a href="{{ root_path }}/admin/users" class="menu-card card-admin">
<span class="icon">👥</span>
<h3>User Management</h3>
<p>Manage users and roles (Admin only)</p>
</a>
{% endif %}
</div>
</div>
<script>
function toggleSubmenu(card) {
const submenu = card.querySelector('.submenu');
const toggle = card.querySelector('.submenu-toggle');
if (submenu.classList.contains('active')) {
submenu.classList.remove('active');
toggle.textContent = '▼';
} else {
submenu.classList.add('active');
toggle.textContent = '▲';
}
}
</script>
</body>
</html>

View File

@@ -48,7 +48,7 @@ async def upsert_to_supabase(
headers["Prefer"] += f",on_conflict={on_conflict}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
response = await client.post(url, json=data, headers=headers)
response.raise_for_status()
return {
@@ -102,7 +102,7 @@ def upsert_to_supabase_sync(
headers["Prefer"] += f",on_conflict={on_conflict}"
try:
with httpx.Client(timeout=30.0) as client:
with httpx.Client(timeout=30.0, verify=False) as client:
response = client.post(url, json=data, headers=headers)
response.raise_for_status()
return {

View File

@@ -1,6 +1,12 @@
x-common-configs: &common-config
extra_hosts:
- "dev.sriphat.com:192.168.100.9"
pull_policy: ${DOCKER_PULL_POLICY:-missing}
services:
apiservice:
build: .
#build: .
image: 03-apiservice-apiservice:latest
container_name: apiservice
env_file:
- .env
@@ -17,6 +23,13 @@ services:
- ADMIN_SECRET_KEY=${ADMIN_SECRET_KEY}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- API_KEY_ENC_SECRET=${ADMIN_SECRET_KEY}
- DEBUG_AUTH=${DEBUG_AUTH:-false}
- KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL}
- KEYCLOAK_REALM=${KEYCLOAK_REALM}
- KEYCLOAK_CLIENT_ID=${API_KEYCLOAK_CLIENT_ID}
- KEYCLOAK_CLIENT_SECRET=${API_KEYCLOAK_CLIENT_SECRET}
- KEYCLOAK_REDIRECT_URI=${API_KEYCLOAK_REDIRECT_URI}
- LOG_LEVEL=debug
ports:
- "8040:8040"
@@ -25,6 +38,7 @@ services:
volumes:
- ./app:/app/app
- .env:/app/.env
- ./data/uploads:/data/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8040/apiservice/docs"]
@@ -32,6 +46,9 @@ services:
timeout: 10s
retries: 3
start_period: 40s
# extra_hosts:
# - "dev.sriphat.com:192.168.100.9"
<<: *common-config
networks:
shared_data_network:

View File

@@ -14,4 +14,7 @@ httpx==0.28.1
WTForms
#==3.2.1
cryptography==42.0.5
python-keycloak==3.9.0
Authlib==1.3.0
python-jose[cryptography]==3.3.0

View File

@@ -0,0 +1,46 @@
FROM apache/superset:latest
USER root
ENV PATH="/app/.venv/bin:$PATH"
RUN python -m ensurepip --upgrade && /app/.venv/bin/python -m pip install --upgrade pip setuptools wheel
RUN /app/.venv/bin/pip install --no-cache-dir psycopg2-binary
# Install nginx and supervisor
RUN apt-get update && apt-get install -y --no-install-recommends nginx supervisor && rm -rf /var/lib/apt/lists/*
# Copy nginx config
COPY nginx-superset.conf /etc/nginx/sites-available/default
# Create supervisor config to run both nginx and gunicorn
RUN cat > /etc/supervisor/conf.d/supervisord.conf << 'EOF'
[supervisord]
nodaemon=true
user=root
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:superset]
command=/app/.venv/bin/gunicorn --bind 127.0.0.1:8088 --workers 4 --timeout 120 --limit-request-line 0 --limit-request-field_size 0 "superset.app:create_app()"
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
user=superset
EOF
EXPOSE 80
USER superset

View File

@@ -0,0 +1,61 @@
server {
listen 80;
server_name localhost;
# Handle API endpoints directly - no rewrite needed
location /superset/api/ {
proxy_pass http://superset:8088/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Accept-Encoding "";
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
}
# Strip /superset/ prefix, proxy to Superset at root
location /superset/ {
proxy_pass http://superset:8088/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Accept-Encoding "";
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
# Rewrite redirect Location headers to add /superset prefix
proxy_redirect ~^(https?://[^/]+)?/(.*)$ $1/superset/$2;
# Rewrite URLs in HTML responses
sub_filter_once off;
sub_filter_types text/html application/json;
sub_filter 'href="/' 'href="/superset/';
sub_filter 'src="/' 'src="/superset/';
sub_filter 'action="/' 'action="/superset/';
sub_filter '"/static/' '"/superset/static/';
sub_filter '"/api/' '"/superset/api/';
sub_filter '"/superset/superset/' '"/superset/superset/';
sub_filter '"/login/' '"/superset/login/';
sub_filter '"/logout/' '"/superset/logout/';
sub_filter '"/lang/' '"/superset/lang/';
}
location = /superset {
return 301 /superset/;
}
# Health check for Docker
location = /health {
proxy_pass http://superset:8088/health;
}
}

55
07-minio/.env.example Normal file
View File

@@ -0,0 +1,55 @@
# MinIO Configuration
# Copy this file to .env and update with your values
# ============================================================================
# MinIO Credentials
# ============================================================================
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin_secure_password_2026
# ============================================================================
# MinIO Ports
# ============================================================================
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
# ============================================================================
# MinIO Server URLs (for reverse proxy)
# ============================================================================
# API endpoint URL (for S3 API access)
MINIO_SERVER_URL=https://ai.sriphat.com/minio
# Console UI URL (for web interface)
MINIO_BROWSER_REDIRECT_URL=https://ai.sriphat.com/minio-console
# ============================================================================
# MinIO Region
# ============================================================================
MINIO_REGION=ap-southeast-1
# ============================================================================
# Keycloak Integration (OpenID Connect)
# ============================================================================
# Keycloak OpenID configuration URL
# Format: https://{keycloak-domain}/realms/{realm-name}/.well-known/openid-configuration
MINIO_IDENTITY_OPENID_CONFIG_URL=https://ai.sriphat.com/keycloak/realms/sriphat/.well-known/openid-configuration
# MinIO client in Keycloak
MINIO_IDENTITY_OPENID_CLIENT_ID=minio
# Client secret from Keycloak
MINIO_IDENTITY_OPENID_CLIENT_SECRET=your-minio-client-secret-here
# Claim name for policy mapping (default: policy)
MINIO_IDENTITY_OPENID_CLAIM_NAME=policy
# OpenID scopes
MINIO_IDENTITY_OPENID_SCOPES=openid,profile,email
# Redirect URI after authentication
MINIO_IDENTITY_OPENID_REDIRECT_URI=https://ai.sriphat.com/minio-console/oauth_callback
# ============================================================================
# Timezone
# ============================================================================
TZ=Asia/Bangkok

30
07-minio/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Environment variables
.env
# Data directory (persistent storage)
data/
# SSL certificates
certs/
# Logs
*.log
# Backup files
*.tar.gz
*.zip
# Temporary files
*.tmp
*.temp
# OS files
.DS_Store
Thumbs.db
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~

View File

@@ -0,0 +1,362 @@
# MinIO Keycloak Integration Guide
Complete guide for integrating MinIO with Keycloak for SSO authentication.
## 🎯 Overview
MinIO supports OpenID Connect (OIDC) authentication, allowing users to log in to MinIO Console using Keycloak credentials. This integration provides:
- **Single Sign-On (SSO)** - Users authenticate once with Keycloak
- **Centralized User Management** - Manage users in Keycloak
- **Role-Based Access Control** - Map Keycloak roles to MinIO policies
- **Secure Authentication** - OAuth 2.0 / OpenID Connect flow
## 📋 Prerequisites
- Keycloak instance running and accessible
- MinIO instance running
- Admin access to both Keycloak and MinIO
## 🔧 Setup Steps
### **Step 1: Create MinIO Client in Keycloak**
1. **Login to Keycloak Admin Console**
```
https://ai.sriphat.com/keycloak
```
2. **Select Realm**
- Go to your realm (e.g., `sriphat`)
3. **Create Client**
- Navigate to: **Clients** → **Create Client**
- **Client ID**: `minio`
- **Client Type**: `OpenID Connect`
- **Client Protocol**: `openid-connect`
- Click **Next**
4. **Capability Config**
- **Client authentication**: `ON`
- **Authorization**: `OFF`
- **Authentication flow**:
- ✅ Standard flow
- ✅ Direct access grants
- ❌ Implicit flow
- ❌ Service accounts roles
- Click **Next**
5. **Login Settings**
- **Root URL**: `https://ai.sriphat.com/minio-console`
- **Home URL**: `https://ai.sriphat.com/minio-console`
- **Valid redirect URIs**:
```
https://ai.sriphat.com/minio-console/*
https://ai.sriphat.com/minio-console/oauth_callback
```
- **Valid post logout redirect URIs**: `https://ai.sriphat.com/minio-console`
- **Web origins**: `https://ai.sriphat.com`
- Click **Save**
6. **Get Client Secret**
- Go to **Credentials** tab
- Copy the **Client Secret**
- Save this for `.env` configuration
### **Step 2: Create Client Scope for MinIO Policy**
1. **Create Client Scope**
- Navigate to: **Client Scopes** → **Create client scope**
- **Name**: `minio-authorization`
- **Type**: `Optional`
- **Protocol**: `OpenID Connect`
- **Display on consent screen**: `OFF`
- Click **Save**
2. **Add Mapper for Policy Claim**
- Go to **Mappers** tab
- Click **Add mapper** → **By configuration**
- Select **User Attribute**
- **Name**: `minio-policy`
- **User Attribute**: `minio_policy`
- **Token Claim Name**: `policy`
- **Claim JSON Type**: `String`
- **Add to ID token**: `ON`
- **Add to access token**: `ON`
- **Add to userinfo**: `ON`
- Click **Save**
3. **Assign Scope to MinIO Client**
- Go to **Clients** → `minio`
- Go to **Client scopes** tab
- Click **Add client scope**
- Select `minio-authorization`
- Choose **Optional**
- Click **Add**
### **Step 3: Create MinIO Policies in Keycloak**
MinIO uses policies to control access. Common policies:
- `consoleAdmin` - Full admin access
- `readonly` - Read-only access
- `readwrite` - Read and write access
- `diagnostics` - Diagnostics access
**Add Policy to Users:**
1. **Go to Users**
- Navigate to: **Users** → Select user
2. **Add Attribute**
- Go to **Attributes** tab
- Click **Add attribute**
- **Key**: `minio_policy`
- **Value**: `consoleAdmin` (or other policy)
- Click **Save**
### **Step 4: Configure MinIO Environment Variables**
Update `07-minio/.env`:
```bash
# Keycloak Integration
MINIO_IDENTITY_OPENID_CONFIG_URL=https://ai.sriphat.com/keycloak/realms/sriphat/.well-known/openid-configuration
MINIO_IDENTITY_OPENID_CLIENT_ID=minio
MINIO_IDENTITY_OPENID_CLIENT_SECRET=your-client-secret-from-step-1
MINIO_IDENTITY_OPENID_CLAIM_NAME=policy
MINIO_IDENTITY_OPENID_SCOPES=openid,profile,email,minio-authorization
MINIO_IDENTITY_OPENID_REDIRECT_URI=https://ai.sriphat.com/minio-console/oauth_callback
```
### **Step 5: Restart MinIO**
```bash
cd 07-minio
docker compose down
docker compose up -d
```
### **Step 6: Test Authentication**
1. **Access MinIO Console**
```
https://ai.sriphat.com/minio-console
```
2. **Click "Login with SSO"**
- You'll be redirected to Keycloak
- Login with Keycloak credentials
- After successful authentication, you'll be redirected back to MinIO Console
## 🔐 MinIO Policies
### **Default Policies**
MinIO comes with built-in policies:
| Policy | Description |
|--------|-------------|
| `consoleAdmin` | Full admin access to console and buckets |
| `readonly` | Read-only access to buckets |
| `readwrite` | Read and write access to buckets |
| `diagnostics` | Access to diagnostics and monitoring |
| `writeonly` | Write-only access (upload only) |
### **Custom Policies**
Create custom policies in MinIO Console or via `mc` CLI:
```bash
# Install mc (MinIO Client)
wget https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc
sudo mv mc /usr/local/bin/
# Configure mc
mc alias set myminio https://ai.sriphat.com/minio minioadmin minioadmin_secure_password_2026
# Create custom policy
cat > custom-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::mybucket/*"
]
}
]
}
EOF
# Add policy to MinIO
mc admin policy create myminio custom-policy custom-policy.json
```
## 🔄 Policy Mapping Flow
```
User logs in with Keycloak
Keycloak returns ID token with 'policy' claim
MinIO reads 'policy' claim value (e.g., "consoleAdmin")
MinIO applies corresponding policy to user session
User has permissions defined by the policy
```
## 🎯 Role-Based Access Example
### **Scenario: Different User Roles**
**Admin Users:**
```
Keycloak User Attribute:
minio_policy: consoleAdmin
```
**Data Scientists:**
```
Keycloak User Attribute:
minio_policy: readwrite
```
**Analysts:**
```
Keycloak User Attribute:
minio_policy: readonly
```
## 🐛 Troubleshooting
### **Issue: "Login with SSO" button not showing**
**Check:**
```bash
# Verify environment variables
docker exec minio printenv | grep MINIO_IDENTITY_OPENID
# Check MinIO logs
docker logs minio
```
**Solution:**
- Ensure all `MINIO_IDENTITY_OPENID_*` variables are set
- Restart MinIO container
### **Issue: Redirect loop after login**
**Check:**
- `MINIO_BROWSER_REDIRECT_URL` matches Keycloak redirect URI
- Valid redirect URIs in Keycloak client include `/oauth_callback`
**Solution:**
```bash
# Update .env
MINIO_BROWSER_REDIRECT_URL=https://ai.sriphat.com/minio-console
MINIO_IDENTITY_OPENID_REDIRECT_URI=https://ai.sriphat.com/minio-console/oauth_callback
```
### **Issue: User has no permissions after login**
**Check:**
- User has `minio_policy` attribute in Keycloak
- Policy name matches MinIO policy exactly (case-sensitive)
**Solution:**
```bash
# Verify user attribute in Keycloak
# Add minio_policy attribute with value: consoleAdmin
```
### **Issue: Cannot access Keycloak config URL**
**Check:**
```bash
# Test from MinIO container
docker exec minio curl -k https://ai.sriphat.com/keycloak/realms/sriphat/.well-known/openid-configuration
```
**Solution:**
- Ensure MinIO container can reach Keycloak
- Check network connectivity
- Verify Keycloak realm name is correct
## 📊 Monitoring
### **Check OpenID Configuration**
```bash
# View current OpenID config
docker exec minio mc admin config get myminio identity_openid
```
### **View Active Sessions**
```bash
# List active user sessions
docker exec minio mc admin user list myminio
```
### **Audit Logs**
```bash
# Enable audit logging
docker exec minio mc admin config set myminio audit_webhook:1 endpoint="http://your-webhook-endpoint"
# View logs
docker logs minio -f
```
## 🔒 Security Best Practices
1. **Use HTTPS Only**
- Always use HTTPS for MinIO and Keycloak
- Configure SSL certificates properly
2. **Rotate Client Secrets**
- Periodically rotate Keycloak client secrets
- Update MinIO configuration after rotation
3. **Least Privilege Principle**
- Assign minimal required policies to users
- Use custom policies for specific use cases
4. **Monitor Access**
- Enable audit logging
- Review access logs regularly
5. **Secure Network**
- Use firewall rules to restrict access
- Consider VPN for sensitive data
## 📚 References
- [MinIO OpenID Connect](https://min.io/docs/minio/linux/operations/external-iam/configure-openid-external-identity-management.html)
- [Keycloak OpenID Connect](https://www.keycloak.org/docs/latest/server_admin/#_oidc)
- [MinIO IAM Policies](https://min.io/docs/minio/linux/administration/identity-access-management/policy-based-access-control.html)
## 🎉 Summary
After completing these steps:
- ✅ MinIO integrated with Keycloak SSO
- ✅ Users can login with Keycloak credentials
- ✅ Role-based access control configured
- ✅ Centralized user management in Keycloak
- ✅ Secure HTTPS access via Nginx reverse proxy
**Access MinIO Console:**
```
https://ai.sriphat.com/minio-console
```
**Login with SSO** → Keycloak authentication → MinIO Console access! 🚀

520
07-minio/README.md Normal file
View File

@@ -0,0 +1,520 @@
# MinIO Object Storage Service
MinIO is a high-performance, S3-compatible object storage system. This setup includes persistent storage, HTTPS access via Nginx reverse proxy, and Keycloak SSO integration.
## 🎯 Overview
**MinIO Features:**
- **S3-Compatible API** - Works with AWS S3 SDKs and tools
- **High Performance** - Optimized for large-scale data workloads
- **Distributed Storage** - Supports multi-node deployment
- **Web Console** - User-friendly web interface
- **Encryption** - Server-side and client-side encryption
- **Versioning** - Object versioning support
- **Lifecycle Management** - Automatic data retention policies
**This Setup Includes:**
- Docker Compose configuration
- Persistent storage with volume mounts
- HTTPS access via Nginx reverse proxy
- Keycloak SSO integration (OpenID Connect)
- Health checks and monitoring
## 📋 Prerequisites
- Docker and Docker Compose installed
- Network: `shared_data_network` created
- Nginx reverse proxy configured
- Keycloak instance running (for SSO)
- Server: 192.168.100.9
## 🚀 Quick Start
### **Step 1: Configure Environment**
```bash
cd 07-minio
# Copy example environment file
cp .env.example .env
# Edit .env with your settings
nano .env
```
**Required Configuration:**
```bash
# MinIO Credentials
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=your-secure-password-here
# Keycloak Integration
MINIO_IDENTITY_OPENID_CLIENT_SECRET=your-keycloak-client-secret
```
### **Step 2: Create Data Directory**
```bash
# Create persistent storage directory
mkdir -p data
# Set permissions
chmod 755 data
```
### **Step 3: Start MinIO**
```bash
# Start service
docker compose up -d
# Check status
docker compose ps
# View logs
docker logs minio -f
```
### **Step 4: Configure Nginx Reverse Proxy**
Add the configuration from `nginx-minio.conf` to your Nginx Proxy Manager:
1. Go to Nginx Proxy Manager UI
2. Create/Edit Proxy Host for `ai.sriphat.com`
3. Add MinIO configuration to "Custom Nginx Configuration"
4. Save and test
### **Step 5: Setup Keycloak Integration**
Follow the detailed guide in `KEYCLOAK_INTEGRATION.md`:
1. Create MinIO client in Keycloak
2. Configure client scopes and mappers
3. Add policy attributes to users
4. Update MinIO environment variables
5. Restart MinIO service
## 🌐 Access URLs
**MinIO Console (Web UI):**
```
https://ai.sriphat.com/minio-console
```
**MinIO API (S3 Compatible):**
```
https://ai.sriphat.com/minio
```
**Direct Access (Development):**
```
http://192.168.100.9:9001 (Console)
http://192.168.100.9:9000 (API)
```
## 🔑 Authentication
### **Option 1: Root Credentials (Default)**
Login with root credentials from `.env`:
- **Username**: Value of `MINIO_ROOT_USER`
- **Password**: Value of `MINIO_ROOT_PASSWORD`
### **Option 2: Keycloak SSO (Recommended)**
1. Click "Login with SSO" on MinIO Console
2. Authenticate with Keycloak
3. Access granted based on policy mapping
See `KEYCLOAK_INTEGRATION.md` for setup instructions.
## 📦 Using MinIO
### **Web Console**
1. Access: `https://ai.sriphat.com/minio-console`
2. Login with credentials or SSO
3. Create buckets, upload files, manage access
### **MinIO Client (mc)**
```bash
# Install mc
wget https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc
sudo mv mc /usr/local/bin/
# Configure alias
mc alias set myminio https://ai.sriphat.com/minio minioadmin your-password
# List buckets
mc ls myminio
# Create bucket
mc mb myminio/my-bucket
# Upload file
mc cp myfile.txt myminio/my-bucket/
# Download file
mc cp myminio/my-bucket/myfile.txt ./
# List objects
mc ls myminio/my-bucket
# Remove object
mc rm myminio/my-bucket/myfile.txt
```
### **Python SDK (boto3)**
```python
import boto3
from botocore.client import Config
# Configure S3 client
s3 = boto3.client(
's3',
endpoint_url='https://ai.sriphat.com/minio',
aws_access_key_id='minioadmin',
aws_secret_access_key='your-password',
config=Config(signature_version='s3v4'),
region_name='ap-southeast-1'
)
# List buckets
response = s3.list_buckets()
for bucket in response['Buckets']:
print(bucket['Name'])
# Upload file
s3.upload_file('myfile.txt', 'my-bucket', 'myfile.txt')
# Download file
s3.download_file('my-bucket', 'myfile.txt', 'downloaded.txt')
# List objects
response = s3.list_objects_v2(Bucket='my-bucket')
for obj in response.get('Contents', []):
print(obj['Key'])
```
### **AWS CLI**
```bash
# Configure AWS CLI
aws configure set aws_access_key_id minioadmin
aws configure set aws_secret_access_key your-password
aws configure set region ap-southeast-1
# List buckets
aws --endpoint-url https://ai.sriphat.com/minio s3 ls
# Create bucket
aws --endpoint-url https://ai.sriphat.com/minio s3 mb s3://my-bucket
# Upload file
aws --endpoint-url https://ai.sriphat.com/minio s3 cp myfile.txt s3://my-bucket/
# Download file
aws --endpoint-url https://ai.sriphat.com/minio s3 cp s3://my-bucket/myfile.txt ./
# Sync directory
aws --endpoint-url https://ai.sriphat.com/minio s3 sync ./mydir s3://my-bucket/mydir/
```
## 🔧 Configuration
### **Environment Variables**
| Variable | Description | Default |
|----------|-------------|---------|
| `MINIO_ROOT_USER` | Root username | minioadmin |
| `MINIO_ROOT_PASSWORD` | Root password | - |
| `MINIO_API_PORT` | API port | 9000 |
| `MINIO_CONSOLE_PORT` | Console port | 9001 |
| `MINIO_SERVER_URL` | API endpoint URL | - |
| `MINIO_BROWSER_REDIRECT_URL` | Console URL | - |
| `MINIO_REGION` | Default region | ap-southeast-1 |
### **Keycloak Integration**
| Variable | Description |
|----------|-------------|
| `MINIO_IDENTITY_OPENID_CONFIG_URL` | Keycloak OIDC config URL |
| `MINIO_IDENTITY_OPENID_CLIENT_ID` | Client ID in Keycloak |
| `MINIO_IDENTITY_OPENID_CLIENT_SECRET` | Client secret |
| `MINIO_IDENTITY_OPENID_CLAIM_NAME` | Policy claim name |
| `MINIO_IDENTITY_OPENID_SCOPES` | OIDC scopes |
### **Storage**
**Persistent Data:**
```
07-minio/data/ # Object storage data
07-minio/certs/ # SSL certificates (optional)
```
**Volume Mounts:**
```yaml
volumes:
- ./data:/data # Storage data
- ./certs:/root/.minio/certs:ro # SSL certs
```
## 🔒 Security
### **1. Strong Passwords**
```bash
# Generate strong password
openssl rand -base64 32
# Update .env
MINIO_ROOT_PASSWORD=generated-password-here
```
### **2. Network Security**
```bash
# Firewall rules (if needed)
sudo ufw allow from 192.168.100.0/24 to any port 9000
sudo ufw allow from 192.168.100.0/24 to any port 9001
```
### **3. HTTPS Only**
- Always use HTTPS in production
- Configure SSL certificates in Nginx
- Set `MINIO_SERVER_URL` and `MINIO_BROWSER_REDIRECT_URL` to HTTPS
### **4. Access Policies**
```bash
# Create read-only policy
mc admin policy create myminio readonly-policy readonly-policy.json
# Assign policy to user
mc admin policy attach myminio readonly-policy --user=username
```
### **5. Bucket Policies**
```bash
# Set bucket policy (public read)
mc anonymous set download myminio/public-bucket
# Set bucket policy (private)
mc anonymous set none myminio/private-bucket
```
## 📊 Monitoring
### **Health Check**
```bash
# Check MinIO health
curl -k https://ai.sriphat.com/minio/health/live
# Check from container
docker exec minio curl -f http://localhost:9000/minio/health/live
```
### **Logs**
```bash
# View logs
docker logs minio -f
# View last 100 lines
docker logs minio --tail 100
# Export logs
docker logs minio > minio.log
```
### **Metrics**
```bash
# View server info
mc admin info myminio
# View server stats
mc admin prometheus metrics myminio
```
### **Disk Usage**
```bash
# Check disk usage
mc admin info myminio
# Check bucket size
mc du myminio/my-bucket
```
## 🐛 Troubleshooting
### **Issue: Cannot access MinIO Console**
**Check:**
```bash
# Verify container is running
docker ps | grep minio
# Check logs
docker logs minio
# Test direct access
curl http://192.168.100.9:9001
```
**Solution:**
- Ensure container is running: `docker compose up -d`
- Check firewall rules
- Verify Nginx configuration
### **Issue: SSO login not working**
**Check:**
```bash
# Verify Keycloak config
docker exec minio printenv | grep MINIO_IDENTITY_OPENID
# Test Keycloak connectivity
docker exec minio curl -k https://ai.sriphat.com/keycloak/realms/sriphat/.well-known/openid-configuration
```
**Solution:**
- Verify all Keycloak environment variables are set
- Check client secret is correct
- Ensure redirect URIs match in Keycloak
- See `KEYCLOAK_INTEGRATION.md` for detailed troubleshooting
### **Issue: Upload fails**
**Check:**
```bash
# Check disk space
df -h
# Check permissions
ls -la data/
```
**Solution:**
- Ensure sufficient disk space
- Check directory permissions: `chmod 755 data/`
- Increase `client_max_body_size` in Nginx
### **Issue: S3 API connection refused**
**Check:**
```bash
# Test API endpoint
curl -k https://ai.sriphat.com/minio/
# Test direct connection
curl http://192.168.100.9:9000/
```
**Solution:**
- Verify `MINIO_SERVER_URL` is set correctly
- Check Nginx proxy configuration
- Ensure port 9000 is accessible
## 🔄 Maintenance
### **Backup**
```bash
# Backup data directory
tar -czf minio-backup-$(date +%Y%m%d).tar.gz data/
# Backup to remote location
rsync -avz data/ user@backup-server:/backups/minio/
```
### **Update MinIO**
```bash
# Pull latest image
docker compose pull
# Restart with new image
docker compose up -d
# Verify version
docker exec minio minio --version
```
### **Restore**
```bash
# Stop MinIO
docker compose down
# Restore data
tar -xzf minio-backup-20260325.tar.gz
# Start MinIO
docker compose up -d
```
## 📚 Documentation
- **MinIO Official Docs**: https://min.io/docs/minio/linux/
- **S3 API Reference**: https://docs.aws.amazon.com/AmazonS3/latest/API/
- **Keycloak Integration**: See `KEYCLOAK_INTEGRATION.md`
- **Nginx Configuration**: See `nginx-minio.conf`
## 🎯 Use Cases
### **1. Data Lake Storage**
- Store raw data files (CSV, JSON, Parquet)
- Integrate with Spark, Pandas, Dask
- Version control for datasets
### **2. Backup Storage**
- Database backups
- Application backups
- Log archival
### **3. Media Storage**
- Images, videos, documents
- CDN integration
- Static website hosting
### **4. ML/AI Workflows**
- Model storage
- Training data storage
- Experiment artifacts
### **5. Application Storage**
- User uploads
- Generated reports
- Temporary files
## 🎉 Summary
**What You Have:**
- ✅ MinIO object storage service
- ✅ Persistent storage with volume mounts
- ✅ HTTPS access via Nginx reverse proxy
- ✅ Keycloak SSO integration ready
- ✅ S3-compatible API
- ✅ Web console for management
- ✅ Health checks and monitoring
**Access:**
- Console: `https://ai.sriphat.com/minio-console`
- API: `https://ai.sriphat.com/minio`
**Next Steps:**
1. Configure `.env` file
2. Start MinIO: `docker compose up -d`
3. Setup Keycloak integration (optional)
4. Configure Nginx reverse proxy
5. Create buckets and start using!
For detailed Keycloak SSO setup, see `KEYCLOAK_INTEGRATION.md` 🚀

View File

@@ -0,0 +1,50 @@
version: '3.8'
services:
minio:
image: minio/minio:latest
container_name: minio
command: server /data --console-address ":9001"
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
environment:
# MinIO credentials
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
# Server settings
MINIO_SERVER_URL: ${MINIO_SERVER_URL:-https://ai.sriphat.com/minio}
MINIO_BROWSER_REDIRECT_URL: ${MINIO_BROWSER_REDIRECT_URL:-https://ai.sriphat.com/minio-console}
# Region
MINIO_REGION: ${MINIO_REGION:-ap-southeast-1}
# Identity OpenID (Keycloak)
MINIO_IDENTITY_OPENID_CONFIG_URL: ${MINIO_IDENTITY_OPENID_CONFIG_URL}
MINIO_IDENTITY_OPENID_CLIENT_ID: ${MINIO_IDENTITY_OPENID_CLIENT_ID}
MINIO_IDENTITY_OPENID_CLIENT_SECRET: ${MINIO_IDENTITY_OPENID_CLIENT_SECRET}
MINIO_IDENTITY_OPENID_CLAIM_NAME: ${MINIO_IDENTITY_OPENID_CLAIM_NAME:-policy}
MINIO_IDENTITY_OPENID_SCOPES: ${MINIO_IDENTITY_OPENID_SCOPES:-openid,profile,email}
MINIO_IDENTITY_OPENID_REDIRECT_URI: ${MINIO_IDENTITY_OPENID_REDIRECT_URI}
# Timezone
TZ: ${TZ:-Asia/Bangkok}
volumes:
# Persistent storage
- ./data:/data
# SSL certificates (if using direct HTTPS)
- ./certs:/root/.minio/certs:ro
networks:
- shared_data_network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
shared_data_network:
external: true

104
07-minio/nginx-minio.conf Normal file
View File

@@ -0,0 +1,104 @@
# MinIO Nginx Configuration
# For use with Nginx Proxy Manager or standalone Nginx
# This configuration provides HTTPS access to MinIO API and Console
# ============================================================================
# MinIO S3 API - Port 9000
# Subpath: /minio
# ============================================================================
location /minio/ {
# Rewrite path to remove /minio prefix
rewrite ^/minio/(.*) /$1 break;
# Forward to MinIO API
proxy_pass http://192.168.100.9:9000;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Required for MinIO
proxy_set_header X-NginX-Proxy true;
# Disable buffering for large uploads
proxy_buffering off;
proxy_request_buffering off;
# Timeouts for large file uploads/downloads
proxy_connect_timeout 300;
proxy_send_timeout 300;
proxy_read_timeout 300;
send_timeout 300;
# Max upload size (adjust as needed)
client_max_body_size 0;
}
# ============================================================================
# MinIO Console (Web UI) - Port 9001
# Subpath: /minio-console
# ============================================================================
location /minio-console/ {
# Forward to MinIO Console
proxy_pass http://192.168.100.9:9001/;
# Preserve headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket support for real-time updates
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Disable buffering
proxy_buffering off;
proxy_request_buffering off;
# Timeouts
proxy_connect_timeout 300;
proxy_send_timeout 300;
proxy_read_timeout 300;
}
# ============================================================================
# MinIO Console Assets
# ============================================================================
location /minio-console/assets/ {
proxy_pass http://192.168.100.9:9001/assets/;
proxy_set_header Host $host;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, immutable";
}
# ============================================================================
# MinIO Console API
# ============================================================================
location /minio-console/api/ {
proxy_pass http://192.168.100.9:9001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
}
# ============================================================================
# MinIO Health Check
# ============================================================================
location /minio/health {
proxy_pass http://192.168.100.9:9000/minio/health;
proxy_set_header Host $host;
access_log off;
}