forked from baron/baron-sso
feat: 테넌트 그룹 기반 권한 상속 고도화 및 개발자 포털 보안 강화 #239
This commit is contained in:
@@ -259,7 +259,7 @@ func main() {
|
|||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService)
|
||||||
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService)
|
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
||||||
@@ -619,14 +619,24 @@ func main() {
|
|||||||
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
|
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
|
||||||
|
|
||||||
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
||||||
dev := api.Group("/dev")
|
dev := api.Group("/dev", requireAdmin)
|
||||||
dev.Get("/clients", devHandler.ListClients)
|
dev.Get("/clients", devHandler.ListClients)
|
||||||
dev.Post("/clients", devHandler.CreateClient)
|
dev.Post("/clients", devHandler.CreateClient)
|
||||||
dev.Get("/clients/:id", devHandler.GetClient)
|
dev.Get("/clients/:id",
|
||||||
dev.Put("/clients/:id", devHandler.UpdateClient)
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"),
|
||||||
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret)
|
devHandler.GetClient)
|
||||||
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
|
dev.Put("/clients/:id",
|
||||||
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
devHandler.UpdateClient)
|
||||||
|
dev.Post("/clients/:id/secret/rotate",
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
devHandler.RotateClientSecret)
|
||||||
|
dev.Patch("/clients/:id/status",
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
devHandler.UpdateClientStatus)
|
||||||
|
dev.Delete("/clients/:id",
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
devHandler.DeleteClient)
|
||||||
dev.Get("/consents", devHandler.ListConsents)
|
dev.Get("/consents", devHandler.ListConsents)
|
||||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ type UserProfileResponse struct {
|
|||||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
Tenant *Tenant `json:"tenant,omitempty"`
|
Tenant *Tenant `json:"tenant,omitempty"`
|
||||||
|
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserRequest struct {
|
type UpdateUserRequest struct {
|
||||||
|
|||||||
@@ -36,6 +36,28 @@ func (t *Tenant) IsActive() bool {
|
|||||||
return t.Status == TenantStatusActive
|
return t.Status == TenantStatusActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMergedConfig merges the group-level config with tenant-level config.
|
||||||
|
// Tenant config takes precedence.
|
||||||
|
func (t *Tenant) GetMergedConfig() JSONMap {
|
||||||
|
merged := make(JSONMap)
|
||||||
|
|
||||||
|
// 1. Apply Group Config (Base)
|
||||||
|
if t.TenantGroup != nil && t.TenantGroup.Config != nil {
|
||||||
|
for k, v := range t.TenantGroup.Config {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Apply Tenant Config (Overrides)
|
||||||
|
if t.Config != nil {
|
||||||
|
for k, v := range t.Config {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
// BeforeCreate hook to generate UUID if not present.
|
// BeforeCreate hook to generate UUID if not present.
|
||||||
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
||||||
if t.ID == "" {
|
if t.ID == "" {
|
||||||
|
|||||||
@@ -3953,6 +3953,13 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch Manageable Tenants for Admins
|
||||||
|
if profile.Role == domain.RoleSuperAdmin || profile.Role == domain.RoleTenantAdmin || profile.Role == domain.RoleRPAdmin {
|
||||||
|
if tenants, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID); err == nil {
|
||||||
|
profile.ManageableTenants = tenants
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Save to Redis Cache (Short TTL)
|
// 4. Save to Redis Cache (Short TTL)
|
||||||
if h.RedisService != nil && cacheKey != "" {
|
if h.RedisService != nil && cacheKey != "" {
|
||||||
if data, err := json.Marshal(profile); err == nil {
|
if data, err := json.Marshal(profile); err == nil {
|
||||||
|
|||||||
@@ -22,15 +22,17 @@ type DevHandler struct {
|
|||||||
SecretRepo domain.ClientSecretRepository
|
SecretRepo domain.ClientSecretRepository
|
||||||
KratosAdmin *service.KratosAdminService
|
KratosAdmin *service.KratosAdminService
|
||||||
ConsentRepo repository.ClientConsentRepository
|
ConsentRepo repository.ClientConsentRepository
|
||||||
|
RPService service.RelyingPartyService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler {
|
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpService service.RelyingPartyService) *DevHandler {
|
||||||
return &DevHandler{
|
return &DevHandler{
|
||||||
Hydra: service.NewHydraAdminService(),
|
Hydra: service.NewHydraAdminService(),
|
||||||
Redis: redis,
|
Redis: redis,
|
||||||
SecretRepo: secretRepo,
|
SecretRepo: secretRepo,
|
||||||
KratosAdmin: service.NewKratosAdminService(),
|
KratosAdmin: service.NewKratosAdminService(),
|
||||||
ConsentRepo: consentRepo,
|
ConsentRepo: consentRepo,
|
||||||
|
RPService: rpService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,38 +97,58 @@ type clientUpsertRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||||
limit := c.QueryInt("limit", 50)
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
offset := c.QueryInt("offset", 0)
|
if !ok {
|
||||||
if limit <= 0 {
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized: user profile not found"})
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
// Super Admin sees all (best effort via Hydra list for now, or we can use RPService if it's improved)
|
||||||
|
if profile.Role == domain.RoleSuperAdmin {
|
||||||
|
limit := c.QueryInt("limit", 50)
|
||||||
|
offset := c.QueryInt("offset", 0)
|
||||||
|
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
items := make([]clientSummary, 0, len(clients))
|
||||||
|
for _, client := range clients {
|
||||||
|
items = append(items, h.mapClientSummary(client))
|
||||||
|
}
|
||||||
|
return c.JSON(clientListResponse{Items: items, Limit: limit, Offset: offset})
|
||||||
|
}
|
||||||
|
|
||||||
|
// For others, only show manageable tenants' clients
|
||||||
|
var tenantIDs []string
|
||||||
|
for _, t := range profile.ManageableTenants {
|
||||||
|
tenantIDs = append(tenantIDs, t.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tenantIDs) == 0 && profile.TenantID != nil {
|
||||||
|
tenantIDs = append(tenantIDs, *profile.TenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tenantIDs) == 0 {
|
||||||
|
return c.JSON(clientListResponse{Items: []clientSummary{}, Limit: 50, Offset: 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
rps, err := h.RPService.ListByTenantIDs(c.Context(), tenantIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "clients not found"})
|
|
||||||
}
|
|
||||||
errMsg := err.Error()
|
|
||||||
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
|
||||||
"error": "Hydra service is unavailable. Please check if Ory Hydra is running.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errMsg})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]clientSummary, 0, len(clients))
|
items := make([]clientSummary, 0, len(rps))
|
||||||
for _, client := range clients {
|
for _, rp := range rps {
|
||||||
items = append(items, h.mapClientSummary(client))
|
// We need HydraClient details for the summary
|
||||||
|
client, err := h.Hydra.GetClient(c.Context(), rp.ClientID)
|
||||||
|
if err == nil {
|
||||||
|
items = append(items, h.mapClientSummary(*client))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(clientListResponse{
|
return c.JSON(clientListResponse{
|
||||||
Items: items,
|
Items: items,
|
||||||
Limit: limit,
|
Limit: len(items),
|
||||||
Offset: offset,
|
Offset: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,11 +219,46 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||||
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
var req clientUpsertRequest
|
var req clientUpsertRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine Tenant ID
|
||||||
|
targetTenantID := c.Get("X-Tenant-ID")
|
||||||
|
if targetTenantID == "" && profile.TenantID != nil {
|
||||||
|
targetTenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetTenantID == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "X-Tenant-ID header is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Permission
|
||||||
|
isAllowed := false
|
||||||
|
if profile.Role == domain.RoleSuperAdmin {
|
||||||
|
isAllowed = true
|
||||||
|
} else {
|
||||||
|
for _, t := range profile.ManageableTenants {
|
||||||
|
if t.ID == targetTenantID {
|
||||||
|
isAllowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isAllowed && profile.TenantID != nil && *profile.TenantID == targetTenantID {
|
||||||
|
isAllowed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isAllowed {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "you do not have permission to create clients for this tenant"})
|
||||||
|
}
|
||||||
|
|
||||||
clientID := strings.TrimSpace(valueOr(req.ID, ""))
|
clientID := strings.TrimSpace(valueOr(req.ID, ""))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
clientID = uuid.NewString()
|
clientID = uuid.NewString()
|
||||||
@@ -257,11 +314,18 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := h.Hydra.CreateClient(c.Context(), clientReq)
|
// Use RPService to ensure Keto relations are created
|
||||||
|
rp, err := h.RPService.Create(c.Context(), targetTenantID, clientReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch back the Hydra client to get the secret (RPService.Create returns domain.RelyingParty which has limited fields)
|
||||||
|
created, err := h.Hydra.GetClient(c.Context(), rp.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "client created but failed to retrieve details"})
|
||||||
|
}
|
||||||
|
|
||||||
// Store secret in metadata for later retrieval
|
// Store secret in metadata for later retrieval
|
||||||
if created.ClientSecret != "" {
|
if created.ClientSecret != "" {
|
||||||
// 1. Store in PostgreSQL (Source of Truth)
|
// 1. Store in PostgreSQL (Source of Truth)
|
||||||
@@ -403,11 +467,24 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||||
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permission Check
|
||||||
|
if profile.Role != domain.RoleSuperAdmin {
|
||||||
|
allowed, err := h.RPService.CheckPermission(c.Context(), profile.ID, clientID, "view")
|
||||||
|
if err != nil || !allowed {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: you do not have permission to view consents for this client"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
subject := strings.TrimSpace(c.Query("subject"))
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
@@ -484,12 +561,28 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||||
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
subject := strings.TrimSpace(c.Query("subject"))
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
|
||||||
}
|
}
|
||||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||||
|
|
||||||
|
// Permission Check (if clientID is provided)
|
||||||
|
if clientID != "" && profile.Role != domain.RoleSuperAdmin {
|
||||||
|
allowed, err := h.RPService.CheckPermission(c.Context(), profile.ID, clientID, "manage")
|
||||||
|
if err != nil || !allowed {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: you do not have permission to revoke consents for this client"})
|
||||||
|
}
|
||||||
|
} else if clientID == "" && profile.Role != domain.RoleSuperAdmin {
|
||||||
|
// If clientID is not provided, we might need a more global check or just disallow it for non-superadmins
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required for non-superadmins"})
|
||||||
|
}
|
||||||
|
|
||||||
// If subject is not a UUID, try to resolve it as an identifier (email/username)
|
// If subject is not a UUID, try to resolve it as an identifier (email/username)
|
||||||
if _, err := uuid.Parse(subject); err != nil {
|
if _, err := uuid.Parse(subject); err != nil {
|
||||||
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject)
|
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tenants []domain.Tenant
|
var tenants []domain.Tenant
|
||||||
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
|
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Preload("TenantGroup").Find(&tenants).Error; err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +341,7 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
|
|||||||
Status: t.Status,
|
Status: t.Status,
|
||||||
TenantGroupID: t.TenantGroupID,
|
TenantGroupID: t.TenantGroupID,
|
||||||
Domains: domains,
|
Domains: domains,
|
||||||
Config: t.Config,
|
Config: t.GetMergedConfig(),
|
||||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set tenant_id for audit logging if namespace is Tenant
|
||||||
|
if namespace == "Tenant" {
|
||||||
|
c.Locals("tenant_id", objectID)
|
||||||
|
}
|
||||||
|
|
||||||
// Check with Keto
|
// Check with Keto
|
||||||
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
||||||
if err != nil || !allowed {
|
if err != nil || !allowed {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type TenantRepository interface {
|
|||||||
FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||||
FindByName(ctx context.Context, name string) (*domain.Tenant, error)
|
FindByName(ctx context.Context, name string) (*domain.Tenant, error)
|
||||||
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
||||||
|
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
|
||||||
AddDomain(ctx context.Context, tenantID string, domainName string) error
|
AddDomain(ctx context.Context, tenantID string, domainName string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,17 @@ func (r *tenantRepository) FindByID(ctx context.Context, id string) (*domain.Ten
|
|||||||
return &tenant, nil
|
return &tenant, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *tenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
|
||||||
|
var tenants []domain.Tenant
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return tenants, nil
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Preload("Domains").Where("id IN ?", ids).Find(&tenants).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tenants, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||||
var tenant domain.Tenant
|
var tenant domain.Tenant
|
||||||
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil {
|
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type KetoService interface {
|
|||||||
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
|
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
|
||||||
DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error
|
DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error
|
||||||
ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error)
|
ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error)
|
||||||
|
ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ketoService struct {
|
type ketoService struct {
|
||||||
@@ -192,3 +193,40 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel
|
|||||||
slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ketoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||||
|
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.readURL))
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("namespace", namespace)
|
||||||
|
q.Set("relation", relation)
|
||||||
|
q.Set("subject_id", subject)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var res relationTuplesResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
objects := make([]string, 0, len(res.RelationTuples))
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, rt := range res.RelationTuples {
|
||||||
|
if !seen[rt.Object] {
|
||||||
|
objects = append(objects, rt.Object)
|
||||||
|
seen[rt.Object] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type RelyingPartyService interface {
|
|||||||
ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error)
|
ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error)
|
||||||
Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error)
|
Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error)
|
||||||
Delete(ctx context.Context, clientID string) error
|
Delete(ctx context.Context, clientID string) error
|
||||||
|
CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type relyingPartyService struct {
|
type relyingPartyService struct {
|
||||||
@@ -158,6 +159,10 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *relyingPartyService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) {
|
||||||
|
return s.ketoService.CheckPermission(ctx, userID, "RelyingParty", clientID, relation)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty {
|
func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type TenantService interface {
|
|||||||
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
||||||
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||||
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
|
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
|
||||||
|
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
||||||
ApproveTenant(ctx context.Context, id string) error
|
ApproveTenant(ctx context.Context, id string) error
|
||||||
SetKetoService(keto KetoService) // 추가
|
SetKetoService(keto KetoService) // 추가
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,60 @@ func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenan
|
|||||||
return s.repo.FindByID(ctx, id)
|
return s.repo.FindByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *tenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||||
|
if s.keto == nil {
|
||||||
|
return nil, errors.New("keto service not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Get directly managed tenants
|
||||||
|
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to list directly managed tenants from Keto", "userID", userID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get managed tenant groups
|
||||||
|
groupIDs, err := s.keto.ListObjects(ctx, "TenantGroup", "admins", userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to list managed tenant groups from Keto", "userID", userID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get tenants belonging to those groups
|
||||||
|
var groupInheritedTenantIDs []string
|
||||||
|
for _, groupID := range groupIDs {
|
||||||
|
// In Keto, we defined: Tenant#parent_group@TenantGroup:GroupID#_
|
||||||
|
// To find tenants in a group, we look for relations where namespace=Tenant, relation=parent_group, subject=TenantGroup:GroupID#_
|
||||||
|
// Wait, my ListObjects lists objects given a subject.
|
||||||
|
// So subject="TenantGroup:"+groupID+"#_"
|
||||||
|
// Object is Tenant ID.
|
||||||
|
ts, err := s.keto.ListRelations(ctx, "Tenant", "", "parent_group", "TenantGroup:"+groupID)
|
||||||
|
if err == nil {
|
||||||
|
for _, t := range ts {
|
||||||
|
groupInheritedTenantIDs = append(groupInheritedTenantIDs, t.Object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine and deduplicate IDs
|
||||||
|
allIDsMap := make(map[string]bool)
|
||||||
|
for _, id := range directTenantIDs {
|
||||||
|
allIDsMap[id] = true
|
||||||
|
}
|
||||||
|
for _, id := range groupInheritedTenantIDs {
|
||||||
|
allIDsMap[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
allIDs := make([]string, 0, len(allIDsMap))
|
||||||
|
for id := range allIDsMap {
|
||||||
|
allIDs = append(allIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allIDs) == 0 {
|
||||||
|
return []domain.Tenant{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.FindByIDs(ctx, allIDs)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
|
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
|
||||||
// Validate Slug
|
// Validate Slug
|
||||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||||
|
|||||||
Reference in New Issue
Block a user