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