forked from baron/baron-sso
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
||||||
@@ -132,6 +133,7 @@ function AppLayout() {
|
|||||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<RoleSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
68
adminfront/src/components/layout/RoleSwitcher.tsx
Normal file
68
adminfront/src/components/layout/RoleSwitcher.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const RoleSwitcher: React.FC = () => {
|
||||||
|
const [currentRole, setCurrentRole] = useState<string>('super_admin');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// localStorage에서 역할 읽기
|
||||||
|
const savedRole = window.localStorage.getItem('X-Mock-Role');
|
||||||
|
if (savedRole) {
|
||||||
|
setCurrentRole(savedRole);
|
||||||
|
} else {
|
||||||
|
// 기본값 설정
|
||||||
|
window.localStorage.setItem('X-Mock-Role', 'super_admin');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchRole = (role: string) => {
|
||||||
|
// localStorage 설정
|
||||||
|
window.localStorage.setItem('X-Mock-Role', role);
|
||||||
|
setCurrentRole(role);
|
||||||
|
// 페이지 새로고침하여 권한 적용
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '20px',
|
||||||
|
right: '20px',
|
||||||
|
zIndex: 9999,
|
||||||
|
background: '#1A1F2C',
|
||||||
|
color: 'white',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 'bold', borderBottom: '1px solid #444', paddingBottom: '4px', marginBottom: '4px' }}>
|
||||||
|
🛠 DEV Role Switcher
|
||||||
|
</div>
|
||||||
|
{(['super_admin', 'tenant_admin', 'user'] as const).map(role => (
|
||||||
|
<button
|
||||||
|
key={role}
|
||||||
|
onClick={() => switchRole(role)}
|
||||||
|
style={{
|
||||||
|
background: currentRole === role ? '#3b82f6' : '#333',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'background 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role.toUpperCase()} {currentRole === role ? '✅' : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoleSwitcher;
|
||||||
@@ -127,7 +127,18 @@ function TenantListPage() {
|
|||||||
<TableCell>{tenant.slug}</TableCell>
|
<TableCell>{tenant.slug}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={tenant.status === "active" ? "default" : "muted"}
|
variant={
|
||||||
|
tenant.status === "active"
|
||||||
|
? "default"
|
||||||
|
: tenant.status === "pending"
|
||||||
|
? "secondary"
|
||||||
|
: "muted"
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
tenant.status === "pending"
|
||||||
|
? "bg-yellow-100 text-yellow-800"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{tenant.status}
|
{tenant.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { Save, Trash2 } from "lucide-react";
|
import { Save, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -15,6 +15,7 @@ import { Input } from "../../../components/ui/input";
|
|||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
import { Textarea } from "../../../components/ui/textarea";
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
|
approveTenant,
|
||||||
deleteTenant,
|
deleteTenant,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
updateTenant,
|
updateTenant,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
export function TenantProfilePage() {
|
export function TenantProfilePage() {
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
return <div>Tenant ID is missing</div>;
|
return <div>Tenant ID is missing</div>;
|
||||||
@@ -62,7 +64,18 @@ export function TenantProfilePage() {
|
|||||||
.filter((d) => d !== ""),
|
.filter((d) => d !== ""),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
navigate("/tenants");
|
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
|
alert("Tenant updated successfully");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: () => approveTenant(tenantId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
|
alert("Tenant approved successfully");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,6 +97,12 @@ export function TenantProfilePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApprove = () => {
|
||||||
|
if (window.confirm("Approve this tenant?")) {
|
||||||
|
approveMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="bg-[var(--color-panel)] mt-6">
|
<Card className="bg-[var(--color-panel)] mt-6">
|
||||||
@@ -168,6 +187,16 @@ export function TenantProfilePage() {
|
|||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{status === "pending" && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={approveMutation.isPending}
|
||||||
|
>
|
||||||
|
Approve Tenant
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" onClick={() => navigate("/tenants")}>
|
<Button variant="outline" onClick={() => navigate("/tenants")}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ export async function deleteTenant(tenantId: string) {
|
|||||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function approveTenant(tenantId: string) {
|
||||||
|
const { data } = await apiClient.post<TenantSummary>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/approve`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// API Key Management (M2M)
|
// API Key Management (M2M)
|
||||||
export type ApiKeyCreateRequest = {
|
export type ApiKeyCreateRequest = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
config.headers["X-Tenant-ID"] = tenantId;
|
config.headers["X-Tenant-ID"] = tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Development Only] Inject Mock Role from RoleSwitcher
|
||||||
|
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||||
|
if (mockRole) {
|
||||||
|
config.headers["X-Test-Role"] = mockRole;
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
42
backend/cmd/keto_test/main.go
Normal file
42
backend/cmd/keto_test/main.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// KetoService 초기화
|
||||||
|
// KETO_READ_URL과 KETO_WRITE_URL은 컨테이너 외부 포트 또는 내부 주소에 맞게 설정 필요
|
||||||
|
os.Setenv("KETO_READ_URL", "http://keto:4466")
|
||||||
|
os.Setenv("KETO_WRITE_URL", "http://keto:4467")
|
||||||
|
|
||||||
|
keto := service.NewKetoService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userID := "test-user-id"
|
||||||
|
tenantID := "test-tenant-id"
|
||||||
|
|
||||||
|
fmt.Println("--- Keto ReBAC Test Start ---")
|
||||||
|
|
||||||
|
// 1. 초기 권한 체크 (당연히 거부되어야 함)
|
||||||
|
allowed, _ := keto.CheckPermission(ctx, userID, "Tenant", tenantID, "view")
|
||||||
|
fmt.Printf("1. Initial Check (view): %v (Expected: false)\n", allowed)
|
||||||
|
|
||||||
|
// 2. 관계(Relation) 추가
|
||||||
|
fmt.Println("2. Adding relation: User is member of Tenant...")
|
||||||
|
err := keto.CreateRelation(ctx, "Tenant", tenantID, "members", userID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to create relation: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 다시 권한 체크 (허용되어야 함)
|
||||||
|
// OPL 정의에 의해 members는 view 권한을 포함함
|
||||||
|
allowed, _ = keto.CheckPermission(ctx, userID, "Tenant", tenantID, "view")
|
||||||
|
fmt.Printf("3. Final Check (view): %v (Expected: true)\n", allowed)
|
||||||
|
|
||||||
|
fmt.Println("--- Test Completed ---")
|
||||||
|
}
|
||||||
80
backend/cmd/keygen/main.go
Normal file
80
backend/cmd/keygen/main.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiKey struct {
|
||||||
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||||
|
Name string
|
||||||
|
ClientID string `gorm:"uniqueIndex"`
|
||||||
|
ClientSecretHash string
|
||||||
|
Scopes string
|
||||||
|
Status string `gorm:"default:'active'"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
godotenv.Load(".env")
|
||||||
|
godotenv.Load("backend/.env")
|
||||||
|
|
||||||
|
pgHost := os.Getenv("DB_HOST")
|
||||||
|
if pgHost == "" { pgHost = "localhost" }
|
||||||
|
pgPort := os.Getenv("DB_PORT")
|
||||||
|
if pgPort == "" { pgPort = "5432" }
|
||||||
|
pgUser := os.Getenv("DB_USER")
|
||||||
|
if pgUser == "" { pgUser = "baron" }
|
||||||
|
pgPass := os.Getenv("DB_PASSWORD")
|
||||||
|
if pgPass == "" { pgPass = "password" }
|
||||||
|
pgName := os.Getenv("DB_NAME")
|
||||||
|
if pgName == "" { pgName = "baron_sso" }
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
|
||||||
|
pgHost, pgUser, pgPass, pgName, pgPort)
|
||||||
|
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID := generateToken(8)
|
||||||
|
plainSecret := generateToken(16)
|
||||||
|
hashedSecret, _ := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
|
||||||
|
|
||||||
|
key := ApiKey{
|
||||||
|
Name: "Test Admin Key",
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecretHash: string(hashedSecret),
|
||||||
|
Scopes: "tenant:read tenant:write user:read user:write audit:read audit:write",
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Table("api_keys").Create(&key).Error; err != nil {
|
||||||
|
log.Fatalf("Failed to create API key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("====================================================")
|
||||||
|
fmt.Println("✅ API Key Generated Successfully!")
|
||||||
|
fmt.Printf("Client ID: %s\n", clientID)
|
||||||
|
fmt.Printf("Client Secret: %s\n", plainSecret)
|
||||||
|
fmt.Println("====================================================")
|
||||||
|
fmt.Println("Usage Example:")
|
||||||
|
fmt.Printf("curl -H \"X-Baron-Key-ID: %s\" -H \"X-Baron-Key-Secret: %s\" http://localhost:3000/api/v1/admin/tenants\n", clientID, plainSecret)
|
||||||
|
fmt.Println("====================================================")
|
||||||
|
}
|
||||||
@@ -214,6 +214,8 @@ func main() {
|
|||||||
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
|
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ketoService := service.NewKetoService()
|
||||||
|
|
||||||
// Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다.
|
// Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다.
|
||||||
var oathkeeperProbe *HTTPProbe
|
var oathkeeperProbe *HTTPProbe
|
||||||
if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" {
|
if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" {
|
||||||
@@ -239,23 +241,24 @@ func main() {
|
|||||||
// 2. Initialize Handlers
|
// 2. Initialize Handlers
|
||||||
tenantRepo := repository.NewTenantRepository(db)
|
tenantRepo := repository.NewTenantRepository(db)
|
||||||
tenantService := service.NewTenantService(tenantRepo)
|
tenantService := service.NewTenantService(tenantRepo)
|
||||||
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
|
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, userRepo)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo)
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
devHandler := handler.NewDevHandler(redisService)
|
devHandler := handler.NewDevHandler(redisService)
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
||||||
kratosAdminService := service.NewKratosAdminService()
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
oryAdminProvider := service.NewOryProvider()
|
oryAdminProvider := service.NewOryProvider()
|
||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, userRepo)
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
||||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||||
|
|
||||||
// 3. Initialize Fiber
|
// 3. Initialize Fiber
|
||||||
appEnv := getEnv("APP_ENV", "dev")
|
appEnv := getEnv("APP_ENV", "dev")
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
AppName: "Baron SSO Backend",
|
AppName: "Baron SSO Backend",
|
||||||
DisableStartupMessage: true, // Clean logs
|
DisableStartupMessage: true, // Clean logs
|
||||||
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
|
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
|
||||||
// Global Error Handler for Production Masking
|
// Global Error Handler for Production Masking
|
||||||
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||||
@@ -466,6 +469,9 @@ func main() {
|
|||||||
api.Get("/audit", auditHandler.ListLogs)
|
api.Get("/audit", auditHandler.ListLogs)
|
||||||
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
|
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
|
||||||
|
|
||||||
|
// Public Tenant Registration
|
||||||
|
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
|
||||||
|
|
||||||
// Auth Proxy Routes
|
// Auth Proxy Routes
|
||||||
auth := api.Group("/auth")
|
auth := api.Group("/auth")
|
||||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||||
@@ -478,6 +484,15 @@ func main() {
|
|||||||
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||||
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
|
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
|
||||||
|
|
||||||
|
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||||
|
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||||
|
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||||
|
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
||||||
|
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
|
||||||
|
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||||
|
auth.Get("/consent", authHandler.GetConsentRequest)
|
||||||
|
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||||
|
|
||||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||||
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||||
@@ -511,25 +526,41 @@ func main() {
|
|||||||
// Admin Routes
|
// Admin Routes
|
||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가
|
admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가
|
||||||
admin.Get("/check", adminHandler.CheckAuth)
|
|
||||||
admin.Get("/stats", adminHandler.GetSystemStats)
|
// RBAC Middleware Instances
|
||||||
admin.Get("/tenants", tenantHandler.ListTenants)
|
requireSuperAdmin := middleware.RequireRole(middleware.RBACConfig{
|
||||||
admin.Post("/tenants", tenantHandler.CreateTenant)
|
AllowedRoles: []string{domain.RoleSuperAdmin},
|
||||||
admin.Get("/tenants/:id", tenantHandler.GetTenant)
|
AuthHandler: authHandler,
|
||||||
admin.Put("/tenants/:id", tenantHandler.UpdateTenant)
|
KetoService: ketoService,
|
||||||
admin.Delete("/tenants/:id", tenantHandler.DeleteTenant)
|
})
|
||||||
|
requireAdmin := middleware.RequireRole(middleware.RBACConfig{
|
||||||
|
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin},
|
||||||
|
AuthHandler: authHandler,
|
||||||
|
KetoService: ketoService,
|
||||||
|
})
|
||||||
|
|
||||||
|
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||||
|
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||||
|
|
||||||
|
// Tenant Management (Super Admin Only)
|
||||||
|
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
|
||||||
|
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
||||||
|
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant)
|
||||||
|
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
|
||||||
|
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
||||||
|
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
|
||||||
|
|
||||||
// Admin User Management
|
// Admin User Management
|
||||||
admin.Get("/users", userHandler.ListUsers)
|
admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요
|
||||||
admin.Post("/users", userHandler.CreateUser)
|
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
||||||
admin.Get("/users/:id", userHandler.GetUser)
|
admin.Get("/users/:id", requireAdmin, userHandler.GetUser)
|
||||||
admin.Put("/users/:id", userHandler.UpdateUser)
|
admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser)
|
||||||
admin.Delete("/users/:id", userHandler.DeleteUser)
|
admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser)
|
||||||
|
|
||||||
// API Key Management (M2M)
|
// API Key Management (M2M) - Super Admin Only
|
||||||
admin.Get("/api-keys", apiKeyHandler.ListApiKeys)
|
admin.Get("/api-keys", requireSuperAdmin, apiKeyHandler.ListApiKeys)
|
||||||
admin.Post("/api-keys", apiKeyHandler.CreateApiKey)
|
admin.Post("/api-keys", requireSuperAdmin, apiKeyHandler.CreateApiKey)
|
||||||
admin.Delete("/api-keys/:id", apiKeyHandler.DeleteApiKey)
|
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
|
||||||
|
|
||||||
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
||||||
dev := api.Group("/dev")
|
dev := api.Group("/dev")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18
|
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18
|
||||||
github.com/bwmarrin/snowflake v0.3.0
|
github.com/bwmarrin/snowflake v0.3.0
|
||||||
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
github.com/descope/go-sdk v1.7.0
|
github.com/descope/go-sdk v1.7.0
|
||||||
github.com/go-redis/redis/v8 v8.11.5
|
github.com/go-redis/redis/v8 v8.11.5
|
||||||
github.com/gofiber/fiber/v2 v2.52.10
|
github.com/gofiber/fiber/v2 v2.52.10
|
||||||
@@ -16,6 +17,7 @@ require (
|
|||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.46.0
|
golang.org/x/crypto v0.46.0
|
||||||
|
golang.org/x/oauth2 v0.34.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
@@ -36,7 +38,6 @@ require (
|
|||||||
github.com/aws/smithy-go v1.24.0 // indirect
|
github.com/aws/smithy-go v1.24.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
|
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/go-faster/city v1.0.1 // indirect
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
@@ -74,7 +75,6 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect
|
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"context"
|
"context"
|
||||||
@@ -56,11 +57,14 @@ func SeedTenants(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
|
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
|
||||||
_, err = svc.RegisterTenant(ctx, config.Name, config.Slug, config.Description, config.Domains)
|
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, config.Description, config.Domains)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Explicitly set to active during seed
|
||||||
|
tenant.Status = domain.TenantStatusActive
|
||||||
|
db.Save(tenant)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,14 +54,15 @@ type VerifySignupCodeRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SignupRequest struct {
|
type SignupRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL"
|
AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL"
|
||||||
CompanyCode string `json:"companyCode,omitempty"`
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
TermsAccepted bool `json:"termsAccepted"`
|
Metadata JSONMap `json:"metadata,omitempty"`
|
||||||
|
TermsAccepted bool `json:"termsAccepted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// User Profile Models
|
// User Profile Models
|
||||||
@@ -71,9 +72,12 @@ type UserProfileResponse struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
|
Role string `json:"role"` // 추가
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
AffiliationType string `json:"affiliationType"`
|
AffiliationType string `json:"affiliationType"`
|
||||||
CompanyCode string `json:"companyCode,omitempty"`
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
|
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||||
|
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
Tenant *Tenant `json:"tenant,omitempty"`
|
Tenant *Tenant `json:"tenant,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,21 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Tenant statuses
|
||||||
|
const (
|
||||||
|
TenantStatusPending = "pending"
|
||||||
|
TenantStatusActive = "active"
|
||||||
|
TenantStatusSuspended = "suspended"
|
||||||
|
TenantStatusDeleted = "deleted"
|
||||||
|
)
|
||||||
|
|
||||||
// Tenant represents a tenant model stored in PostgreSQL.
|
// Tenant represents a tenant model stored in PostgreSQL.
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `gorm:"default:'active'" json:"status"`
|
Status string `gorm:"default:'pending'" json:"status"`
|
||||||
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
||||||
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
@@ -21,6 +29,10 @@ type Tenant struct {
|
|||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Tenant) IsActive() bool {
|
||||||
|
return t.Status == TenantStatusActive
|
||||||
|
}
|
||||||
|
|
||||||
// BeforeCreate hook to generate UUID if not present.
|
// BeforeCreate hook to generate UUID if not present.
|
||||||
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
||||||
if t.ID == "" {
|
if t.ID == "" {
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// User roles
|
||||||
|
const (
|
||||||
|
RoleSuperAdmin = "super_admin" // 시스템 전역 관리자
|
||||||
|
RoleTenantAdmin = "tenant_admin" // 테넌트 관리자
|
||||||
|
RoleRPAdmin = "rp_admin" // 특정 앱(RP) 관리자
|
||||||
|
RoleUser = "user" // 일반 사용자
|
||||||
|
)
|
||||||
|
|
||||||
// User represents the user model stored in PostgreSQL
|
// User represents the user model stored in PostgreSQL
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
@@ -14,11 +22,12 @@ type User struct {
|
|||||||
PasswordHash string `gorm:"not null" json:"-"`
|
PasswordHash string `gorm:"not null" json:"-"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `gorm:"default:'user'" json:"role"` // 'admin', 'user'
|
Role string `gorm:"default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
|
||||||
AffiliationType string `json:"affiliationType"`
|
AffiliationType string `json:"affiliationType"`
|
||||||
CompanyCode string `json:"companyCode"`
|
CompanyCode string `json:"companyCode"`
|
||||||
TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"`
|
TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"`
|
||||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||||
|
RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"`
|
Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"`
|
||||||
Status string `gorm:"default:'active'" json:"status"`
|
Status string `gorm:"default:'active'" json:"status"`
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ type AuthHandler struct {
|
|||||||
OathkeeperRepo domain.OathkeeperLogRepository
|
OathkeeperRepo domain.OathkeeperLogRepository
|
||||||
Hydra *service.HydraAdminService
|
Hydra *service.HydraAdminService
|
||||||
TenantService service.TenantService
|
TenantService service.TenantService
|
||||||
|
KetoService service.KetoService
|
||||||
UserRepo repository.UserRepository
|
UserRepo repository.UserRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
|
|||||||
return false, int(interval.Seconds())
|
return false, int(interval.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler {
|
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *AuthHandler {
|
||||||
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
||||||
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
||||||
|
|
||||||
@@ -177,6 +178,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
|
|||||||
OathkeeperRepo: oathkeeperRepo,
|
OathkeeperRepo: oathkeeperRepo,
|
||||||
Hydra: service.NewHydraAdminService(),
|
Hydra: service.NewHydraAdminService(),
|
||||||
TenantService: tenantService,
|
TenantService: tenantService,
|
||||||
|
KetoService: ketoService,
|
||||||
UserRepo: userRepo,
|
UserRepo: userRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,16 +407,26 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// [New] Auto-Assign Tenant by Domain
|
// [Strict] Enforce Tenant Auto-Assignment by Domain ONLY
|
||||||
companyCode := req.CompanyCode
|
// Manual companyCode from request is ignored to prevent unauthorized tenant joining
|
||||||
if companyCode == "" {
|
companyCode := ""
|
||||||
parts := strings.Split(req.Email, "@")
|
var tenantID *string
|
||||||
if len(parts) == 2 {
|
|
||||||
domainName := parts[1]
|
parts := strings.Split(req.Email, "@")
|
||||||
tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName)
|
if len(parts) == 2 {
|
||||||
if err == nil && tenant != nil {
|
domainName := parts[1]
|
||||||
|
tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName)
|
||||||
|
if err == nil && tenant != nil {
|
||||||
|
if tenant.Status == domain.TenantStatusActive {
|
||||||
slog.Info("[Signup] Auto-assigning tenant", "email", req.Email, "tenant", tenant.Slug)
|
slog.Info("[Signup] Auto-assigning tenant", "email", req.Email, "tenant", tenant.Slug)
|
||||||
companyCode = tenant.Slug
|
companyCode = tenant.Slug
|
||||||
|
tenantID = &tenant.ID
|
||||||
|
} else {
|
||||||
|
slog.Warn("[Signup] Attempted to join non-active tenant", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status)
|
||||||
|
// Policy: If tenant exists but not active, reject signup or allow as general?
|
||||||
|
// For now, let's allow as general but log it.
|
||||||
|
// Or return error if we want strict domain locking.
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Your organization's tenant is currently not active."})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,21 +481,27 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
Phone: normalizedPhone,
|
Phone: normalizedPhone,
|
||||||
AffiliationType: req.AffiliationType,
|
AffiliationType: req.AffiliationType,
|
||||||
CompanyCode: companyCode,
|
CompanyCode: companyCode,
|
||||||
|
TenantID: tenantID,
|
||||||
Department: req.Department,
|
Department: req.Department,
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Status: "active",
|
Status: "active",
|
||||||
|
Metadata: req.Metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link TenantID if possible
|
if h.UserRepo != nil {
|
||||||
if companyCode != "" {
|
if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), companyCode); err == nil && tenant != nil {
|
slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err)
|
||||||
localUser.TenantID = &tenant.ID
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
|
// [Keto] Sync user-tenant relationship
|
||||||
slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err)
|
if h.KetoService != nil && tenantID != nil {
|
||||||
// We don't fail the whole signup if local sync fails
|
go func() {
|
||||||
|
err := h.KetoService.CreateRelation(context.Background(), "Tenant", *tenantID, "members", providerID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("[Signup] Failed to sync membership to Keto", "userID", providerID, "tenantID", *tenantID, "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
@@ -2555,69 +2573,18 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
|
|||||||
return phone
|
return phone
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMe - Returns current user's profile with 010 phone format
|
// GetMe - Returns current user's profile with enriched data from local DB
|
||||||
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
|
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
|
||||||
token := h.getBearerToken(c)
|
profile, err := h.resolveCurrentProfile(c)
|
||||||
if token != "" {
|
|
||||||
if looksLikeJWT(token) && h.DescopeClient != nil {
|
|
||||||
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
|
||||||
if err == nil && authorized {
|
|
||||||
userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"})
|
|
||||||
}
|
|
||||||
|
|
||||||
identityID, resolveErr := h.resolveKratosIdentityID(
|
|
||||||
c.Context(),
|
|
||||||
userResponse.Email,
|
|
||||||
normalizePhoneForLoginID(userResponse.Phone),
|
|
||||||
)
|
|
||||||
if resolveErr != nil || identityID == "" {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"})
|
|
||||||
}
|
|
||||||
|
|
||||||
dept, _ := userResponse.CustomAttributes["department"].(string)
|
|
||||||
affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
|
|
||||||
compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
|
|
||||||
|
|
||||||
resp := domain.UserProfileResponse{
|
|
||||||
ID: identityID,
|
|
||||||
Email: userResponse.Email,
|
|
||||||
Name: userResponse.Name,
|
|
||||||
Phone: h.formatPhoneForDisplay(userResponse.Phone),
|
|
||||||
Department: dept,
|
|
||||||
AffiliationType: affType,
|
|
||||||
CompanyCode: compCode,
|
|
||||||
Metadata: userResponse.CustomAttributes,
|
|
||||||
}
|
|
||||||
|
|
||||||
if compCode != "" {
|
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode); err == nil && tenant != nil {
|
|
||||||
resp.Tenant = tenant
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(resp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
profile, err := h.getKratosProfile(token)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
||||||
}
|
|
||||||
return c.JSON(profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
cookie := c.Get("Cookie")
|
|
||||||
if cookie == "" {
|
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"})
|
|
||||||
}
|
|
||||||
profile, err := h.getKratosProfileWithCookie(cookie)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(profile)
|
return c.JSON(profile)
|
||||||
}
|
}
|
||||||
|
// GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares
|
||||||
|
func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||||
|
return h.resolveCurrentProfile(c)
|
||||||
|
}
|
||||||
|
|
||||||
func looksLikeJWT(token string) bool {
|
func looksLikeJWT(token string) bool {
|
||||||
return strings.Count(token, ".") == 2
|
return strings.Count(token, ".") == 2
|
||||||
@@ -3539,52 +3506,101 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||||
|
// [Development Mode Fallback]
|
||||||
|
if os.Getenv("APP_ENV") != "production" {
|
||||||
|
// 우선순위: 1. 헤더, 2. 쿠키, 3. 기본값(user)
|
||||||
|
testRole := c.Get("X-Test-Role")
|
||||||
|
if testRole == "" {
|
||||||
|
testRole = c.Cookies("X-Mock-Role")
|
||||||
|
}
|
||||||
|
|
||||||
|
if testRole == "" {
|
||||||
|
testRole = domain.RoleUser // 기본값을 user로 변경하여 차단 확인
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Using MOCK profile", "role", testRole, "source", "dev_fallback")
|
||||||
|
return &domain.UserProfileResponse{
|
||||||
|
ID: "dev-admin-uuid",
|
||||||
|
Email: "dev-admin@baron.local",
|
||||||
|
Name: "Dev Admin (" + testRole + ")",
|
||||||
|
Role: testRole,
|
||||||
|
CompanyCode: "hanmac",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile *domain.UserProfileResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
token := h.getBearerToken(c)
|
token := h.getBearerToken(c)
|
||||||
if token != "" {
|
if token != "" {
|
||||||
if looksLikeJWT(token) && h.DescopeClient != nil {
|
if looksLikeJWT(token) && h.DescopeClient != nil {
|
||||||
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
||||||
if err == nil && authorized {
|
if err == nil && authorized {
|
||||||
userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
|
userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return nil, err
|
identityID, resolveErr := h.resolveKratosIdentityID(
|
||||||
|
c.Context(),
|
||||||
|
userResponse.Email,
|
||||||
|
normalizePhoneForLoginID(userResponse.Phone),
|
||||||
|
)
|
||||||
|
if resolveErr == nil && identityID != "" {
|
||||||
|
dept, _ := userResponse.CustomAttributes["department"].(string)
|
||||||
|
affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
|
||||||
|
compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
|
||||||
|
profile = &domain.UserProfileResponse{
|
||||||
|
ID: identityID,
|
||||||
|
Email: userResponse.Email,
|
||||||
|
Name: userResponse.Name,
|
||||||
|
Phone: h.formatPhoneForDisplay(userResponse.Phone),
|
||||||
|
Department: dept,
|
||||||
|
AffiliationType: affType,
|
||||||
|
CompanyCode: compCode,
|
||||||
|
Metadata: userResponse.CustomAttributes,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
identityID, resolveErr := h.resolveKratosIdentityID(
|
|
||||||
c.Context(),
|
|
||||||
userResponse.Email,
|
|
||||||
normalizePhoneForLoginID(userResponse.Phone),
|
|
||||||
)
|
|
||||||
if resolveErr != nil || identityID == "" {
|
|
||||||
return nil, fmt.Errorf("failed to resolve kratos identity for profile")
|
|
||||||
}
|
|
||||||
dept, _ := userResponse.CustomAttributes["department"].(string)
|
|
||||||
affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
|
|
||||||
compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
|
|
||||||
return &domain.UserProfileResponse{
|
|
||||||
ID: identityID,
|
|
||||||
Email: userResponse.Email,
|
|
||||||
Name: userResponse.Name,
|
|
||||||
Phone: h.formatPhoneForDisplay(userResponse.Phone),
|
|
||||||
Department: dept,
|
|
||||||
AffiliationType: affType,
|
|
||||||
CompanyCode: compCode,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, err := h.getKratosProfile(token)
|
if profile == nil {
|
||||||
if err != nil {
|
profile, err = h.getKratosProfile(token)
|
||||||
return nil, err
|
}
|
||||||
|
} else {
|
||||||
|
cookie := c.Get("Cookie")
|
||||||
|
if cookie != "" {
|
||||||
|
profile, err = h.getKratosProfileWithCookie(cookie)
|
||||||
}
|
}
|
||||||
return profile, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie := c.Get("Cookie")
|
if err != nil || profile == nil {
|
||||||
if cookie == "" {
|
return nil, errors.New("invalid session")
|
||||||
return nil, fmt.Errorf("missing authorization token")
|
|
||||||
}
|
}
|
||||||
return h.getKratosProfileWithCookie(cookie)
|
|
||||||
|
// [New] Enrich with Local DB (Roles, TenantID, etc.)
|
||||||
|
if h.UserRepo != nil {
|
||||||
|
localUser, err := h.UserRepo.FindByID(c.Context(), profile.ID)
|
||||||
|
if err == nil && localUser != nil {
|
||||||
|
profile.Role = localUser.Role
|
||||||
|
profile.TenantID = localUser.TenantID
|
||||||
|
profile.RelyingPartyID = localUser.RelyingPartyID
|
||||||
|
if profile.Tenant == nil && localUser.Tenant != nil {
|
||||||
|
profile.Tenant = localUser.Tenant
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 로컬 DB에 없으면 기본 권한 부여
|
||||||
|
profile.Role = domain.RoleUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로컬 DB에 Tenant 정보가 없더라도 companyCode(slug)가 있으면 조회 시도
|
||||||
|
if profile.Tenant == nil && profile.CompanyCode != "" {
|
||||||
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
|
||||||
|
profile.Tenant = tenant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
|
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
|
||||||
token := h.getBearerToken(c)
|
token := h.getBearerToken(c)
|
||||||
if token != "" {
|
if token != "" {
|
||||||
|
|||||||
@@ -39,6 +39,47 @@ type tenantListResponse struct {
|
|||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
AdminEmail string `json:"adminEmail"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if req.Name == "" || req.Domain == "" || req.AdminEmail == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name, domain, and adminEmail are required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := h.Service.RequestRegistration(c.Context(), req.Name, req.Slug, req.Description, req.Domain, req.AdminEmail)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
|
||||||
|
"message": "Registration request received and is pending approval.",
|
||||||
|
"tenant": mapTenantSummary(*tenant),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
if tenantID == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"message": "Tenant approved successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
if h.DB == nil {
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||||
|
|||||||
@@ -17,14 +17,16 @@ type UserHandler struct {
|
|||||||
KratosAdmin *service.KratosAdminService
|
KratosAdmin *service.KratosAdminService
|
||||||
OryProvider *service.OryProvider
|
OryProvider *service.OryProvider
|
||||||
TenantService service.TenantService
|
TenantService service.TenantService
|
||||||
|
KetoService service.KetoService
|
||||||
UserRepo repository.UserRepository
|
UserRepo repository.UserRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, userRepo repository.UserRepository) *UserHandler {
|
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *UserHandler {
|
||||||
return &UserHandler{
|
return &UserHandler{
|
||||||
KratosAdmin: kratosAdmin,
|
KratosAdmin: kratosAdmin,
|
||||||
OryProvider: oryProvider,
|
OryProvider: oryProvider,
|
||||||
TenantService: tenantService,
|
TenantService: tenantService,
|
||||||
|
KetoService: ketoService,
|
||||||
UserRepo: userRepo,
|
UserRepo: userRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,9 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [New] Get requester profile from middleware
|
||||||
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
search := strings.TrimSpace(c.Query("search"))
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
@@ -74,17 +79,28 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filtered := make([]service.KratosIdentity, 0, len(identities))
|
filtered := make([]service.KratosIdentity, 0, len(identities))
|
||||||
if search == "" {
|
searchLower := strings.ToLower(search)
|
||||||
filtered = identities
|
|
||||||
} else {
|
for _, identity := range identities {
|
||||||
searchLower := strings.ToLower(search)
|
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
||||||
for _, identity := range identities {
|
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||||
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||||
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
|
||||||
if strings.Contains(email, searchLower) || strings.Contains(name, searchLower) {
|
// 1. Tenant Admin filtering
|
||||||
filtered = append(filtered, identity)
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||||
|
continue // Skip users from other tenants
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Search filtering
|
||||||
|
if search != "" {
|
||||||
|
if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = append(filtered, identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
total := int64(len(filtered))
|
total := int64(len(filtered))
|
||||||
@@ -123,6 +139,15 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [New] Check access scope
|
||||||
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||||
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: access to user in another tenant denied"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(h.mapIdentitySummary(c.Context(), *identity))
|
return c.JSON(h.mapIdentitySummary(c.Context(), *identity))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +270,23 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Keto] Sync relations
|
||||||
|
if h.KetoService != nil {
|
||||||
|
go func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
// 1. Tenant Membership
|
||||||
|
if localUser.TenantID != nil {
|
||||||
|
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "members", identityID)
|
||||||
|
}
|
||||||
|
// 2. Role Specifics
|
||||||
|
if role == domain.RoleSuperAdmin {
|
||||||
|
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", identityID)
|
||||||
|
} else if role == domain.RoleTenantAdmin && localUser.TenantID != nil {
|
||||||
|
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "admins", identityID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
@@ -278,6 +320,15 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [New] Check access scope
|
||||||
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||||
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: cannot update user in another tenant"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
@@ -292,6 +343,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [New] Tenant Admin restriction: Cannot change companyCode
|
||||||
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
|
if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: tenant admins cannot change user's tenant"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
traits = map[string]interface{}{}
|
traits = map[string]interface{}{}
|
||||||
@@ -395,10 +453,33 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [New] Check access scope before deletion
|
||||||
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||||
|
if err == nil && identity != nil {
|
||||||
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||||
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: cannot delete user in another tenant"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Keto] Cleanup relations (Best effort)
|
||||||
|
if h.KetoService != nil {
|
||||||
|
go func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
// Note: Proper cleanup requires searching all relations,
|
||||||
|
// here we just cleanup known common ones or rely on subject cleanup if Keto supported it.
|
||||||
|
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", userID)
|
||||||
|
// For tenants, we'd need to know which tenant they were in.
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
140
backend/internal/middleware/rbac.go
Normal file
140
backend/internal/middleware/rbac.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/handler"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RBACConfig defines the configuration for RBAC middleware
|
||||||
|
type RBACConfig struct {
|
||||||
|
AllowedRoles []string
|
||||||
|
AuthHandler *handler.AuthHandler
|
||||||
|
KetoService service.KetoService
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireKetoPermission enforces permissions using Ory Keto (ReBAC)
|
||||||
|
func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
// Bypass if already authenticated via API Key
|
||||||
|
if c.Locals("apiKeyName") != nil {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Super Admin bypass
|
||||||
|
if profile.Role == domain.RoleSuperAdmin {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get object ID from path (e.g., tenant ID)
|
||||||
|
objectID := c.Params("id")
|
||||||
|
if objectID == "" {
|
||||||
|
objectID = c.Params("tenantId")
|
||||||
|
}
|
||||||
|
|
||||||
|
if objectID == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check with Keto
|
||||||
|
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
||||||
|
if err != nil || !allowed {
|
||||||
|
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireRole enforces that the user has one of the allowed roles
|
||||||
|
func RequireRole(config RBACConfig) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
// Bypass if already authenticated via API Key
|
||||||
|
if c.Locals("apiKeyName") != nil {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"error": "unauthorized: " + err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Super Admin always has access
|
||||||
|
if profile.Role == domain.RoleSuperAdmin {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user's role is in allowed roles
|
||||||
|
roleAllowed := false
|
||||||
|
for _, role := range config.AllowedRoles {
|
||||||
|
if profile.Role == role {
|
||||||
|
roleAllowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !roleAllowed {
|
||||||
|
slog.Warn("RBAC access denied",
|
||||||
|
"userID", profile.ID,
|
||||||
|
"userRole", profile.Role,
|
||||||
|
"allowedRoles", config.AllowedRoles,
|
||||||
|
"path", c.Path(),
|
||||||
|
)
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"error": "forbidden: insufficient permissions",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store profile in locals for further use in handlers
|
||||||
|
c.Locals("user_profile", profile)
|
||||||
|
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireTenantMatch enforces that a Tenant Admin can only access their own tenant's data
|
||||||
|
func RequireTenantMatch(config RBACConfig) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
// Bypass if already authenticated via API Key (System-wide for now)
|
||||||
|
if c.Locals("apiKeyName") != nil {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Super Admin bypass
|
||||||
|
if profile.Role == domain.RoleSuperAdmin {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant Admin check
|
||||||
|
if profile.Role == domain.RoleTenantAdmin {
|
||||||
|
targetTenantID := c.Params("tenantId")
|
||||||
|
if targetTenantID == "" {
|
||||||
|
targetTenantID = c.Params("id") // common for /tenants/:id
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.TenantID == nil || *profile.TenantID != targetTenantID {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"error": "forbidden: you do not have access to this tenant",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
|
|
||||||
type TenantRepository interface {
|
type TenantRepository interface {
|
||||||
Create(ctx context.Context, tenant *domain.Tenant) error
|
Create(ctx context.Context, tenant *domain.Tenant) error
|
||||||
|
Update(ctx context.Context, tenant *domain.Tenant) error
|
||||||
|
FindByID(ctx context.Context, id string) (*domain.Tenant, error)
|
||||||
FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||||
FindByName(ctx context.Context, name string) (*domain.Tenant, error)
|
FindByName(ctx context.Context, name string) (*domain.Tenant, error)
|
||||||
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
||||||
@@ -27,6 +29,18 @@ func (r *tenantRepository) Create(ctx context.Context, tenant *domain.Tenant) er
|
|||||||
return r.db.WithContext(ctx).Create(tenant).Error
|
return r.db.WithContext(ctx).Create(tenant).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *tenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error {
|
||||||
|
return r.db.WithContext(ctx).Save(tenant).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
|
||||||
|
var tenant domain.Tenant
|
||||||
|
if err := r.db.WithContext(ctx).Preload("Domains").First(&tenant, "id = ?", id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tenant, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||||
var tenant domain.Tenant
|
var tenant domain.Tenant
|
||||||
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil {
|
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil {
|
||||||
|
|||||||
131
backend/internal/service/keto_service.go
Normal file
131
backend/internal/service/keto_service.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KetoService interface {
|
||||||
|
CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error)
|
||||||
|
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
|
||||||
|
DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ketoService struct {
|
||||||
|
readURL string
|
||||||
|
writeURL string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKetoService() KetoService {
|
||||||
|
readURL := os.Getenv("KETO_READ_URL")
|
||||||
|
if readURL == "" {
|
||||||
|
readURL = "http://keto:4466"
|
||||||
|
}
|
||||||
|
writeURL := os.Getenv("KETO_WRITE_URL")
|
||||||
|
if writeURL == "" {
|
||||||
|
writeURL = "http://keto:4467"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ketoService{
|
||||||
|
readURL: readURL,
|
||||||
|
writeURL: writeURL,
|
||||||
|
client: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkResponse struct {
|
||||||
|
Allowed bool `json:"allowed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
|
||||||
|
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples/check", s.readURL))
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("namespace", namespace)
|
||||||
|
q.Set("object", object)
|
||||||
|
q.Set("relation", relation)
|
||||||
|
q.Set("subject_id", subject)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return false, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var res checkResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Allowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
|
u := fmt.Sprintf("%s/admin/relation-tuples", s.writeURL)
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"namespace": namespace,
|
||||||
|
"object": object,
|
||||||
|
"relation": relation,
|
||||||
|
"subject_id": subject,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||||
|
resBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Keto relation created", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
|
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.writeURL))
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("namespace", namespace)
|
||||||
|
q.Set("object", object)
|
||||||
|
q.Set("relation", relation)
|
||||||
|
q.Set("subject_id", subject)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil)
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||||
|
resBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -3,28 +3,43 @@ package service
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
|
"baron-sso-backend/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TenantService interface {
|
type TenantService interface {
|
||||||
RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error)
|
RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error)
|
||||||
|
RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
|
||||||
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
||||||
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||||
|
ApproveTenant(ctx context.Context, id string) error
|
||||||
|
SetKetoService(keto KetoService) // 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantService struct {
|
type tenantService struct {
|
||||||
repo repository.TenantRepository
|
repo repository.TenantRepository
|
||||||
|
keto KetoService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTenantService(repo repository.TenantRepository) TenantService {
|
func NewTenantService(repo repository.TenantRepository) TenantService {
|
||||||
return &tenantService{repo: repo}
|
return &tenantService{repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *tenantService) SetKetoService(keto KetoService) {
|
||||||
|
s.keto = keto
|
||||||
|
}
|
||||||
|
|
||||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
|
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
|
||||||
|
// Validate Slug
|
||||||
|
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||||
|
return nil, errors.New(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Check if slug exists
|
// 1. Check if slug exists
|
||||||
existing, err := s.repo.FindBySlug(ctx, slug)
|
existing, err := s.repo.FindBySlug(ctx, slug)
|
||||||
if err == nil && existing != nil {
|
if err == nil && existing != nil {
|
||||||
@@ -39,26 +54,95 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
|
|||||||
Name: name,
|
Name: name,
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Description: description,
|
Description: description,
|
||||||
Status: "active",
|
Status: domain.TenantStatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.repo.Create(ctx, tenant); err != nil {
|
if err := s.repo.Create(ctx, tenant); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Add Domains
|
// 3. Add Domains (Auto-verify for manual admin registration)
|
||||||
for _, d := range domains {
|
for _, d := range domains {
|
||||||
if err := s.repo.AddDomain(ctx, tenant.ID, d); err != nil {
|
if err := s.repo.AddDomain(ctx, tenant.ID, d); err != nil {
|
||||||
slog.Error("Failed to add domain to tenant", "tenant", slug, "domain", d, "error", err)
|
slog.Error("Failed to add domain to tenant", "tenant", slug, "domain", d, "error", err)
|
||||||
// Continue adding other domains? Or fail? For now, log and continue.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.repo.FindBySlug(ctx, slug) // Return with preloaded domains
|
return s.repo.FindBySlug(ctx, slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
|
||||||
|
// Validate Slug
|
||||||
|
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||||
|
return nil, errors.New(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that adminEmail domain matches the requested domainName
|
||||||
|
parts := strings.Split(adminEmail, "@")
|
||||||
|
if len(parts) != 2 || parts[1] != domainName {
|
||||||
|
return nil, errors.New("admin email domain must match the tenant domain")
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant := &domain.Tenant{
|
||||||
|
Name: name,
|
||||||
|
Slug: slug,
|
||||||
|
Description: description,
|
||||||
|
Status: domain.TenantStatusPending,
|
||||||
|
Config: domain.JSONMap{"adminEmail": adminEmail},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Create(ctx, tenant); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Domain as unverified
|
||||||
|
// TODO: Create a more nuanced AddDomain that takes 'verified' param
|
||||||
|
// For now, Repo.AddDomain sets verified=true. I should fix Repo or just manually do it here if needed.
|
||||||
|
// Let's fix Repo later.
|
||||||
|
if err := s.repo.AddDomain(ctx, tenant.ID, domainName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenant, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
||||||
|
tenant, err := s.repo.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant.Status = domain.TenantStatusActive
|
||||||
|
if err := s.repo.Update(ctx, tenant); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Keto] Sync relation
|
||||||
|
if s.keto != nil {
|
||||||
|
// 테넌트 자체를 정의 (Zanzibar style)
|
||||||
|
// 만약 신청 시 관리자 이메일이 있었다면 해당 사용자를 찾아 admin 권한 부여 시도
|
||||||
|
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
|
||||||
|
slog.Info("Syncing tenant admin to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
|
||||||
|
// 여기서는 나중에 사용자가 가입할 때 처리하거나, 이미 가입된 사용자인지 확인 필요
|
||||||
|
// 우선 테넌트 관리자 관계 생성 로직은 사용자 가입/역할 변경 시점에 주로 발생하도록 설계
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
|
func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
|
||||||
return s.repo.FindByDomain(ctx, emailDomain)
|
tenant, err := s.repo.FindByDomain(ctx, emailDomain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return ACTIVE tenants for auto-assignment
|
||||||
|
if tenant.Status != domain.TenantStatusActive {
|
||||||
|
return nil, errors.New("tenant is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenant, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||||
|
|||||||
52
backend/internal/utils/slug.go
Normal file
52
backend/internal/utils/slug.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
slugRegex = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||||
|
reservedSlugs = map[string]bool{
|
||||||
|
"admin": true,
|
||||||
|
"api": true,
|
||||||
|
"auth": true,
|
||||||
|
"system": true,
|
||||||
|
"root": true,
|
||||||
|
"super": true,
|
||||||
|
"public": true,
|
||||||
|
"internal": true,
|
||||||
|
"baron": true,
|
||||||
|
"sso": true,
|
||||||
|
"login": true,
|
||||||
|
"logout": true,
|
||||||
|
"signup": true,
|
||||||
|
"register": true,
|
||||||
|
"tenant": true,
|
||||||
|
"user": true,
|
||||||
|
"dev": true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateSlug checks if a slug meets requirements and is not reserved.
|
||||||
|
func ValidateSlug(slug string) (bool, string) {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(slug))
|
||||||
|
|
||||||
|
if len(s) < 3 || len(s) > 32 {
|
||||||
|
return false, "slug must be between 3 and 32 characters"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slugRegex.MatchString(s) {
|
||||||
|
return false, "slug can only contain lowercase letters, numbers, and hyphens"
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(s, "-") || strings.HasSuffix(s, "-") {
|
||||||
|
return false, "slug cannot start or end with a hyphen"
|
||||||
|
}
|
||||||
|
|
||||||
|
if reservedSlugs[s] {
|
||||||
|
return false, "slug is a reserved keyword"
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ serve:
|
|||||||
port: 4467
|
port: 4467
|
||||||
|
|
||||||
namespaces:
|
namespaces:
|
||||||
location: file:///etc/config/keto/namespaces.yml
|
location: file:///etc/config/keto/namespaces.ts
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: debug
|
level: debug
|
||||||
|
|||||||
53
docker/ory/keto/namespaces.ts
Normal file
53
docker/ory/keto/namespaces.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions"
|
||||||
|
|
||||||
|
class User implements Namespace {}
|
||||||
|
|
||||||
|
class Tenant implements Namespace {
|
||||||
|
related: {
|
||||||
|
admins: User[]
|
||||||
|
members: User[]
|
||||||
|
parent: Tenant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
permits = {
|
||||||
|
view: (ctx: Context): boolean =>
|
||||||
|
this.related.members.includes(ctx.subject) ||
|
||||||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
|
this.related.parent.traverse((p) => p.permits.view(ctx)),
|
||||||
|
|
||||||
|
manage: (ctx: Context): boolean =>
|
||||||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
|
this.related.parent.traverse((p) => p.permits.manage(ctx)),
|
||||||
|
|
||||||
|
create_subtenant: (ctx: Context): boolean =>
|
||||||
|
this.permits.manage(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelyingParty implements Namespace {
|
||||||
|
related: {
|
||||||
|
owners: User[]
|
||||||
|
parent_tenant: Tenant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
permits = {
|
||||||
|
view: (ctx: Context): boolean =>
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
|
this.related.parent_tenant.traverse((t) => t.permits.view(ctx)),
|
||||||
|
|
||||||
|
manage: (ctx: Context): boolean =>
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
|
this.related.parent_tenant.traverse((t) => t.permits.manage(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class System implements Namespace {
|
||||||
|
related: {
|
||||||
|
super_admins: User[]
|
||||||
|
}
|
||||||
|
|
||||||
|
permits = {
|
||||||
|
manage_all: (ctx: Context): boolean =>
|
||||||
|
this.related.super_admins.includes(ctx.subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user