From 066ea86f4600c52639c6872e538174bccedff344 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 4 Feb 2026 14:56:16 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=A0=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=85=98(RP)=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20Ory=20Keto=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 8 +++ .../src/components/layout/AppLayout.tsx | 2 + .../tenants/routes/TenantDetailPage.tsx | 10 +++ .../src/features/users/UserCreatePage.tsx | 1 + .../src/features/users/UserDetailPage.tsx | 1 + adminfront/src/lib/adminApi.ts | 67 +++++++++++++++++++ backend/cmd/server/main.go | 55 ++++++++++++--- backend/internal/domain/tenant.go | 1 + backend/internal/handler/auth_handler.go | 4 +- backend/internal/handler/dev_handler.go | 7 +- backend/internal/handler/user_handler.go | 40 +++++++++-- .../internal/service/hydra_admin_service.go | 31 ++++----- backend/internal/service/keto_service.go | 37 ++++++---- docker/ory/keto/namespaces.ts | 16 ++++- 14 files changed, 232 insertions(+), 48 deletions(-) diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 1c994bed..3f43e7eb 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -11,6 +11,10 @@ import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"; 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 UserDetailPage from "../features/users/UserDetailPage"; import UserListPage from "../features/users/UserListPage"; @@ -28,6 +32,7 @@ export const router = createBrowserRouter( { path: "users", element: }, { path: "users/new", element: }, { path: "users/:id", element: }, + { path: "relying-parties", element: }, { path: "tenants", element: }, { path: "tenants/new", element: }, { @@ -36,6 +41,9 @@ export const router = createBrowserRouter( children: [ { index: true, element: }, { path: "schema", element: }, + { path: "relying-parties", element: }, + { path: "relying-parties/new", element: }, + { path: "relying-parties/:id", element: }, ], }, { path: "api-keys", element: }, diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 03f37346..b29a3cd4 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -9,6 +9,7 @@ import { ShieldHalf, Sun, Users, + Share2, } from "lucide-react"; import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; @@ -19,6 +20,7 @@ const navItems = [ { label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf }, { label: "Tenants", to: "/tenants", icon: Building2 }, { label: "Users", to: "/users", icon: Users }, + { label: "Applications", to: "/relying-parties", icon: Share2 }, { label: "API Keys (M2M)", to: "/api-keys", icon: Key }, { label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs }, { label: "Auth Guard", to: "/auth", icon: KeyRound }, diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index dccf8a27..0b8bc825 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -70,6 +70,16 @@ function TenantDetailPage() { > Schema + + Relying Parties + {/* Outlet for nested routes */} diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index 070401cc..4ec0a275 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -17,6 +17,7 @@ import { Label } from "../../components/ui/label"; import { createUser, fetchTenants, + fetchTenant, type UserCreateRequest, type UserCreateResponse, } from "../../lib/adminApi"; diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index ba21cd9f..e445ab33 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -17,6 +17,7 @@ import { Label } from "../../components/ui/label"; import { fetchUser, fetchTenants, + fetchTenant, updateUser, type UserUpdateRequest, } from "../../lib/adminApi"; diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 7ae1a82a..b808fdb5 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -252,3 +252,70 @@ export async function updateUser(userId: string, payload: UserUpdateRequest) { export async function deleteUser(userId: string) { 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; +}; + +export async function fetchRelyingParties(tenantId: string) { + const { data } = await apiClient.get( + `/v1/admin/tenants/${tenantId}/relying-parties`, + ); + return data; +} + +export async function fetchAllRelyingParties() { + const { data } = await apiClient.get( + "/v1/admin/relying-parties", + ); + return data; +} + +export async function createRelyingParty( + tenantId: string, + payload: HydraClientReq, +) { + const { data } = await apiClient.post( + `/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( + `/v1/admin/relying-parties/${id}`, + payload, + ); + return data; +} + +export async function deleteRelyingParty(id: string) { + await apiClient.delete(`/v1/admin/relying-parties/${id}`); +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 02065c64..7fef9116 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -173,6 +173,13 @@ func main() { 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) pgHost := getEnv("DB_HOST", "localhost") pgPort := getEnv("DB_PORT", "5432") @@ -205,17 +212,16 @@ func main() { // Run Bootstrap (Migrations & Seeding) if err := bootstrap.Run(db); err != nil { 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 상태를 주기적으로 확인해 다운을 감지합니다. var oathkeeperProbe *HTTPProbe if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" { @@ -243,12 +249,16 @@ func main() { tenantService := service.NewTenantService(tenantRepo) tenantService.SetKetoService(ketoService) // Keto 주입 userRepo := repository.NewUserRepository(db) + relyingPartyRepo := repository.NewRelyingPartyRepository(db) + hydraService := service.NewHydraAdminService() + relyingPartyService := service.NewRelyingPartyService(relyingPartyRepo, hydraService, ketoService) auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo) adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler(redisService) tenantHandler := handler.NewTenantHandler(db, tenantService) + relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService) kratosAdminService := service.NewKratosAdminService() oryAdminProvider := service.NewOryProvider() userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo) @@ -550,6 +560,35 @@ func main() { admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant) 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.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요 admin.Post("/users", requireAdmin, userHandler.CreateUser) diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go index 79fe1158..2383c840 100644 --- a/backend/internal/domain/tenant.go +++ b/backend/internal/domain/tenant.go @@ -18,6 +18,7 @@ const ( // Tenant represents a tenant model stored in PostgreSQL. type Tenant struct { 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"` Slug string `gorm:"uniqueIndex;not null" json:"slug"` Description string `json:"description"` diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index b5a06c63..a5606591 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3208,7 +3208,9 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } - var sessions []service.HydraConsentSession + var sessions []domain.HydraConsentSession + + var lastErr error hasSuccess := false for _, subject := range subjects { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 76cab629..3d446240 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -1,6 +1,7 @@ package handler import ( + "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "errors" "strings" @@ -230,7 +231,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { } } - clientReq := service.HydraClient{ + clientReq := domain.HydraClient{ ClientID: clientID, ClientName: name, RedirectURIs: redirectURIs, @@ -329,7 +330,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { metadata["status"] = status } - updated := service.HydraClient{ + updated := domain.HydraClient{ ClientID: current.ClientID, ClientName: valueOr(req.Name, current.ClientName), RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs), @@ -438,7 +439,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } -func (h *DevHandler) mapClientSummary(client service.HydraClient) clientSummary { +func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { status := "active" if client.Metadata != nil { if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" { diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 6166ef5b..c3956871 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -404,6 +404,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { // [New] Local DB Sync if h.UserRepo != 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 { localUser.Name = *req.Name } @@ -428,7 +434,26 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { if req.Metadata != nil { 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) } } @@ -471,13 +496,14 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { // [Keto] Cleanup relations (Best effort) if h.KetoService != nil { - go func() { + go func(uID string) { 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. - }() + // Fetch user from DB before cleanup if needed, but here we cleanup common namespaces + _ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID) + + // 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) diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index 408e56fb..e4353dae 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -1,6 +1,7 @@ package service import ( + "baron-sso-backend/internal/domain" "bytes" "context" "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{ "limit": limit, "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)) } - var clients []HydraClient + var clients []domain.HydraClient if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil { return nil, fmt.Errorf("hydra admin: decode clients failed: %w", err) } 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)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, 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)) } - var client HydraClient + var client domain.HydraClient if err := json.NewDecoder(resp.Body).Decode(&client); err != nil { return nil, fmt.Errorf("hydra admin: decode client failed: %w", err) } 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{}{ "metadata": map[string]interface{}{ "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)) } - var updated HydraClient + var updated domain.HydraClient if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { return nil, fmt.Errorf("hydra admin: decode patched client failed: %w", err) } 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) 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)) } - var created HydraClient + var created domain.HydraClient if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { return nil, fmt.Errorf("hydra admin: decode created client failed: %w", err) } 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 body, _ := json.Marshal(client) 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)) } - var updated HydraClient + var updated domain.HydraClient if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { 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 } -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{ "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)) } - var sessions []HydraConsentSession + var sessions []domain.HydraConsentSession if err := json.Unmarshal(body, &sessions); err != nil { return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err) } @@ -376,7 +377,7 @@ type AcceptConsentRequestResponse struct { 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{ "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)) } - var consentReq HydraConsentRequest + var consentReq domain.HydraConsentRequest if err := json.Unmarshal(body, &consentReq); err != nil { 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 } -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{ "consent_challenge": challenge, } diff --git a/backend/internal/service/keto_service.go b/backend/internal/service/keto_service.go index 17184a1b..2ee23a86 100644 --- a/backend/internal/service/keto_service.go +++ b/backend/internal/service/keto_service.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "os" + "time" ) type KetoService interface { @@ -87,22 +88,34 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel } body, _ := json.Marshal(payload) - req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") + // Exponential Backoff Retry Logic + var lastErr error + maxRetries := 3 + backoff := 100 * time.Millisecond - resp, err := s.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() + for i := 0; i < maxRetries; i++ { + req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") - 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)) + resp, err := s.client.Do(req) + if err == nil { + 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) - return nil + slog.Error("Keto create relation failed after retries", "error", lastErr) + return lastErr } func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index 9ca3c396..60e349aa 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -2,6 +2,18 @@ import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions" 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 { related: { admins: User[] @@ -26,7 +38,7 @@ class Tenant implements Namespace { class RelyingParty implements Namespace { related: { - owners: User[] + owners: (User | SubjectSet)[] parent_tenant: Tenant[] } @@ -50,4 +62,4 @@ class System implements Namespace { manage_all: (ctx: Context): boolean => this.related.super_admins.includes(ctx.subject) } -} +} \ No newline at end of file