forked from baron/baron-sso
애플리케이션(RP) 관리 기능 구현 및 Ory Keto 권한 연동
This commit is contained in:
@@ -11,6 +11,10 @@ import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
|||||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||||
|
import TenantRelyingPartyListPage from "../features/tenants/routes/TenantRelyingPartyListPage";
|
||||||
|
import TenantRelyingPartyCreatePage from "../features/tenants/routes/TenantRelyingPartyCreatePage";
|
||||||
|
import TenantRelyingPartyDetailPage from "../features/tenants/routes/TenantRelyingPartyDetailPage";
|
||||||
|
import RelyingPartyListPage from "../features/relying-parties/RelyingPartyListPage";
|
||||||
import UserCreatePage from "../features/users/UserCreatePage";
|
import UserCreatePage from "../features/users/UserCreatePage";
|
||||||
import UserDetailPage from "../features/users/UserDetailPage";
|
import UserDetailPage from "../features/users/UserDetailPage";
|
||||||
import UserListPage from "../features/users/UserListPage";
|
import UserListPage from "../features/users/UserListPage";
|
||||||
@@ -28,6 +32,7 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "users", element: <UserListPage /> },
|
{ path: "users", element: <UserListPage /> },
|
||||||
{ path: "users/new", element: <UserCreatePage /> },
|
{ path: "users/new", element: <UserCreatePage /> },
|
||||||
{ path: "users/:id", element: <UserDetailPage /> },
|
{ path: "users/:id", element: <UserDetailPage /> },
|
||||||
|
{ path: "relying-parties", element: <RelyingPartyListPage /> },
|
||||||
{ path: "tenants", element: <TenantListPage /> },
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
{
|
{
|
||||||
@@ -36,6 +41,9 @@ export const router = createBrowserRouter(
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <TenantProfilePage /> },
|
{ index: true, element: <TenantProfilePage /> },
|
||||||
{ path: "schema", element: <TenantSchemaPage /> },
|
{ path: "schema", element: <TenantSchemaPage /> },
|
||||||
|
{ path: "relying-parties", element: <TenantRelyingPartyListPage /> },
|
||||||
|
{ path: "relying-parties/new", element: <TenantRelyingPartyCreatePage /> },
|
||||||
|
{ path: "relying-parties/:id", element: <TenantRelyingPartyDetailPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sun,
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
|
Share2,
|
||||||
} 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";
|
||||||
@@ -19,6 +20,7 @@ const navItems = [
|
|||||||
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
||||||
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
||||||
{ label: "Users", to: "/users", icon: Users },
|
{ label: "Users", to: "/users", icon: Users },
|
||||||
|
{ label: "Applications", to: "/relying-parties", icon: Share2 },
|
||||||
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
||||||
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
||||||
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ function TenantDetailPage() {
|
|||||||
>
|
>
|
||||||
Schema
|
Schema
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/tenants/${tenantId}/relying-parties`}
|
||||||
|
className={`px-4 py-2 text-sm font-medium ${
|
||||||
|
location.pathname.includes("/relying-parties")
|
||||||
|
? "border-b-2 border-blue-500 text-blue-600"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Relying Parties
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outlet for nested routes */}
|
{/* Outlet for nested routes */}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Label } from "../../components/ui/label";
|
|||||||
import {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
|
fetchTenant,
|
||||||
type UserCreateRequest,
|
type UserCreateRequest,
|
||||||
type UserCreateResponse,
|
type UserCreateResponse,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Label } from "../../components/ui/label";
|
|||||||
import {
|
import {
|
||||||
fetchUser,
|
fetchUser,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
|
fetchTenant,
|
||||||
updateUser,
|
updateUser,
|
||||||
type UserUpdateRequest,
|
type UserUpdateRequest,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
|
|||||||
@@ -252,3 +252,70 @@ export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
|||||||
export async function deleteUser(userId: string) {
|
export async function deleteUser(userId: string) {
|
||||||
await apiClient.delete(`/v1/admin/users/${userId}`);
|
await apiClient.delete(`/v1/admin/users/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Relying Party Management
|
||||||
|
export type RelyingParty = {
|
||||||
|
clientId: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HydraClientReq = {
|
||||||
|
client_id?: string;
|
||||||
|
client_name: string;
|
||||||
|
client_secret?: string;
|
||||||
|
redirect_uris: string[];
|
||||||
|
scope?: string;
|
||||||
|
token_endpoint_auth_method?: string;
|
||||||
|
grant_types?: string[];
|
||||||
|
response_types?: string[];
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchRelyingParties(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<RelyingParty[]>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/relying-parties`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllRelyingParties() {
|
||||||
|
const { data } = await apiClient.get<RelyingParty[]>(
|
||||||
|
"/v1/admin/relying-parties",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRelyingParty(
|
||||||
|
tenantId: string,
|
||||||
|
payload: HydraClientReq,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<RelyingParty>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/relying-parties`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRelyingParty(id: string) {
|
||||||
|
const { data } = await apiClient.get<{
|
||||||
|
relyingParty: RelyingParty;
|
||||||
|
oauth2Config: HydraClientReq;
|
||||||
|
}>(`/v1/admin/relying-parties/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRelyingParty(id: string, payload: HydraClientReq) {
|
||||||
|
const { data } = await apiClient.put<RelyingParty>(
|
||||||
|
`/v1/admin/relying-parties/${id}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRelyingParty(id: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,6 +173,13 @@ func main() {
|
|||||||
slog.Info("✅ Connected to Ory ClickHouse")
|
slog.Info("✅ Connected to Ory ClickHouse")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
redisService, err := service.NewRedisService()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ketoService := service.NewKetoService()
|
||||||
|
|
||||||
// PostgreSQL (Meta Store)
|
// PostgreSQL (Meta Store)
|
||||||
pgHost := getEnv("DB_HOST", "localhost")
|
pgHost := getEnv("DB_HOST", "localhost")
|
||||||
pgPort := getEnv("DB_PORT", "5432")
|
pgPort := getEnv("DB_PORT", "5432")
|
||||||
@@ -205,17 +212,16 @@ func main() {
|
|||||||
// Run Bootstrap (Migrations & Seeding)
|
// Run Bootstrap (Migrations & Seeding)
|
||||||
if err := bootstrap.Run(db); err != nil {
|
if err := bootstrap.Run(db); err != nil {
|
||||||
slog.Error("❌ Bootstrap failed", "error", err)
|
slog.Error("❌ Bootstrap failed", "error", err)
|
||||||
// Panic or Exit depending on policy.
|
}
|
||||||
|
|
||||||
|
// [New] Sync existing data to Keto
|
||||||
|
if ketoService != nil {
|
||||||
|
if err := bootstrap.SyncKetoRelations(db, ketoService); err != nil {
|
||||||
|
slog.Warn("⚠️ Keto synchronization failed during startup", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
redisService, err := service.NewRedisService()
|
|
||||||
if err != nil {
|
|
||||||
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" {
|
||||||
@@ -243,12 +249,16 @@ func main() {
|
|||||||
tenantService := service.NewTenantService(tenantRepo)
|
tenantService := service.NewTenantService(tenantRepo)
|
||||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
|
relyingPartyRepo := repository.NewRelyingPartyRepository(db)
|
||||||
|
hydraService := service.NewHydraAdminService()
|
||||||
|
relyingPartyService := service.NewRelyingPartyService(relyingPartyRepo, hydraService, ketoService)
|
||||||
|
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, 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)
|
||||||
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
||||||
kratosAdminService := service.NewKratosAdminService()
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
oryAdminProvider := service.NewOryProvider()
|
oryAdminProvider := service.NewOryProvider()
|
||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
||||||
@@ -550,6 +560,35 @@ func main() {
|
|||||||
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
||||||
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
|
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
|
||||||
|
|
||||||
|
// Relying Party Management (Global List)
|
||||||
|
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
||||||
|
|
||||||
|
// Relying Party Management (Tenant Context)
|
||||||
|
admin.Post("/tenants/:tenantId/relying-parties",
|
||||||
|
requireAdmin,
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"),
|
||||||
|
relyingPartyHandler.Create)
|
||||||
|
|
||||||
|
admin.Get("/tenants/:tenantId/relying-parties",
|
||||||
|
requireAdmin,
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"),
|
||||||
|
relyingPartyHandler.List)
|
||||||
|
|
||||||
|
admin.Get("/relying-parties/:id",
|
||||||
|
requireAdmin,
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"),
|
||||||
|
relyingPartyHandler.Get)
|
||||||
|
|
||||||
|
admin.Put("/relying-parties/:id",
|
||||||
|
requireAdmin,
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
relyingPartyHandler.Update)
|
||||||
|
|
||||||
|
admin.Delete("/relying-parties/:id",
|
||||||
|
requireAdmin,
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
relyingPartyHandler.Delete)
|
||||||
|
|
||||||
// Admin User Management
|
// Admin User Management
|
||||||
admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요
|
admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요
|
||||||
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const (
|
|||||||
// 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"`
|
||||||
|
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 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"`
|
||||||
|
|||||||
@@ -3208,7 +3208,9 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessions []service.HydraConsentSession
|
var sessions []domain.HydraConsentSession
|
||||||
|
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
hasSuccess := false
|
hasSuccess := false
|
||||||
for _, subject := range subjects {
|
for _, subject := range subjects {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -230,7 +231,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clientReq := service.HydraClient{
|
clientReq := domain.HydraClient{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientName: name,
|
ClientName: name,
|
||||||
RedirectURIs: redirectURIs,
|
RedirectURIs: redirectURIs,
|
||||||
@@ -329,7 +330,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
metadata["status"] = status
|
metadata["status"] = status
|
||||||
}
|
}
|
||||||
|
|
||||||
updated := service.HydraClient{
|
updated := domain.HydraClient{
|
||||||
ClientID: current.ClientID,
|
ClientID: current.ClientID,
|
||||||
ClientName: valueOr(req.Name, current.ClientName),
|
ClientName: valueOr(req.Name, current.ClientName),
|
||||||
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
|
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
|
||||||
@@ -438,7 +439,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
|||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) mapClientSummary(client service.HydraClient) clientSummary {
|
func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
||||||
status := "active"
|
status := "active"
|
||||||
if client.Metadata != nil {
|
if client.Metadata != nil {
|
||||||
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
|
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
|
||||||
|
|||||||
@@ -404,6 +404,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
// [New] Local DB Sync
|
// [New] Local DB Sync
|
||||||
if h.UserRepo != nil {
|
if h.UserRepo != nil {
|
||||||
if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil {
|
if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil {
|
||||||
|
oldRole := localUser.Role
|
||||||
|
oldTenantID := ""
|
||||||
|
if localUser.TenantID != nil {
|
||||||
|
oldTenantID = *localUser.TenantID
|
||||||
|
}
|
||||||
|
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
localUser.Name = *req.Name
|
localUser.Name = *req.Name
|
||||||
}
|
}
|
||||||
@@ -428,7 +434,26 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if req.Metadata != nil {
|
if req.Metadata != nil {
|
||||||
localUser.Metadata = req.Metadata
|
localUser.Metadata = req.Metadata
|
||||||
}
|
}
|
||||||
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
|
||||||
|
if err := h.UserRepo.Update(c.Context(), localUser); err == nil {
|
||||||
|
// [Keto Sync on Role Change]
|
||||||
|
if h.KetoService != nil && req.Role != nil && *req.Role != oldRole {
|
||||||
|
go func(uID, oldR, newR, tID string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if oldR == domain.RoleSuperAdmin {
|
||||||
|
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID)
|
||||||
|
} else if oldR == domain.RoleTenantAdmin && tID != "" {
|
||||||
|
_ = h.KetoService.DeleteRelation(ctx, "Tenant", tID, "admins", uID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if newR == domain.RoleSuperAdmin {
|
||||||
|
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", uID)
|
||||||
|
} else if newR == domain.RoleTenantAdmin && tID != "" {
|
||||||
|
_ = h.KetoService.CreateRelation(ctx, "Tenant", tID, "admins", uID)
|
||||||
|
}
|
||||||
|
}(userID, oldRole, *req.Role, oldTenantID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", userID, "error", err)
|
slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", userID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,13 +496,14 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// [Keto] Cleanup relations (Best effort)
|
// [Keto] Cleanup relations (Best effort)
|
||||||
if h.KetoService != nil {
|
if h.KetoService != nil {
|
||||||
go func() {
|
go func(uID string) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// Note: Proper cleanup requires searching all relations,
|
// Fetch user from DB before cleanup if needed, but here we cleanup common namespaces
|
||||||
// here we just cleanup known common ones or rely on subject cleanup if Keto supported it.
|
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID)
|
||||||
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", userID)
|
|
||||||
// For tenants, we'd need to know which tenant they were in.
|
// If we had more complex relations, we would query Keto first or use user metadata
|
||||||
}()
|
slog.Info("Keto relations cleaned up for user", "userID", uID)
|
||||||
|
}(userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -73,7 +74,7 @@ func NewHydraAdminService() *HydraAdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HydraAdminService) ListClients(ctx context.Context, limit, offset int) ([]HydraClient, error) {
|
func (s *HydraAdminService) ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error) {
|
||||||
endpoint, err := s.buildURL("/clients", map[string]int{
|
endpoint, err := s.buildURL("/clients", map[string]int{
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
@@ -101,14 +102,14 @@ func (s *HydraAdminService) ListClients(ctx context.Context, limit, offset int)
|
|||||||
return nil, fmt.Errorf("hydra admin: list clients failed status=%d body=%s", resp.StatusCode, string(body))
|
return nil, fmt.Errorf("hydra admin: list clients failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
var clients []HydraClient
|
var clients []domain.HydraClient
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil {
|
||||||
return nil, fmt.Errorf("hydra admin: decode clients failed: %w", err)
|
return nil, fmt.Errorf("hydra admin: decode clients failed: %w", err)
|
||||||
}
|
}
|
||||||
return clients, nil
|
return clients, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*HydraClient, error) {
|
func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*domain.HydraClient, error) {
|
||||||
endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID))
|
endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID))
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,14 +130,14 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*Hy
|
|||||||
return nil, fmt.Errorf("hydra admin: get client failed status=%d body=%s", resp.StatusCode, string(body))
|
return nil, fmt.Errorf("hydra admin: get client failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
var client HydraClient
|
var client domain.HydraClient
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&client); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&client); err != nil {
|
||||||
return nil, fmt.Errorf("hydra admin: decode client failed: %w", err)
|
return nil, fmt.Errorf("hydra admin: decode client failed: %w", err)
|
||||||
}
|
}
|
||||||
return &client, nil
|
return &client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*HydraClient, error) {
|
func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*domain.HydraClient, error) {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"metadata": map[string]interface{}{
|
"metadata": map[string]interface{}{
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -165,14 +166,14 @@ func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, sta
|
|||||||
return nil, fmt.Errorf("hydra admin: patch client failed status=%d body=%s", resp.StatusCode, string(respBody))
|
return nil, fmt.Errorf("hydra admin: patch client failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
var updated HydraClient
|
var updated domain.HydraClient
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil {
|
||||||
return nil, fmt.Errorf("hydra admin: decode patched client failed: %w", err)
|
return nil, fmt.Errorf("hydra admin: decode patched client failed: %w", err)
|
||||||
}
|
}
|
||||||
return &updated, nil
|
return &updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HydraAdminService) CreateClient(ctx context.Context, client HydraClient) (*HydraClient, error) {
|
func (s *HydraAdminService) CreateClient(ctx context.Context, client domain.HydraClient) (*domain.HydraClient, error) {
|
||||||
body, _ := json.Marshal(client)
|
body, _ := json.Marshal(client)
|
||||||
endpoint := fmt.Sprintf("%s/clients", strings.TrimRight(s.AdminURL, "/"))
|
endpoint := fmt.Sprintf("%s/clients", strings.TrimRight(s.AdminURL, "/"))
|
||||||
|
|
||||||
@@ -193,14 +194,14 @@ func (s *HydraAdminService) CreateClient(ctx context.Context, client HydraClient
|
|||||||
return nil, fmt.Errorf("hydra admin: create client failed status=%d body=%s", resp.StatusCode, string(respBody))
|
return nil, fmt.Errorf("hydra admin: create client failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
var created HydraClient
|
var created domain.HydraClient
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||||
return nil, fmt.Errorf("hydra admin: decode created client failed: %w", err)
|
return nil, fmt.Errorf("hydra admin: decode created client failed: %w", err)
|
||||||
}
|
}
|
||||||
return &created, nil
|
return &created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HydraAdminService) UpdateClient(ctx context.Context, clientID string, client HydraClient) (*HydraClient, error) {
|
func (s *HydraAdminService) UpdateClient(ctx context.Context, clientID string, client domain.HydraClient) (*domain.HydraClient, error) {
|
||||||
client.ClientID = clientID
|
client.ClientID = clientID
|
||||||
body, _ := json.Marshal(client)
|
body, _ := json.Marshal(client)
|
||||||
endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID))
|
endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID))
|
||||||
@@ -225,7 +226,7 @@ func (s *HydraAdminService) UpdateClient(ctx context.Context, clientID string, c
|
|||||||
return nil, fmt.Errorf("hydra admin: update client failed status=%d body=%s", resp.StatusCode, string(respBody))
|
return nil, fmt.Errorf("hydra admin: update client failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
var updated HydraClient
|
var updated domain.HydraClient
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil {
|
||||||
return nil, fmt.Errorf("hydra admin: decode updated client failed: %w", err)
|
return nil, fmt.Errorf("hydra admin: decode updated client failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -255,7 +256,7 @@ func (s *HydraAdminService) DeleteClient(ctx context.Context, clientID string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, clientID string) ([]HydraConsentSession, error) {
|
func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, clientID string) ([]domain.HydraConsentSession, error) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
}
|
}
|
||||||
@@ -283,7 +284,7 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl
|
|||||||
return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body))
|
return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessions []HydraConsentSession
|
var sessions []domain.HydraConsentSession
|
||||||
if err := json.Unmarshal(body, &sessions); err != nil {
|
if err := json.Unmarshal(body, &sessions); err != nil {
|
||||||
return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err)
|
return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -376,7 +377,7 @@ type AcceptConsentRequestResponse struct {
|
|||||||
RedirectTo string `json:"redirectTo"`
|
RedirectTo string `json:"redirectTo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge string) (*HydraConsentRequest, error) {
|
func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge string) (*domain.HydraConsentRequest, error) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"consent_challenge": challenge,
|
"consent_challenge": challenge,
|
||||||
}
|
}
|
||||||
@@ -401,7 +402,7 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str
|
|||||||
return nil, fmt.Errorf("hydra admin: get consent failed status=%d body=%s", resp.StatusCode, string(body))
|
return nil, fmt.Errorf("hydra admin: get consent failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
var consentReq HydraConsentRequest
|
var consentReq domain.HydraConsentRequest
|
||||||
if err := json.Unmarshal(body, &consentReq); err != nil {
|
if err := json.Unmarshal(body, &consentReq); err != nil {
|
||||||
return nil, fmt.Errorf("hydra admin: decode get consent response failed: %w", err)
|
return nil, fmt.Errorf("hydra admin: decode get consent response failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -442,7 +443,7 @@ func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge strin
|
|||||||
return &loginReq, nil
|
return &loginReq, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest, sessionClaims map[string]any) (*AcceptConsentRequestResponse, error) {
|
func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *domain.HydraConsentRequest, sessionClaims map[string]any) (*AcceptConsentRequestResponse, error) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"consent_challenge": challenge,
|
"consent_challenge": challenge,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type KetoService interface {
|
type KetoService interface {
|
||||||
@@ -87,22 +88,34 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
|
|||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
|
// Exponential Backoff Retry Logic
|
||||||
req.Header.Set("Content-Type", "application/json")
|
var lastErr error
|
||||||
|
maxRetries := 3
|
||||||
|
backoff := 100 * time.Millisecond
|
||||||
|
|
||||||
resp, err := s.client.Do(req)
|
for i := 0; i < maxRetries; i++ {
|
||||||
if err != nil {
|
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
|
||||||
return err
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
resp, err := s.client.Do(req)
|
||||||
resBody, _ := io.ReadAll(resp.Body)
|
if err == nil {
|
||||||
return fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody))
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||||
|
slog.Info("Keto relation created", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resBody, _ := io.ReadAll(resp.Body)
|
||||||
|
lastErr = fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody))
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(backoff)
|
||||||
|
backoff *= 2
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("Keto relation created", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
slog.Error("Keto create relation failed after retries", "error", lastErr)
|
||||||
return nil
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions"
|
|||||||
|
|
||||||
class User implements Namespace {}
|
class User implements Namespace {}
|
||||||
|
|
||||||
|
class UserGroup implements Namespace {
|
||||||
|
related: {
|
||||||
|
members: User[]
|
||||||
|
parent_tenant: Tenant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
permits = {
|
||||||
|
check_member: (ctx: Context): boolean =>
|
||||||
|
this.related.members.includes(ctx.subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class Tenant implements Namespace {
|
class Tenant implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
admins: User[]
|
admins: User[]
|
||||||
@@ -26,7 +38,7 @@ class Tenant implements Namespace {
|
|||||||
|
|
||||||
class RelyingParty implements Namespace {
|
class RelyingParty implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
owners: User[]
|
owners: (User | SubjectSet<UserGroup, "members">)[]
|
||||||
parent_tenant: Tenant[]
|
parent_tenant: Tenant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user