forked from baron/baron-sso
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
||||
@@ -132,6 +133,7 @@ function AppLayout() {
|
||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
</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>
|
||||
<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}
|
||||
</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 { Save, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -15,6 +15,7 @@ import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import {
|
||||
approveTenant,
|
||||
deleteTenant,
|
||||
fetchTenant,
|
||||
updateTenant,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
export function TenantProfilePage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!tenantId) {
|
||||
return <div>Tenant ID is missing</div>;
|
||||
@@ -62,7 +64,18 @@ export function TenantProfilePage() {
|
||||
.filter((d) => d !== ""),
|
||||
}),
|
||||
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 (
|
||||
<>
|
||||
<Card className="bg-[var(--color-panel)] mt-6">
|
||||
@@ -168,6 +187,16 @@ export function TenantProfilePage() {
|
||||
Delete
|
||||
</Button>
|
||||
<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")}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -132,6 +132,13 @@ export async function deleteTenant(tenantId: string) {
|
||||
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)
|
||||
export type ApiKeyCreateRequest = {
|
||||
name: string;
|
||||
|
||||
@@ -17,6 +17,12 @@ apiClient.interceptors.request.use((config) => {
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
ketoService := service.NewKetoService()
|
||||
|
||||
// Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다.
|
||||
var oathkeeperProbe *HTTPProbe
|
||||
if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" {
|
||||
@@ -239,23 +241,24 @@ func main() {
|
||||
// 2. Initialize Handlers
|
||||
tenantRepo := repository.NewTenantRepository(db)
|
||||
tenantService := service.NewTenantService(tenantRepo)
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
|
||||
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()
|
||||
devHandler := handler.NewDevHandler(redisService)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
oryAdminProvider := service.NewOryProvider()
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, userRepo)
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||
|
||||
// 3. Initialize Fiber
|
||||
appEnv := getEnv("APP_ENV", "dev")
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Baron SSO Backend",
|
||||
DisableStartupMessage: true, // Clean logs
|
||||
DisableStartupMessage: true, // Clean logs
|
||||
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
|
||||
// Global Error Handler for Production Masking
|
||||
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||
@@ -466,6 +469,9 @@ func main() {
|
||||
api.Get("/audit", auditHandler.ListLogs)
|
||||
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
|
||||
|
||||
// Public Tenant Registration
|
||||
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
|
||||
|
||||
// Auth Proxy Routes
|
||||
auth := api.Group("/auth")
|
||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||
@@ -478,6 +484,15 @@ func main() {
|
||||
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||
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)
|
||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||
@@ -511,25 +526,41 @@ func main() {
|
||||
// Admin Routes
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가
|
||||
admin.Get("/check", adminHandler.CheckAuth)
|
||||
admin.Get("/stats", adminHandler.GetSystemStats)
|
||||
admin.Get("/tenants", tenantHandler.ListTenants)
|
||||
admin.Post("/tenants", tenantHandler.CreateTenant)
|
||||
admin.Get("/tenants/:id", tenantHandler.GetTenant)
|
||||
admin.Put("/tenants/:id", tenantHandler.UpdateTenant)
|
||||
admin.Delete("/tenants/:id", tenantHandler.DeleteTenant)
|
||||
|
||||
// RBAC Middleware Instances
|
||||
requireSuperAdmin := middleware.RequireRole(middleware.RBACConfig{
|
||||
AllowedRoles: []string{domain.RoleSuperAdmin},
|
||||
AuthHandler: authHandler,
|
||||
KetoService: ketoService,
|
||||
})
|
||||
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.Get("/users", userHandler.ListUsers)
|
||||
admin.Post("/users", userHandler.CreateUser)
|
||||
admin.Get("/users/:id", userHandler.GetUser)
|
||||
admin.Put("/users/:id", userHandler.UpdateUser)
|
||||
admin.Delete("/users/:id", userHandler.DeleteUser)
|
||||
admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요
|
||||
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
||||
admin.Get("/users/:id", requireAdmin, userHandler.GetUser)
|
||||
admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser)
|
||||
admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser)
|
||||
|
||||
// API Key Management (M2M)
|
||||
admin.Get("/api-keys", apiKeyHandler.ListApiKeys)
|
||||
admin.Post("/api-keys", apiKeyHandler.CreateApiKey)
|
||||
admin.Delete("/api-keys/:id", apiKeyHandler.DeleteApiKey)
|
||||
// API Key Management (M2M) - Super Admin Only
|
||||
admin.Get("/api-keys", requireSuperAdmin, apiKeyHandler.ListApiKeys)
|
||||
admin.Post("/api-keys", requireSuperAdmin, apiKeyHandler.CreateApiKey)
|
||||
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
|
||||
|
||||
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
||||
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/service/ses v1.34.18
|
||||
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/go-redis/redis/v8 v8.11.5
|
||||
github.com/gofiber/fiber/v2 v2.52.10
|
||||
@@ -16,6 +17,7 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
@@ -36,7 +38,6 @@ require (
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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.yaml.in/yaml/v3 v3.0.4 // 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/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
@@ -56,11 +57,14 @@ func SeedTenants(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
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 {
|
||||
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
||||
return err
|
||||
}
|
||||
// Explicitly set to active during seed
|
||||
tenant.Status = domain.TenantStatusActive
|
||||
db.Save(tenant)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -54,14 +54,15 @@ type VerifySignupCodeRequest struct {
|
||||
}
|
||||
|
||||
type SignupRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL"
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
Department string `json:"department"`
|
||||
TermsAccepted bool `json:"termsAccepted"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL"
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
Department string `json:"department"`
|
||||
Metadata JSONMap `json:"metadata,omitempty"`
|
||||
TermsAccepted bool `json:"termsAccepted"`
|
||||
}
|
||||
|
||||
// User Profile Models
|
||||
@@ -71,9 +72,12 @@ type UserProfileResponse struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"` // 추가
|
||||
Department string `json:"department"`
|
||||
AffiliationType string `json:"affiliationType"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Tenant *Tenant `json:"tenant,omitempty"`
|
||||
}
|
||||
|
||||
@@ -7,13 +7,21 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Tenant statuses
|
||||
const (
|
||||
TenantStatusPending = "pending"
|
||||
TenantStatusActive = "active"
|
||||
TenantStatusSuspended = "suspended"
|
||||
TenantStatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
// Tenant represents a tenant model stored in PostgreSQL.
|
||||
type Tenant struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||
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"`
|
||||
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
@@ -21,6 +29,10 @@ type Tenant struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (t *Tenant) IsActive() bool {
|
||||
return t.Status == TenantStatusActive
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present.
|
||||
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if t.ID == "" {
|
||||
|
||||
@@ -7,6 +7,14 @@ import (
|
||||
"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
|
||||
type User struct {
|
||||
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:"-"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
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"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"`
|
||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||
RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||
Department string `json:"department"`
|
||||
Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"`
|
||||
Status string `gorm:"default:'active'" json:"status"`
|
||||
|
||||
@@ -91,6 +91,7 @@ type AuthHandler struct {
|
||||
OathkeeperRepo domain.OathkeeperLogRepository
|
||||
Hydra *service.HydraAdminService
|
||||
TenantService service.TenantService
|
||||
KetoService service.KetoService
|
||||
UserRepo repository.UserRepository
|
||||
}
|
||||
|
||||
@@ -149,7 +150,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
|
||||
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")
|
||||
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
||||
|
||||
@@ -177,6 +178,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
|
||||
OathkeeperRepo: oathkeeperRepo,
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
TenantService: tenantService,
|
||||
KetoService: ketoService,
|
||||
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"})
|
||||
}
|
||||
|
||||
// [New] Auto-Assign Tenant by Domain
|
||||
companyCode := req.CompanyCode
|
||||
if companyCode == "" {
|
||||
parts := strings.Split(req.Email, "@")
|
||||
if len(parts) == 2 {
|
||||
domainName := parts[1]
|
||||
tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName)
|
||||
if err == nil && tenant != nil {
|
||||
// [Strict] Enforce Tenant Auto-Assignment by Domain ONLY
|
||||
// Manual companyCode from request is ignored to prevent unauthorized tenant joining
|
||||
companyCode := ""
|
||||
var tenantID *string
|
||||
|
||||
parts := strings.Split(req.Email, "@")
|
||||
if len(parts) == 2 {
|
||||
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)
|
||||
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,
|
||||
AffiliationType: req.AffiliationType,
|
||||
CompanyCode: companyCode,
|
||||
TenantID: tenantID,
|
||||
Department: req.Department,
|
||||
Role: "user",
|
||||
Status: "active",
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
// Link TenantID if possible
|
||||
if companyCode != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), companyCode); err == nil && tenant != nil {
|
||||
localUser.TenantID = &tenant.ID
|
||||
if h.UserRepo != nil {
|
||||
if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
|
||||
slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
|
||||
slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err)
|
||||
// We don't fail the whole signup if local sync fails
|
||||
// [Keto] Sync user-tenant relationship
|
||||
if h.KetoService != nil && tenantID != nil {
|
||||
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{
|
||||
@@ -2555,69 +2573,18 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
|
||||
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 {
|
||||
token := h.getBearerToken(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)
|
||||
profile, err := h.resolveCurrentProfile(c)
|
||||
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)
|
||||
}
|
||||
// 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 {
|
||||
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) {
|
||||
// [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)
|
||||
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 nil, err
|
||||
if err == nil {
|
||||
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 err != nil {
|
||||
return nil, err
|
||||
if profile == nil {
|
||||
profile, err = h.getKratosProfile(token)
|
||||
}
|
||||
} else {
|
||||
cookie := c.Get("Cookie")
|
||||
if cookie != "" {
|
||||
profile, err = h.getKratosProfileWithCookie(cookie)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
cookie := c.Get("Cookie")
|
||||
if cookie == "" {
|
||||
return nil, fmt.Errorf("missing authorization token")
|
||||
if err != nil || profile == nil {
|
||||
return nil, errors.New("invalid session")
|
||||
}
|
||||
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) {
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" {
|
||||
|
||||
@@ -39,6 +39,47 @@ type tenantListResponse struct {
|
||||
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 {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
|
||||
@@ -17,14 +17,16 @@ type UserHandler struct {
|
||||
KratosAdmin *service.KratosAdminService
|
||||
OryProvider *service.OryProvider
|
||||
TenantService service.TenantService
|
||||
KetoService service.KetoService
|
||||
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{
|
||||
KratosAdmin: kratosAdmin,
|
||||
OryProvider: oryProvider,
|
||||
TenantService: tenantService,
|
||||
KetoService: ketoService,
|
||||
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"})
|
||||
}
|
||||
|
||||
// [New] Get requester profile from middleware
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
|
||||
limit := c.QueryInt("limit", 50)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
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))
|
||||
if search == "" {
|
||||
filtered = identities
|
||||
} else {
|
||||
searchLower := strings.ToLower(search)
|
||||
for _, identity := range identities {
|
||||
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
||||
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||
if strings.Contains(email, searchLower) || strings.Contains(name, searchLower) {
|
||||
filtered = append(filtered, identity)
|
||||
searchLower := strings.ToLower(search)
|
||||
|
||||
for _, identity := range identities {
|
||||
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
||||
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||
|
||||
// 1. Tenant Admin filtering
|
||||
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))
|
||||
@@ -123,6 +139,15 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
if err != nil {
|
||||
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"})
|
||||
}
|
||||
|
||||
// [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 {
|
||||
Password *string `json:"password"`
|
||||
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"})
|
||||
}
|
||||
|
||||
// [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
|
||||
if traits == nil {
|
||||
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"})
|
||||
}
|
||||
|
||||
// [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 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
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)
|
||||
FindByName(ctx context.Context, name 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
|
||||
}
|
||||
|
||||
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) {
|
||||
var tenant domain.Tenant
|
||||
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 (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TenantService interface {
|
||||
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)
|
||||
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||
ApproveTenant(ctx context.Context, id string) error
|
||||
SetKetoService(keto KetoService) // 추가
|
||||
}
|
||||
|
||||
type tenantService struct {
|
||||
repo repository.TenantRepository
|
||||
keto KetoService
|
||||
}
|
||||
|
||||
func NewTenantService(repo repository.TenantRepository) TenantService {
|
||||
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) {
|
||||
// Validate Slug
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
|
||||
// 1. Check if slug exists
|
||||
existing, err := s.repo.FindBySlug(ctx, slug)
|
||||
if err == nil && existing != nil {
|
||||
@@ -39,26 +54,95 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Description: description,
|
||||
Status: "active",
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, tenant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Add Domains
|
||||
// 3. Add Domains (Auto-verify for manual admin registration)
|
||||
for _, d := range domains {
|
||||
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)
|
||||
// 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) {
|
||||
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) {
|
||||
|
||||
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
|
||||
|
||||
namespaces:
|
||||
location: file:///etc/config/keto/namespaces.yml
|
||||
location: file:///etc/config/keto/namespaces.ts
|
||||
|
||||
log:
|
||||
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