forked from baron/baron-sso
fix: improve keto sync reliability and initial rebac permissions for super admin
This commit is contained in:
@@ -234,9 +234,9 @@ func main() {
|
||||
}
|
||||
|
||||
// [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)
|
||||
if ketoOutboxRepo != nil {
|
||||
if err := bootstrap.SyncKetoRelations(db, ketoOutboxRepo); err != nil {
|
||||
slog.Warn("⚠️ Keto synchronization queueing failed during startup", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,52 +2,91 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SyncKetoRelations synchronizes all existing DB users and tenants to Ory Keto.
|
||||
// SyncKetoRelations synchronizes all existing DB users, tenants and RPs to Ory Keto via Outbox.
|
||||
// This ensures data consistency for existing data when ReBAC is introduced.
|
||||
func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
|
||||
slog.Info("🚀 Starting Keto ReBAC relation synchronization...")
|
||||
func SyncKetoRelations(db *gorm.DB, outbox repository.KetoOutboxRepository) error {
|
||||
slog.Info("🚀 Starting Keto ReBAC relation synchronization (via Outbox)...")
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Sync All Tenants (Ensure they exist in Keto if needed)
|
||||
// 1. Sync All Tenants
|
||||
var tenants []domain.Tenant
|
||||
if err := db.Find(&tenants).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("Syncing tenants to Keto", "count", len(tenants))
|
||||
slog.Info("Syncing tenants to Keto Outbox", "count", len(tenants))
|
||||
for _, t := range tenants {
|
||||
// Global Super Admin access to every tenant
|
||||
_ = outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: t.ID,
|
||||
Relation: "admins",
|
||||
Subject: "System:global#super_admins",
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
|
||||
if t.ParentID != nil {
|
||||
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parents", "Tenant:"+*t.ParentID)
|
||||
_ = outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: t.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + *t.ParentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync All Users
|
||||
// 2. Sync All RelyingParties (if needed)
|
||||
// Note: We'll need a way to list them from Hydra or local DB if we had them.
|
||||
// Assuming they are in a table domain.RelyingParty (though it was removed, let's see)
|
||||
// Actually, the comment said SSOT is Hydra. But we might have them in a local table for metadata.
|
||||
// If not, we skip for now or fetch from Hydra.
|
||||
|
||||
// 3. Sync All Users Roles and Tenant Memberships
|
||||
var users []domain.User
|
||||
if err := db.Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("Syncing users to Keto", "count", len(users))
|
||||
slog.Info("Syncing users to Keto Outbox", "count", len(users))
|
||||
for _, u := range users {
|
||||
role := domain.NormalizeRole(u.Role)
|
||||
// Membership
|
||||
// Tenant Membership
|
||||
if u.TenantID != nil {
|
||||
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", "User:"+u.ID)
|
||||
_ = outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *u.TenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + u.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
// Roles
|
||||
role := domain.NormalizeRole(u.Role)
|
||||
if role == domain.RoleSuperAdmin {
|
||||
_ = keto.CreateRelation(ctx, "System", "global", "super_admins", "User:"+u.ID)
|
||||
_ = outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + u.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
} else if role == domain.RoleTenantAdmin && u.TenantID != nil {
|
||||
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", "User:"+u.ID)
|
||||
_ = outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *u.TenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + u.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("✅ Keto ReBAC synchronization completed.")
|
||||
slog.Info("✅ Keto ReBAC synchronization items added to Outbox.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -363,8 +363,8 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check with Keto: System:AppManager#member
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "System", "AppManager", "member")
|
||||
// Check with Keto: System:global#manage_all
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "System", "global", "manage_all")
|
||||
if err != nil {
|
||||
// Fail closed for dev private endpoints: deny on permission backend error.
|
||||
slog.Warn("Dev private permission check failed; denying access", "subject", subject, "error", err)
|
||||
@@ -442,8 +442,8 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check with Keto: System:AppManager#member
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member")
|
||||
// Check with Keto: System:global#manage_all
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "global", "manage_all")
|
||||
if err != nil {
|
||||
// Fail closed for dev private endpoints: deny on permission backend error.
|
||||
slog.Warn("Dev private permission check failed; denying access", "subject", tokenSubject, "error", err)
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
mockKeto.On("CheckPermission", mock.Anything, "user-a", "System", "AppManager", "member").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "user-a", "System", "global", "manage_all").Return(true, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestListClients_Success(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
@@ -235,7 +235,7 @@ func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
@@ -276,7 +276,7 @@ func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
@@ -602,7 +602,7 @@ func TestGetStats_Success(t *testing.T) {
|
||||
assert.Equal(t, int64(2), res.TotalClients)
|
||||
assert.Equal(t, int64(7), res.AuthFailures)
|
||||
assert.Equal(t, int64(3), res.ActiveSessions)
|
||||
mockKeto.AssertNotCalled(t, "CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member")
|
||||
mockKeto.AssertNotCalled(t, "CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all")
|
||||
}
|
||||
|
||||
func TestDevHandler_NoAuditNoAction(t *testing.T) {
|
||||
|
||||
@@ -106,27 +106,39 @@ func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, o
|
||||
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()
|
||||
var lastErr error
|
||||
maxRetries := 5
|
||||
backoff := 200 * time.Millisecond
|
||||
|
||||
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))
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
resp, err := s.client.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var res checkResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res.Allowed, nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
return false, nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
lastErr = fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if i < maxRetries-1 {
|
||||
slog.Debug("Retrying Keto CheckPermission...", "attempt", i+1, "error", lastErr)
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
|
||||
var res checkResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return res.Allowed, nil
|
||||
return false, lastErr
|
||||
}
|
||||
|
||||
func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
@@ -141,8 +153,8 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
|
||||
|
||||
// Exponential Backoff Retry Logic
|
||||
var lastErr error
|
||||
maxRetries := 3
|
||||
backoff := 100 * time.Millisecond
|
||||
maxRetries := 5
|
||||
backoff := 200 * time.Millisecond
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
|
||||
@@ -152,7 +164,7 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
|
||||
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)
|
||||
slog.Debug("Keto relation created", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
||||
return nil
|
||||
}
|
||||
resBody, _ := io.ReadAll(resp.Body)
|
||||
@@ -161,11 +173,14 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
if i < maxRetries-1 {
|
||||
slog.Debug("Retrying Keto CreateRelation...", "attempt", i+1, "error", lastErr)
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
|
||||
slog.Error("Keto create relation failed after retries", "error", lastErr)
|
||||
slog.Error("Keto create relation failed after retries", "error", lastErr, "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
||||
return lastErr
|
||||
}
|
||||
|
||||
@@ -178,20 +193,34 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel
|
||||
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()
|
||||
var lastErr error
|
||||
maxRetries := 5
|
||||
backoff := 200 * time.Millisecond
|
||||
|
||||
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))
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil)
|
||||
resp, err := s.client.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||
slog.Debug("Keto relation deleted", "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
|
||||
}
|
||||
|
||||
if i < maxRetries-1 {
|
||||
slog.Debug("Retrying Keto DeleteRelation...", "attempt", i+1, "error", lastErr)
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
||||
return nil
|
||||
slog.Error("Keto delete relation failed after retries", "error", lastErr)
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (s *ketoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||
|
||||
@@ -120,6 +120,15 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
|
||||
// [Keto] Sync hierarchy and ownership via Outbox
|
||||
if s.outboxRepo != nil {
|
||||
// Global Super Admin access to every tenant
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "admins",
|
||||
Subject: "System:global#super_admins",
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
|
||||
// Sync hierarchy
|
||||
if tenant.ParentID != nil {
|
||||
if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
@@ -198,6 +207,17 @@ func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, des
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// [Keto] Global Super Admin access to every tenant (even pending ones)
|
||||
if s.outboxRepo != nil {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "admins",
|
||||
Subject: "System:global#super_admins",
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
// Add Domain as unverified
|
||||
if err := s.repo.AddDomain(ctx, tenant.ID, domainName, false); err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user