forked from baron/baron-sso
Merge pull request 'feature/df-rebac' (#595) from feature/df-rebac into dev
Reviewed-on: baron/baron-sso#595
This commit is contained in:
@@ -3,6 +3,10 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
const port = Number.parseInt(process.env.PORT ?? "5173", 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI && !process.env.PORT;
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -34,7 +38,7 @@ export default defineConfig({
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:5173",
|
||||
baseURL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "retain-on-failure",
|
||||
@@ -60,12 +64,14 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? "npm run build && npm run preview -- --port 5173"
|
||||
: "npm run dev",
|
||||
url: "http://localhost:5173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
webServer: process.env.BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: process.env.CI
|
||||
? `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`
|
||||
: `npm run dev -- --host 127.0.0.1 --port ${port}`,
|
||||
url: defaultBaseUrl,
|
||||
reuseExistingServer,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -134,27 +134,23 @@ const SidebarNode: React.FC<{
|
||||
? "bg-primary text-primary-foreground font-semibold"
|
||||
: "hover:bg-muted/60 text-muted-foreground hover:text-foreground"
|
||||
} ${isMatching ? "ring-1 ring-primary/30 bg-primary/5" : ""}`}
|
||||
onClick={() => onSelect(node.id)}
|
||||
onClick={() => {
|
||||
onSelect(node.id);
|
||||
if (hasChildren) setIsExpanded(!isExpanded);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
{/* Indent & Expander */}
|
||||
<div style={{ width: `${level * 1.2}rem` }} className="shrink-0" />
|
||||
<div className="w-5 h-5 flex items-center justify-center mr-1">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-primary/20 rounded p-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
>
|
||||
<span className="rounded p-0.5">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
level > 0 && <div className="w-1 h-1 rounded-full bg-border" />
|
||||
)}
|
||||
@@ -457,7 +453,7 @@ function TenantUserGroupsTab() {
|
||||
{selectedNode.slug}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="flex items-center gap-2 mt-0.5">
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2 mt-0.5">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={12} /> {selectedNode.recursiveMemberCount}{" "}
|
||||
{t("ui.admin.tenants.table.members", "명")}
|
||||
@@ -469,7 +465,7 @@ function TenantUserGroupsTab() {
|
||||
selectedNode.type,
|
||||
)}
|
||||
</Badge>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ test.describe("Bulk Actions and Tree Search", () => {
|
||||
await searchInput.fill("Eng");
|
||||
|
||||
const engNode = page
|
||||
.locator("button")
|
||||
.locator('button, [role="button"]')
|
||||
.filter({ hasText: "Engineering" })
|
||||
.first();
|
||||
await expect(engNode).toBeVisible();
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
kratosAdmin := service.NewKratosAdminService()
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
identities, err := kratosAdmin.ListIdentities(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list identities: %v", err)
|
||||
@@ -22,7 +21,7 @@ func main() {
|
||||
for _, id := range identities {
|
||||
traits := id.Traits
|
||||
changed := false
|
||||
|
||||
|
||||
if r, ok := traits["role"].(string); ok {
|
||||
norm := domain.NormalizeRole(r)
|
||||
if norm != r && norm == domain.RoleUser {
|
||||
|
||||
@@ -290,7 +290,7 @@ func main() {
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||
adminHandler := handler.NewAdminHandler(ketoService)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, tenantService, authHandler)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, authHandler)
|
||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||
devHandler.AuditRepo = auditRepo
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
||||
@@ -662,12 +662,12 @@ func main() {
|
||||
// Relying Party Management (Tenant Context)
|
||||
admin.Post("/tenants/:tenantId/relying-parties",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"),
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "grant_dev_permissions"),
|
||||
relyingPartyHandler.Create)
|
||||
|
||||
admin.Get("/tenants/:tenantId/relying-parties",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"),
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view_dev_console"),
|
||||
relyingPartyHandler.List)
|
||||
|
||||
admin.Get("/relying-parties/:id",
|
||||
@@ -677,7 +677,7 @@ func main() {
|
||||
|
||||
admin.Put("/relying-parties/:id",
|
||||
requireAdmin,
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "edit_config"),
|
||||
relyingPartyHandler.Update)
|
||||
|
||||
admin.Delete("/relying-parties/:id",
|
||||
@@ -705,9 +705,13 @@ func main() {
|
||||
dev := api.Group("/dev")
|
||||
dev.Get("/stats", devHandler.GetStats)
|
||||
dev.Get("/my-tenants", devHandler.ListMyTenants)
|
||||
dev.Get("/users", devHandler.SearchUsers)
|
||||
dev.Get("/clients", devHandler.ListClients)
|
||||
dev.Post("/clients", devHandler.CreateClient)
|
||||
dev.Get("/clients/:id", devHandler.GetClient)
|
||||
dev.Get("/clients/:id/relations", devHandler.ListClientRelations)
|
||||
dev.Post("/clients/:id/relations", devHandler.AddClientRelation)
|
||||
dev.Delete("/clients/:id/relations", devHandler.RemoveClientRelation)
|
||||
dev.Put("/clients/:id", devHandler.UpdateClient)
|
||||
dev.Post("/clients/:id/headless-jwks/refresh", devHandler.RefreshHeadlessJWKSCache)
|
||||
dev.Delete("/clients/:id/headless-jwks/cache", devHandler.RevokeHeadlessJWKSCache)
|
||||
|
||||
@@ -42,6 +42,6 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.ClientConsent{},
|
||||
&domain.KetoOutbox{},
|
||||
&domain.SharedLink{},
|
||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||
)
|
||||
}
|
||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5413,7 +5413,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
profile.ManageableTenants = manageable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID)
|
||||
if err == nil {
|
||||
profile.JoinedTenants = joined
|
||||
|
||||
@@ -156,11 +156,26 @@ func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([]
|
||||
}
|
||||
func (m *mockConsentRepo) Delete(ctx context.Context, clientID, subject string) error { return nil }
|
||||
func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
||||
return nil, 0, nil
|
||||
results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents))
|
||||
for _, consent := range m.consents {
|
||||
if consent.ClientID == clientID {
|
||||
results = append(results, domain.ClientConsentWithTenantInfo{ClientConsent: consent})
|
||||
}
|
||||
}
|
||||
return results, int64(len(results)), nil
|
||||
}
|
||||
|
||||
func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
||||
return nil, 0, nil
|
||||
results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents))
|
||||
for _, consent := range m.consents {
|
||||
if consent.ClientID == clientID {
|
||||
results = append(results, domain.ClientConsentWithTenantInfo{
|
||||
ClientConsent: consent,
|
||||
TenantID: tenantID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results, int64(len(results)), nil
|
||||
}
|
||||
|
||||
// --- Mock Secret Repository ---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ import (
|
||||
|
||||
func TestDevHandler_Isolation(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
// Default Mock behavior: deny everything unless explicitly allowed
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
@@ -72,7 +74,6 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
req.Header.Set("Origin", "http://localhost:5174")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
// We expect 401 now because ListClients enforces authentication.
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
@@ -89,7 +90,8 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
mockKeto.On("CheckPermission", mock.Anything, "user-a", "System", "global", "manage_all").Return(true, nil)
|
||||
// Explicit permission for private client check bypass
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "System", "global", "manage_all").Return(true, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
@@ -100,17 +102,17 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
// Should only see client-tenant-a
|
||||
// Should only see client-tenant-a (tenant isolation)
|
||||
assert.Equal(t, 1, len(res.Items))
|
||||
assert.Equal(t, "client-tenant-a", res.Items[0].ID)
|
||||
})
|
||||
|
||||
t.Run("Tenant member should be forbidden from DevFront clients", func(t *testing.T) {
|
||||
t.Run("Tenant member should see empty list from DevFront clients if no relation", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-a",
|
||||
ID: "user-member",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantA,
|
||||
})
|
||||
@@ -120,7 +122,14 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res struct {
|
||||
Items []clientSummary `json:"items"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
// Empty list because we didn't mock any specific 'view' permissions for this user
|
||||
assert.Equal(t, 0, len(res.Items))
|
||||
})
|
||||
|
||||
t.Run("RP Admin should only see managed clients", func(t *testing.T) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --- Mocks with Unique Names to Avoid Collisions ---
|
||||
@@ -51,6 +52,91 @@ type devMockRedisRepo struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
type devMockKratosAdmin struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *devMockKratosAdmin) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).([]service.KratosIdentity), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockKratosAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
args := m.Called(ctx, identifier)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockKratosAdmin) GetIdentity(ctx context.Context, identityID string) (*service.KratosIdentity, error) {
|
||||
args := m.Called(ctx, identityID)
|
||||
if identity, ok := args.Get(0).(*service.KratosIdentity); ok {
|
||||
return identity, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
|
||||
args := m.Called(ctx, identityID, traits, state)
|
||||
if identity, ok := args.Get(0).(*service.KratosIdentity); ok {
|
||||
return identity, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockKratosAdmin) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
return m.Called(ctx, identityID, newPassword).Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockKratosAdmin) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
return m.Called(ctx, identityID).Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
args := m.Called(ctx, user, password)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockKratosAdmin) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
|
||||
args := m.Called(ctx, identityID)
|
||||
return args.Get(0).([]service.KratosSession), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockKratosAdmin) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
|
||||
args := m.Called(ctx, sessionID)
|
||||
if session, ok := args.Get(0).(*service.KratosSession); ok {
|
||||
return session, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockKratosAdmin) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return m.Called(ctx, sessionID).Error(0)
|
||||
}
|
||||
|
||||
type devMockKetoOutboxRepository struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error {
|
||||
return m.Called(ctx, entry).Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error {
|
||||
return m.Called(tx, entry).Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) {
|
||||
args := m.Called(ctx, limit)
|
||||
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
||||
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
|
||||
return m.Called(ctx, id).Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockRedisRepo) Set(key, value string, exp time.Duration) error {
|
||||
if m.data == nil {
|
||||
m.data = make(map[string]string)
|
||||
@@ -143,6 +229,54 @@ func TestListClients_Success(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-a", "status": "active"}},
|
||||
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-b", "status": "active"}},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "view_dev_console").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
tenantID := "tenant-a"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result clientListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
if assert.Len(t, result.Items, 1) {
|
||||
assert.Equal(t, "client-allowed", result.Items[0].ID)
|
||||
}
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestCreateClient_ReservedSystemNameForbidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
t.Fatalf("hydra should not be called when reserved system name is rejected")
|
||||
@@ -223,6 +357,83 @@ func TestUpdateClient_ReservedSystemNameForbidden(t *testing.T) {
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"redirect_uris": []string{
|
||||
"http://localhost/cb",
|
||||
},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile email offline_access",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
"tenant_id": "tenant-1",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One Updated",
|
||||
"redirect_uris": []string{
|
||||
"http://localhost/cb",
|
||||
},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile email offline_access",
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
"tenant_id": "tenant-1",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-1"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "App One Updated",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result clientDetailResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Equal(t, "App One Updated", result.Client.Name)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
@@ -377,6 +588,65 @@ func TestUpdateClientStatus_Success(t *testing.T) {
|
||||
assert.Equal(t, "inactive", res.Client.Status)
|
||||
}
|
||||
|
||||
func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]interface{}{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]interface{}{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "inactive",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-1"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res clientDetailResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&res)
|
||||
assert.Equal(t, "inactive", res.Client.Status)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||
@@ -510,6 +780,152 @@ func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]interface{}{
|
||||
"tenant_id": "tenant-b",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "rp-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"client_secret": "stored-secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "view_dev_console").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(false, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-1"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result clientDetailResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Empty(t, result.Client.ClientSecret)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"client_secret": "stored-secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "view_dev_console").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-1"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result clientDetailResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Equal(t, "stored-secret", result.Client.ClientSecret)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestRotateClientSecret_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
@@ -554,6 +970,59 @@ func TestRotateClientSecret_Success(t *testing.T) {
|
||||
assert.Equal(t, res.Client.ClientSecret, dbS)
|
||||
}
|
||||
|
||||
func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
body["client_secret"] = "generated-secret"
|
||||
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
|
||||
|
||||
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
||||
redisRepo := &devMockRedisRepo{data: make(map[string]string)}
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
SecretRepo: secretRepo,
|
||||
Redis: redisRepo,
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "rp-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"id": "client-1",
|
||||
"name": "App One",
|
||||
"type": "pkce",
|
||||
"redirectUris": []string{"http://localhost/cb"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetStats_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
@@ -1050,7 +1519,7 @@ func TestRevokeHeadlessJWKSCache_DeletesCachedState(t *testing.T) {
|
||||
assert.Nil(t, stored)
|
||||
}
|
||||
|
||||
func TestListAuditLogs_TenantMemberForbidden(t *testing.T) {
|
||||
func TestListAuditLogs_TenantMemberWithoutAuditPermissionReturnsEmpty(t *testing.T) {
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
||||
AuditRepo: &mockAuditRepo{},
|
||||
@@ -1071,7 +1540,11 @@ func TestListAuditLogs_TenantMemberForbidden(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result devAuditListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Empty(t, result.Items)
|
||||
}
|
||||
|
||||
func TestListAuditLogs_RPAdminScope(t *testing.T) {
|
||||
@@ -1124,3 +1597,466 @@ func TestListAuditLogs_RPAdminScope(t *testing.T) {
|
||||
assert.Len(t, result.Items, 1)
|
||||
assert.Equal(t, "evt-1", result.Items[0].EventID)
|
||||
}
|
||||
|
||||
func TestListAuditLogs_UserAllowedByRPAuditPermission(t *testing.T) {
|
||||
auditRepo := &mockAuditRepo{
|
||||
logs: []domain.AuditLog{
|
||||
{
|
||||
EventID: "evt-allowed",
|
||||
EventType: "POST /api/v1/dev/clients/client-allowed/secret/rotate",
|
||||
Status: "success",
|
||||
Timestamp: time.Now().UTC(),
|
||||
Details: `{"target_id":"client-allowed","tenant_id":"tenant-a","action":"ROTATE_SECRET"}`,
|
||||
},
|
||||
{
|
||||
EventID: "evt-allowed-path",
|
||||
EventType: "GET /api/v1/dev/clients/client-allowed/relations",
|
||||
Status: "success",
|
||||
Timestamp: time.Now().UTC().Add(-30 * time.Second),
|
||||
Details: `{"request_id":"req-1"}`,
|
||||
},
|
||||
{
|
||||
EventID: "evt-denied",
|
||||
EventType: "POST /api/v1/dev/clients/client-denied/secret/rotate",
|
||||
Status: "success",
|
||||
Timestamp: time.Now().UTC().Add(-time.Minute),
|
||||
Details: `{"target_id":"client-denied","tenant_id":"tenant-b","action":"ROTATE_SECRET"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
||||
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-b"}},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "audit_viewer").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "audit_viewer").Return(false, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
AuditRepo: auditRepo,
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?limit=50", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result devAuditListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
if assert.Len(t, result.Items, 2) {
|
||||
assert.Equal(t, "evt-allowed", result.Items[0].EventID)
|
||||
assert.Equal(t, "evt-allowed-path", result.Items[1].EventID)
|
||||
}
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestListConsents_UserAllowedByRPAdminsRelation(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_consents").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
ConsentRepo: &mockConsentRepo{
|
||||
consents: []domain.ClientConsent{
|
||||
{
|
||||
ClientID: "client-1",
|
||||
Subject: "subject-1",
|
||||
GrantedScopes: []string{"openid", "profile"},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
},
|
||||
},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-1"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/consents", h.ListConsents)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/consents?client_id=client-1", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result consentListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
if assert.Len(t, result.Items, 1) {
|
||||
assert.Equal(t, "client-1", result.Items[0].ClientID)
|
||||
assert.Equal(t, "subject-1", result.Items[0].Subject)
|
||||
}
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "").Return([]service.RelationTuple{
|
||||
{Object: "client-1", Relation: "config_editor", SubjectID: "User:user-2"},
|
||||
}, nil)
|
||||
for _, relation := range []string{"admins", "creator", "secret_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator"} {
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil)
|
||||
}
|
||||
mockKratos := new(devMockKratosAdmin)
|
||||
mockKratos.On("GetIdentity", mock.Anything, "user-2").Return(&service.KratosIdentity{
|
||||
ID: "user-2",
|
||||
Traits: map[string]interface{}{
|
||||
"name": "김용연",
|
||||
"email": "kyy@example.com",
|
||||
"id": "kyy01",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
KratosAdmin: mockKratos,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients/:id/relations", h.ListClientRelations)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/relations", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result clientRelationListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Len(t, result.Items, 1)
|
||||
assert.Equal(t, "config_editor", result.Items[0].Relation)
|
||||
assert.Equal(t, "User", result.Items[0].SubjectType)
|
||||
assert.Equal(t, "user-2", result.Items[0].SubjectID)
|
||||
assert.Equal(t, "김용연", result.Items[0].UserName)
|
||||
assert.Equal(t, "kyy@example.com", result.Items[0].UserEmail)
|
||||
assert.Equal(t, "kyy01", result.Items[0].UserLoginID)
|
||||
}
|
||||
|
||||
func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "grant_dev_permissions").Return(true, nil)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "User:user-2").Return([]service.RelationTuple{}, nil)
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "RelyingParty" &&
|
||||
entry.Object == "client-1" &&
|
||||
entry.Relation == "config_editor" &&
|
||||
entry.Subject == "User:user-2" &&
|
||||
entry.Action == domain.KetoOutboxActionCreate
|
||||
})).Return(nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
KetoOutbox: mockOutbox,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients/:id/relations", h.AddClientRelation)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"relation": "config_editor",
|
||||
"userId": "user-2",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-1/relations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil)
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "RelyingParty" &&
|
||||
entry.Object == "client-1" &&
|
||||
entry.Relation == "config_editor" &&
|
||||
entry.Subject == "User:user-2" &&
|
||||
entry.Action == domain.KetoOutboxActionDelete
|
||||
})).Return(nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
KetoOutbox: mockOutbox,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/api/v1/dev/clients/:id/relations", h.RemoveClientRelation)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-1/relations?relation=config_editor&subject=User:user-2", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKratos := new(devMockKratosAdmin)
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
||||
{
|
||||
ID: "user-1",
|
||||
Traits: map[string]interface{}{
|
||||
"name": "Alice Kim",
|
||||
"email": "alice@example.com",
|
||||
"id": "alice01",
|
||||
"tenant_id": "tenant-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "user-2",
|
||||
Traits: map[string]interface{}{
|
||||
"name": "Bob Lee",
|
||||
"email": "bob@example.com",
|
||||
"id": "bob01",
|
||||
"tenant_id": "tenant-2",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
KratosAdmin: mockKratos,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-9",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
ManageableTenants: []domain.Tenant{
|
||||
{ID: "tenant-1", Slug: "tenant-one"},
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"managed_client_ids": []any{"client-1"},
|
||||
},
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/users", h.SearchUsers)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?clientId=client-1&search=alice", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result devUserListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
if assert.Len(t, result.Items, 1) {
|
||||
assert.Equal(t, "user-1", result.Items[0].ID)
|
||||
assert.Equal(t, "Alice Kim", result.Items[0].Name)
|
||||
assert.Equal(t, "alice@example.com", result.Items[0].Email)
|
||||
}
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestSearchUsers_UserAllowedByRPAdminRelation(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil)
|
||||
|
||||
mockKratos := new(devMockKratosAdmin)
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
||||
{
|
||||
ID: "target-user",
|
||||
Traits: map[string]interface{}{
|
||||
"name": "김용연",
|
||||
"email": "kyy@example.com",
|
||||
"id": "kyy01",
|
||||
"tenant_id": "tenant-1",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
KratosAdmin: mockKratos,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-1"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/users", h.SearchUsers)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?clientId=client-1&search=김용연", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result devUserListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
if assert.Len(t, result.Items, 1) {
|
||||
assert.Equal(t, "target-user", result.Items[0].ID)
|
||||
assert.Equal(t, "김용연", result.Items[0].Name)
|
||||
}
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -868,7 +868,6 @@ func normalizeTenantType(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func (h *TenantHandler) CreateShareLink(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("id")
|
||||
var req struct {
|
||||
@@ -932,7 +931,9 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
curr := id
|
||||
for {
|
||||
p, exists := parentMap[curr]
|
||||
if !exists || p == "" { break }
|
||||
if !exists || p == "" {
|
||||
break
|
||||
}
|
||||
curr = p
|
||||
}
|
||||
return curr
|
||||
@@ -967,10 +968,14 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
var usersByID []domain.User
|
||||
h.DB.Where("tenant_id IN ?", tenantIDs).Preload("Tenant").Find(&usersByID)
|
||||
for _, u := range usersByID {
|
||||
if u.Status != "active" || seen[u.ID] { continue }
|
||||
if u.Status != "active" || seen[u.ID] {
|
||||
continue
|
||||
}
|
||||
seen[u.ID] = true
|
||||
cc := u.CompanyCode
|
||||
if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug }
|
||||
if cc == "" && u.Tenant != nil {
|
||||
cc = u.Tenant.Slug
|
||||
}
|
||||
publicUsers = append(publicUsers, publicUserSummary{
|
||||
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status,
|
||||
})
|
||||
@@ -980,10 +985,14 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
var usersBySlug []domain.User
|
||||
h.DB.Where("company_code IN ?", slugs).Preload("Tenant").Find(&usersBySlug)
|
||||
for _, u := range usersBySlug {
|
||||
if u.Status != "active" || seen[u.ID] { continue }
|
||||
if u.Status != "active" || seen[u.ID] {
|
||||
continue
|
||||
}
|
||||
seen[u.ID] = true
|
||||
cc := u.CompanyCode
|
||||
if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug }
|
||||
if cc == "" && u.Tenant != nil {
|
||||
cc = u.Tenant.Slug
|
||||
}
|
||||
publicUsers = append(publicUsers, publicUserSummary{
|
||||
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status,
|
||||
})
|
||||
@@ -995,8 +1004,8 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"tenants": tenantSummaries,
|
||||
"users": publicUsers,
|
||||
"tenants": tenantSummaries,
|
||||
"users": publicUsers,
|
||||
"sharedWith": link.Name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,24 +204,24 @@ func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: "super_admin",
|
||||
})
|
||||
return c.Next()
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: "super_admin",
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
|
||||
{ID: "t2", Name: "Tenant B", Slug: "slug-b"},
|
||||
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
|
||||
{ID: "t2", Name: "Tenant B", Slug: "slug-b"},
|
||||
}
|
||||
|
||||
// Mocking for the new allTenants check in ListTenants
|
||||
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
|
||||
|
||||
mockUserRepo.On("CountByCompanyCodes", mock.Anything, mock.Anything).
|
||||
Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe()
|
||||
Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe()
|
||||
mockUserRepo.On("CountByTenantIDs", mock.Anything, mock.Anything).
|
||||
Return(map[string]int64{}, nil).Maybe()
|
||||
Return(map[string]int64{}, nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -263,6 +263,7 @@ func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []s
|
||||
args := m.Called(ctx, tenantIDs)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) != nil {
|
||||
|
||||
@@ -133,7 +133,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
parentMap[t.ID] = *t.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Function to find the root of any given tenant
|
||||
findRoot := func(id string) string {
|
||||
curr := id
|
||||
@@ -331,17 +331,17 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
@@ -1305,7 +1305,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
traits["tenant_id"] = tenant.ID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add to existingCodes if not present
|
||||
found := false
|
||||
for _, existing := range existingCodes {
|
||||
|
||||
@@ -179,6 +179,7 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
||||
EventID: reqID,
|
||||
Timestamp: start,
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
SessionID: sessionID,
|
||||
EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()),
|
||||
Status: statusText,
|
||||
|
||||
@@ -126,6 +126,7 @@ func TestAuditMiddleware(t *testing.T) {
|
||||
}))
|
||||
|
||||
app.Post("/test", func(c *fiber.Ctx) error {
|
||||
c.Locals("tenant_id", "tenant-a")
|
||||
c.Locals("audit_details_extra", map[string]any{
|
||||
"client_id": "rp-1",
|
||||
"client_name": "Demo App",
|
||||
@@ -145,6 +146,9 @@ func TestAuditMiddleware(t *testing.T) {
|
||||
if details["client_name"] != "Demo App" {
|
||||
return false
|
||||
}
|
||||
if log.TenantID != "tenant-a" || details["tenant_id"] != "tenant-a" {
|
||||
return false
|
||||
}
|
||||
skip, ok := details["auth_timeline_skip"].(bool)
|
||||
return ok && skip
|
||||
})).Return(nil)
|
||||
|
||||
@@ -118,8 +118,8 @@ func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *
|
||||
args := make([]any, 0, 5)
|
||||
|
||||
if tenantID != "" {
|
||||
query += " AND tenant_id = ?"
|
||||
args = append(args, tenantID)
|
||||
query += " AND (tenant_id = ? OR (tenant_id = '' AND JSONExtractString(details, 'tenant_id') = ?))"
|
||||
args = append(args, tenantID, tenantID)
|
||||
}
|
||||
|
||||
if cursor != nil {
|
||||
|
||||
@@ -61,7 +61,7 @@ func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Perform Upsert based on ID.
|
||||
// 2. Perform Upsert based on ID.
|
||||
// In GORM v2, true upsert requires Create() with OnConflict on the primary key.
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
|
||||
@@ -94,6 +94,7 @@ func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identity
|
||||
}
|
||||
return args.Get(0).(*KratosIdentity), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
||||
args := m.Called(ctx, identityID, traits, state)
|
||||
if args.Get(0) == nil {
|
||||
@@ -120,9 +121,11 @@ func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *dom
|
||||
func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import (
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
var whitespaceRegex = regexp.MustCompile(`\s+`)
|
||||
var nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`)
|
||||
var (
|
||||
whitespaceRegex = regexp.MustCompile(`\s+`)
|
||||
nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`)
|
||||
)
|
||||
|
||||
type ProgressData struct {
|
||||
Current int `json:"current"`
|
||||
@@ -30,12 +32,12 @@ type ProgressData struct {
|
||||
var ImportProgressCache sync.Map
|
||||
|
||||
type ImportResult struct {
|
||||
TotalRows int `json:"totalRows"`
|
||||
Processed int `json:"processed"`
|
||||
UserCreated int `json:"userCreated"`
|
||||
UserUpdated int `json:"userUpdated"`
|
||||
TenantCreated int `json:"tenantCreated"`
|
||||
Errors []string `json:"errors"`
|
||||
TotalRows int `json:"totalRows"`
|
||||
Processed int `json:"processed"`
|
||||
UserCreated int `json:"userCreated"`
|
||||
UserUpdated int `json:"userUpdated"`
|
||||
TenantCreated int `json:"tenantCreated"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
type OrgChartService interface {
|
||||
@@ -86,13 +88,13 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
}
|
||||
|
||||
fieldMapping := map[string][]string{
|
||||
"email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"},
|
||||
"name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"},
|
||||
"position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"},
|
||||
"jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"},
|
||||
"phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"},
|
||||
"company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"},
|
||||
"is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"},
|
||||
"email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"},
|
||||
"name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"},
|
||||
"position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"},
|
||||
"jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"},
|
||||
"phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"},
|
||||
"company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"},
|
||||
"is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"},
|
||||
}
|
||||
|
||||
var dataRows [][]string
|
||||
@@ -102,11 +104,15 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
|
||||
for sheetIdx, records := range allSheetsRecords {
|
||||
for i, row := range records {
|
||||
if len(row) < 2 { continue }
|
||||
if len(row) < 2 {
|
||||
continue
|
||||
}
|
||||
tempMap := make(map[string]int)
|
||||
for j, cell := range row {
|
||||
clean := s.cleanHeader(cell)
|
||||
if clean != "" { tempMap[clean] = j }
|
||||
if clean != "" {
|
||||
tempMap[clean] = j
|
||||
}
|
||||
}
|
||||
emailIdx := s.findBestMatch(tempMap, fieldMapping["email"])
|
||||
nameIdx := s.findBestMatch(tempMap, fieldMapping["name"])
|
||||
@@ -114,7 +120,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
for j, cell := range row {
|
||||
c := s.cleanHeader(cell)
|
||||
if strings.Contains(c, "mail") || strings.Contains(c, "계정") || strings.Contains(c, "id") || strings.Contains(c, "사번") {
|
||||
emailIdx = j; break
|
||||
emailIdx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,13 +131,17 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
for key, aliases := range fieldMapping {
|
||||
actualMap[key] = s.findBestMatch(tempMap, aliases)
|
||||
}
|
||||
if actualMap["email"] == -1 { actualMap["email"] = emailIdx }
|
||||
if actualMap["email"] == -1 {
|
||||
actualMap["email"] = emailIdx
|
||||
}
|
||||
found = true
|
||||
slog.Info("Found header row", "sheet", sheetIdx, "row", i)
|
||||
break
|
||||
}
|
||||
}
|
||||
if found { break }
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
@@ -173,19 +184,25 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
}
|
||||
|
||||
for rowIdx, record := range dataRows {
|
||||
if len(record) == 0 { continue }
|
||||
if len(record) == 0 {
|
||||
continue
|
||||
}
|
||||
email := s.getVal(record, actualMap["email"])
|
||||
name := s.getVal(record, actualMap["name"])
|
||||
if email == "" || name == "" { continue }
|
||||
if email == "" || name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
position := s.getVal(record, actualMap["position"])
|
||||
jobTitle := s.getVal(record, actualMap["jobtitle"])
|
||||
phone := s.normalizePhone(s.getVal(record, actualMap["phone"]))
|
||||
|
||||
companyName := s.getVal(record, actualMap["company"])
|
||||
if companyName == "" { companyName = "Main" }
|
||||
if companyName == "" {
|
||||
companyName = "Main"
|
||||
}
|
||||
companySlug := s.generateCompanySlug(companyName)
|
||||
|
||||
|
||||
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err))
|
||||
@@ -196,8 +213,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
var orgParts []string
|
||||
for _, idx := range hierarchyIdx {
|
||||
val := s.getVal(record, idx)
|
||||
if val != "" && val != "-" {
|
||||
orgParts = append(orgParts, val)
|
||||
if val != "" && val != "-" {
|
||||
orgParts = append(orgParts, val)
|
||||
}
|
||||
}
|
||||
orgPath := strings.Join(orgParts, " > ")
|
||||
@@ -217,9 +234,9 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
grade := "member"
|
||||
if idx := actualMap["is_owner"]; idx != -1 && idx < len(record) {
|
||||
grade = strings.TrimSpace(record[idx])
|
||||
isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" ||
|
||||
strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") ||
|
||||
strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장")
|
||||
isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" ||
|
||||
strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") ||
|
||||
strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장")
|
||||
}
|
||||
|
||||
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
|
||||
@@ -231,7 +248,7 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
brokerUser := &domain.BrokerUser{
|
||||
Email: email, Name: name, PhoneNumber: phone,
|
||||
Attributes: map[string]interface{}{
|
||||
"affiliationType": "AFFILIATE", "companyCode": companySlug,
|
||||
"affiliationType": "AFFILIATE", "companyCode": companySlug,
|
||||
"department": orgPath, "grade": grade, "position": position,
|
||||
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
|
||||
},
|
||||
@@ -244,7 +261,7 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
result.UserCreated++
|
||||
} else {
|
||||
traits := map[string]interface{}{
|
||||
"name": name, "companyCode": companySlug, "department": orgPath,
|
||||
"name": name, "companyCode": companySlug, "department": orgPath,
|
||||
"grade": grade, "position": position, "affiliationType": "AFFILIATE",
|
||||
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
|
||||
}
|
||||
@@ -257,8 +274,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
|
||||
err = s.userRepo.Update(ctx, &domain.User{
|
||||
ID: kratosID, Email: email, Name: name, Phone: phone, Position: position,
|
||||
JobTitle: jobTitle, Department: orgPath,
|
||||
TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity
|
||||
JobTitle: jobTitle, Department: orgPath,
|
||||
TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity
|
||||
CompanyCode: companySlug, AffiliationType: "AFFILIATE", Status: "active", UpdatedAt: time.Now(), Role: domain.RoleUser,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -269,31 +286,31 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
if s.ketoOutboxRepo != nil {
|
||||
// 1. [Redundant Assignment] Always assign to the Legal Company Tenant
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: companyTenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Namespace: "Tenant",
|
||||
Object: companyTenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
|
||||
// 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists)
|
||||
if leafID != companyTenantID {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 3. Assign ownership if leader
|
||||
if isOwner {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "owners",
|
||||
Subject: "User:" + kratosID,
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "owners",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
@@ -315,26 +332,32 @@ func (s *orgChartService) cleanHeader(val string) string {
|
||||
func (s *orgChartService) findBestMatch(tempMap map[string]int, aliases []string) int {
|
||||
for _, alias := range aliases {
|
||||
ca := s.cleanHeader(alias)
|
||||
if idx, ok := tempMap[ca]; ok { return idx }
|
||||
if idx, ok := tempMap[ca]; ok {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
for cleaned, idx := range tempMap {
|
||||
for _, alias := range aliases {
|
||||
ca := s.cleanHeader(alias)
|
||||
if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) { return idx }
|
||||
if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (s *orgChartService) getVal(record []string, idx int) string {
|
||||
if idx == -1 || idx >= len(record) { return "" }
|
||||
if idx == -1 || idx >= len(record) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
|
||||
func (s *orgChartService) normalizePhone(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
|
||||
|
||||
re := regexp.MustCompile(`[^0-9+]`)
|
||||
normalized = re.ReplaceAllString(normalized, "")
|
||||
|
||||
@@ -354,13 +377,15 @@ func (s *orgChartService) normalizePhone(phone string) string {
|
||||
}
|
||||
return "+82" + normalized
|
||||
}
|
||||
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil { return nil, err }
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))))
|
||||
reader.LazyQuotes = true
|
||||
reader.FieldsPerRecord = -1
|
||||
@@ -369,11 +394,15 @@ func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) {
|
||||
|
||||
func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
|
||||
f, err := excelize.OpenReader(r)
|
||||
if err != nil { return nil, err }
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var allRecords [][][]string
|
||||
for _, sheet := range f.GetSheetList() {
|
||||
if rows, err := f.GetRows(sheet); err == nil { allRecords = append(allRecords, rows) }
|
||||
if rows, err := f.GetRows(sheet); err == nil {
|
||||
allRecords = append(allRecords, rows)
|
||||
}
|
||||
}
|
||||
return allRecords, nil
|
||||
}
|
||||
@@ -381,18 +410,22 @@ func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
|
||||
func (s *orgChartService) generateCompanySlug(name string) string {
|
||||
n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, ""))
|
||||
slugs := map[string]string{
|
||||
"한맥": "hanmac", "삼안": "saman", "장헌": "jangheon",
|
||||
"한맥": "hanmac", "삼안": "saman", "장헌": "jangheon",
|
||||
"ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla",
|
||||
}
|
||||
for k, v := range slugs {
|
||||
if strings.Contains(n, k) || strings.Contains(n, v) { return v }
|
||||
if strings.Contains(n, k) || strings.Contains(n, v) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return utils.GenerateSlug(name)
|
||||
}
|
||||
|
||||
func isAlphaNumeric(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { return false }
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -411,8 +444,10 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name,
|
||||
}
|
||||
|
||||
cacheKey := "company:" + slug
|
||||
if id, ok := cache[cacheKey]; ok { return id, nil }
|
||||
|
||||
if id, ok := cache[cacheKey]; ok {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
tenant, _ := s.tenantRepo.FindBySlug(ctx, slug)
|
||||
if tenant == nil {
|
||||
tenant, _ = s.tenantRepo.FindByName(ctx, name)
|
||||
@@ -420,17 +455,23 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name,
|
||||
|
||||
if tenant == nil {
|
||||
tenant = &domain.Tenant{ID: uuid.NewString(), Name: name, Slug: slug, Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, ParentID: &rootID}
|
||||
if err := s.tenantRepo.Create(ctx, tenant); err != nil { return "", err }
|
||||
if err := s.tenantRepo.Create(ctx, tenant); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.ketoOutboxRepo != nil {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + rootID, Action: domain.KetoOutboxActionCreate})
|
||||
}
|
||||
res.TenantCreated++
|
||||
}
|
||||
|
||||
|
||||
domainPart := ""
|
||||
if parts := strings.Split(email, "@"); len(parts) == 2 { domainPart = parts[1] }
|
||||
if domainPart != "" { _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true) }
|
||||
|
||||
if parts := strings.Split(email, "@"); len(parts) == 2 {
|
||||
domainPart = parts[1]
|
||||
}
|
||||
if domainPart != "" {
|
||||
_ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true)
|
||||
}
|
||||
|
||||
cache[cacheKey] = tenant.ID
|
||||
return tenant.ID, nil
|
||||
}
|
||||
@@ -440,12 +481,19 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
|
||||
currentPath := ""
|
||||
for i, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == "-" { continue }
|
||||
if currentPath == "" { currentPath = part } else { currentPath += "/" + part }
|
||||
|
||||
if part == "" || part == "-" {
|
||||
continue
|
||||
}
|
||||
if currentPath == "" {
|
||||
currentPath = part
|
||||
} else {
|
||||
currentPath += "/" + part
|
||||
}
|
||||
|
||||
cacheKey := rootTenantID + ":" + currentPath
|
||||
if id, ok := cache[cacheKey]; ok {
|
||||
currentParentID = id; continue
|
||||
currentParentID = id
|
||||
continue
|
||||
}
|
||||
|
||||
var existingID string
|
||||
@@ -454,7 +502,8 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
|
||||
isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID)
|
||||
isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID)
|
||||
if g.Name == part && (isTopMatch || isSubMatch) {
|
||||
existingID = g.ID; break
|
||||
existingID = g.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,16 +513,16 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
|
||||
groupSlug := fmt.Sprintf("ug-%s", existingID[:13])
|
||||
|
||||
if err := s.tenantRepo.Create(ctx, &domain.Tenant{
|
||||
ID: existingID,
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
Slug: groupSlug,
|
||||
ID: existingID,
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
Slug: groupSlug,
|
||||
Status: domain.TenantStatusActive,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
var ugParentID *string
|
||||
if currentParentID != rootTenantID {
|
||||
pid := currentParentID
|
||||
@@ -481,10 +530,10 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
|
||||
}
|
||||
|
||||
if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{
|
||||
ID: existingID,
|
||||
TenantID: rootTenantID,
|
||||
ParentID: ugParentID,
|
||||
Name: part,
|
||||
ID: existingID,
|
||||
TenantID: rootTenantID,
|
||||
ParentID: ugParentID,
|
||||
Name: part,
|
||||
UnitType: s.guessUnitType(i, len(parts)),
|
||||
}); err != nil {
|
||||
return "", err
|
||||
@@ -501,7 +550,11 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
|
||||
}
|
||||
|
||||
func (s *orgChartService) guessUnitType(index, total int) string {
|
||||
if total == 1 { return "Team" }
|
||||
if index == 0 { return "Division" }
|
||||
if total == 1 {
|
||||
return "Team"
|
||||
}
|
||||
if index == 0 {
|
||||
return "Division"
|
||||
}
|
||||
return "Team"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/xuri/excelize/v2"
|
||||
@@ -241,9 +241,11 @@ func TestImportOrgChart_MessyHeader(t *testing.T) {
|
||||
func (m *mockKratosService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RelyingPartyService interface {
|
||||
@@ -24,6 +25,21 @@ type relyingPartyService struct {
|
||||
outboxRepo repository.KetoOutboxRepository
|
||||
}
|
||||
|
||||
var defaultRelyingPartyOperatorRelations = []string{
|
||||
"admins",
|
||||
"creator",
|
||||
"config_editor",
|
||||
"secret_viewer",
|
||||
"secret_rotator",
|
||||
"jwks_viewer",
|
||||
"jwks_operator",
|
||||
"consent_viewer",
|
||||
"consent_revoker",
|
||||
"relationship_viewer",
|
||||
"audit_viewer",
|
||||
"status_operator",
|
||||
}
|
||||
|
||||
func NewRelyingPartyService(
|
||||
hydraService *HydraAdminService,
|
||||
ketoService KetoService,
|
||||
@@ -36,6 +52,51 @@ func NewRelyingPartyService(
|
||||
}
|
||||
}
|
||||
|
||||
func extractRelyingPartyCreatorSubject(client *domain.HydraClient) string {
|
||||
if client == nil || client.Metadata == nil {
|
||||
return ""
|
||||
}
|
||||
raw, _ := client.Metadata["user_id"].(string)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
return "User:" + raw
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) enqueueRelyingPartyTuple(ctx context.Context, action, object, relation, subject string) {
|
||||
if s.outboxRepo == nil || strings.TrimSpace(object) == "" || strings.TrimSpace(relation) == "" || strings.TrimSpace(subject) == "" {
|
||||
return
|
||||
}
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: object,
|
||||
Relation: relation,
|
||||
Subject: subject,
|
||||
Action: action,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) enqueueDefaultRelyingPartyRelations(ctx context.Context, action string, client *domain.HydraClient, tenantID string) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID != "" {
|
||||
s.enqueueRelyingPartyTuple(ctx, action, client.ClientID, "parents", "Tenant:"+tenantID)
|
||||
}
|
||||
|
||||
creatorSubject := extractRelyingPartyCreatorSubject(client)
|
||||
if creatorSubject == "" {
|
||||
return
|
||||
}
|
||||
|
||||
for _, relation := range defaultRelyingPartyOperatorRelations {
|
||||
s.enqueueRelyingPartyTuple(ctx, action, client.ClientID, relation, creatorSubject)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
||||
// 1. Create Client in Hydra
|
||||
if client.Metadata == nil {
|
||||
@@ -48,17 +109,8 @@ func (s *relyingPartyService) Create(ctx context.Context, tenantID string, clien
|
||||
return nil, fmt.Errorf("failed to create hydra client: %w", err)
|
||||
}
|
||||
|
||||
// 2. Create Relation in Keto via Outbox
|
||||
// RelyingParty:<client_id>#parents@Tenant:<tenant_id>
|
||||
if s.outboxRepo != nil {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: createdClient.ClientID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + tenantID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
// 2. Create default relations in Keto via Outbox.
|
||||
s.enqueueDefaultRelyingPartyRelations(ctx, domain.KetoOutboxActionCreate, createdClient, tenantID)
|
||||
|
||||
return s.mapHydraToDomain(createdClient), nil
|
||||
}
|
||||
@@ -137,16 +189,8 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Delete from Keto via Outbox
|
||||
if s.outboxRepo != nil && tenantID != "" {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: clientID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + tenantID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
// 3. Delete default relations from Keto via Outbox.
|
||||
s.enqueueDefaultRelyingPartyRelations(ctx, domain.KetoOutboxActionDelete, client, tenantID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
|
||||
tenantID := "tenant-1"
|
||||
inputClient := domain.HydraClient{
|
||||
ClientName: "Test App",
|
||||
Metadata: map[string]interface{}{
|
||||
"user_id": "creator-1",
|
||||
},
|
||||
}
|
||||
|
||||
// Hydra Mock
|
||||
@@ -81,6 +84,12 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "RelyingParty" && e.Object == "generated-client-id" && e.Relation == "parents" && e.Subject == "Tenant:"+tenantID
|
||||
})).Return(nil)
|
||||
for _, relation := range defaultRelyingPartyOperatorRelations {
|
||||
rel := relation
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "RelyingParty" && e.Object == "generated-client-id" && e.Relation == rel && e.Subject == "User:creator-1"
|
||||
})).Return(nil)
|
||||
}
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
|
||||
rp, err := svc.Create(context.Background(), tenantID, inputClient)
|
||||
@@ -173,6 +182,7 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
|
||||
ClientID: clientID,
|
||||
Metadata: map[string]interface{}{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": "creator-1",
|
||||
},
|
||||
})
|
||||
return
|
||||
@@ -192,6 +202,12 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "RelyingParty" && e.Object == clientID && e.Relation == "parents" && e.Subject == "Tenant:"+tenantID
|
||||
})).Return(nil)
|
||||
for _, relation := range defaultRelyingPartyOperatorRelations {
|
||||
rel := relation
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||
return e.Namespace == "RelyingParty" && e.Object == clientID && e.Relation == rel && e.Subject == "User:creator-1"
|
||||
})).Return(nil)
|
||||
}
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
|
||||
err := svc.Delete(context.Background(), clientID)
|
||||
|
||||
@@ -104,9 +104,15 @@ func (s *tenantService) ListJoinedTenants(ctx context.Context, userID string) ([
|
||||
adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||
|
||||
idMap := make(map[string]bool)
|
||||
for _, id := range memberIDs { idMap[id] = true }
|
||||
for _, id := range ownerIDs { idMap[id] = true }
|
||||
for _, id := range adminIDs { idMap[id] = true }
|
||||
for _, id := range memberIDs {
|
||||
idMap[id] = true
|
||||
}
|
||||
for _, id := range ownerIDs {
|
||||
idMap[id] = true
|
||||
}
|
||||
for _, id := range adminIDs {
|
||||
idMap[id] = true
|
||||
}
|
||||
|
||||
allIDs := make([]string, 0, len(idMap))
|
||||
for id := range idMap {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
const skipWebServer =
|
||||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
|
||||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true";
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5174";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -30,7 +34,7 @@ export default defineConfig({
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:5174",
|
||||
baseURL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
@@ -55,11 +59,13 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? "npm run build && npm run preview -- --port 5174"
|
||||
: "npm run dev -- --port 5174",
|
||||
url: "http://localhost:5174",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
webServer: skipWebServer
|
||||
? undefined
|
||||
: {
|
||||
command: process.env.CI
|
||||
? "npm run build && npm run preview -- --port 5174"
|
||||
: "npm run dev -- --port 5174",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import LoginPage from "../features/auth/LoginPage";
|
||||
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
|
||||
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
|
||||
import ClientsPage from "../features/clients/ClientsPage";
|
||||
import ProfilePage from "../features/profile/ProfilePage";
|
||||
|
||||
@@ -33,6 +34,10 @@ export const router = createBrowserRouter(
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||
{
|
||||
path: "clients/:id/relationships",
|
||||
element: <ClientRelationsPage />,
|
||||
},
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "profile", element: <ProfilePage /> },
|
||||
],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
|
||||
interface Props {
|
||||
resourceToken: "audit" | "clients";
|
||||
resourceToken: "audit" | "clients" | "consents";
|
||||
}
|
||||
|
||||
export function ForbiddenMessage({ resourceToken }: Props) {
|
||||
@@ -28,17 +28,33 @@ export function ForbiddenMessage({ resourceToken }: Props) {
|
||||
"테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.",
|
||||
);
|
||||
} else if (role === "user" || role === "tenant_member") {
|
||||
explanation = t(
|
||||
"msg.dev.forbidden.user",
|
||||
"일반 사용자는 관리자 화면에 접근할 수 없습니다.",
|
||||
);
|
||||
if (resourceToken === "consents") {
|
||||
explanation = t(
|
||||
"msg.dev.forbidden.user.consents",
|
||||
"해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.",
|
||||
);
|
||||
} else if (resourceToken === "audit") {
|
||||
explanation = t(
|
||||
"msg.dev.forbidden.user.audit",
|
||||
"해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.",
|
||||
);
|
||||
} else {
|
||||
explanation = t(
|
||||
"msg.dev.forbidden.user.clients",
|
||||
"일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const resourceLabel =
|
||||
resourceToken === "audit"
|
||||
? t("ui.dev.audit.title", "Audit Logs")
|
||||
: resourceToken === "consents"
|
||||
? t("ui.dev.clients.consents.title", "User Consent Grants")
|
||||
: t("ui.dev.clients.registry.subtitle", "연동 앱");
|
||||
|
||||
const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", {
|
||||
resource:
|
||||
resourceToken === "audit"
|
||||
? t("ui.dev.audit.title", "Audit Logs")
|
||||
: t("ui.dev.clients.registry.subtitle", "연동 앱"),
|
||||
resource: resourceLabel,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -270,11 +270,6 @@ function AppLayout() {
|
||||
);
|
||||
const displayRoleKey = profile?.role || currentRole;
|
||||
|
||||
const isDevConsoleAllowed = [
|
||||
"super_admin",
|
||||
"tenant_admin",
|
||||
"rp_admin",
|
||||
].includes(currentRole);
|
||||
const expiresAtSec = auth.user?.expires_at;
|
||||
const remainingMs =
|
||||
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
||||
@@ -360,24 +355,23 @@ function AppLayout() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{isDevConsoleAllowed &&
|
||||
navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
@@ -18,39 +16,5 @@ export default function AuthGuard() {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const normalizedRole = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
const isTenantMember =
|
||||
normalizedRole === "user" || normalizedRole === "tenant_member";
|
||||
|
||||
if (isTenantMember) {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center bg-background text-foreground p-6">
|
||||
<div className="max-w-lg w-full rounded-xl border border-border bg-card p-6 space-y-4">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t("msg.dev.auth.access_denied_title", "접근 권한이 없습니다.")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.auth.access_denied_description",
|
||||
"DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요.",
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||
onClick={() => {
|
||||
auth.removeUser();
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
>
|
||||
{t("ui.common.back_to_login", "로그인으로 돌아가기")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
|
||||
function ClientConsentsPage() {
|
||||
const params = useParams();
|
||||
@@ -160,6 +163,57 @@ function ClientConsentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-4">
|
||||
<div className="flex flex-wrap justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link to="/" className="hover:text-primary">
|
||||
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link to="/clients" className="hover:text-primary">
|
||||
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>{clientData?.client?.name || clientId}</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.consents.breadcrumb.current",
|
||||
"User Consent Grants",
|
||||
)}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={`/clients/${clientId}`}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<p className="text-3xl font-black leading-tight">
|
||||
{t(
|
||||
"ui.dev.clients.consents.title",
|
||||
"User Consent Grants",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClientDetailTabs activeTab="consents" clientId={clientId} />
|
||||
</header>
|
||||
<ForbiddenMessage resourceToken="consents" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-4">
|
||||
@@ -214,23 +268,7 @@ function ClientConsentsPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||
<Link
|
||||
to={`/clients/${clientId}`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||
</Link>
|
||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
</span>
|
||||
<Link
|
||||
to={`/clients/${clientId}/settings`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</Link>
|
||||
</div>
|
||||
<ClientDetailTabs activeTab="consents" clientId={clientId} />
|
||||
</header>
|
||||
|
||||
<Card className="glass-panel">
|
||||
@@ -374,18 +412,20 @@ function ClientConsentsPage() {
|
||||
|
||||
<Card className="glass-panel">
|
||||
{error && (
|
||||
<CardContent className="text-sm text-red-500">
|
||||
<CardContent className="py-8 text-center text-sm text-destructive border-b border-border/50">
|
||||
{t(
|
||||
"msg.dev.clients.consents.load_error",
|
||||
"Error loading consents: {{error}}",
|
||||
{
|
||||
error: (error as Error).message,
|
||||
error:
|
||||
(error as AxiosError<{ error?: string }>).response?.data
|
||||
?.error ?? (error as Error).message,
|
||||
},
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
{isLoading && (
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground border-b border-border/50">
|
||||
{t("msg.dev.clients.consents.loading", "Loading consents...")}
|
||||
</CardContent>
|
||||
)}
|
||||
@@ -423,10 +463,21 @@ function ClientConsentsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.length === 0 && !isLoading ? (
|
||||
{filteredRows.length === 0 && !isLoading && !error ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
{t("msg.dev.clients.consents.empty", "No consents found.")}
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Search className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.dev.clients.consents.empty",
|
||||
"No consents found.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
||||
54
devfront/src/features/clients/ClientDetailTabs.tsx
Normal file
54
devfront/src/features/clients/ClientDetailTabs.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type ClientDetailTab = "connection" | "consents" | "settings" | "relationships";
|
||||
|
||||
interface ClientDetailTabsProps {
|
||||
activeTab: ClientDetailTab;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
const tabOrder: Array<{
|
||||
key: ClientDetailTab;
|
||||
href: (clientId: string) => string;
|
||||
}> = [
|
||||
{ key: "connection", href: (clientId) => `/clients/${clientId}` },
|
||||
{ key: "consents", href: (clientId) => `/clients/${clientId}/consents` },
|
||||
{ key: "settings", href: (clientId) => `/clients/${clientId}/settings` },
|
||||
{
|
||||
key: "relationships",
|
||||
href: (clientId) => `/clients/${clientId}/relationships`,
|
||||
},
|
||||
];
|
||||
|
||||
export function ClientDetailTabs({
|
||||
activeTab,
|
||||
clientId,
|
||||
}: ClientDetailTabsProps) {
|
||||
return (
|
||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||
{tabOrder.map((tab) => {
|
||||
const isActive = tab.key === activeTab;
|
||||
return isActive ? (
|
||||
<span
|
||||
key={tab.key}
|
||||
className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary"
|
||||
>
|
||||
{t(`ui.dev.clients.details.tab.${tab.key}`)}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
key={tab.key}
|
||||
to={tab.href(clientId)}
|
||||
className={cn(
|
||||
"whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`ui.dev.clients.details.tab.${tab.key}`)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
|
||||
function ClientDetailsPage() {
|
||||
const params = useParams();
|
||||
@@ -83,9 +84,24 @@ function ClientDetailsPage() {
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
const axiosError = err as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.save_forbidden",
|
||||
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
|
||||
),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast(
|
||||
t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", {
|
||||
error: (err as Error).message,
|
||||
error:
|
||||
axiosError.response?.data?.error ??
|
||||
(err as Error).message ??
|
||||
t("msg.common.unknown_error", "unknown error"),
|
||||
}),
|
||||
"error",
|
||||
);
|
||||
@@ -253,26 +269,7 @@ function ClientDetailsPage() {
|
||||
: t("msg.common.loading", "Loading...")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-6 border-b border-border">
|
||||
<Link
|
||||
to={`/clients/${clientId}`}
|
||||
className="border-b-2 border-primary pb-3 text-sm font-bold text-primary"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/consents`}
|
||||
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/settings`}
|
||||
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</Link>
|
||||
</div>
|
||||
<ClientDetailTabs activeTab="connection" clientId={clientId} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
|
||||
interface ScopeItem {
|
||||
id: string;
|
||||
@@ -526,8 +527,19 @@ function ClientGeneralPage() {
|
||||
alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
|
||||
},
|
||||
onError: (err) => {
|
||||
const axiosError = err as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
alert(
|
||||
t(
|
||||
"msg.dev.clients.general.save_forbidden",
|
||||
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
axiosError.response?.data?.error ??
|
||||
(err as Error)?.message ??
|
||||
t("msg.common.unknown_error", "unknown error");
|
||||
alert(
|
||||
@@ -665,27 +677,9 @@ function ClientGeneralPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||
{!isCreate && (
|
||||
<>
|
||||
<Link
|
||||
to={`/clients/${clientId}`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/consents`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
</Link>
|
||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isCreate && (
|
||||
<ClientDetailTabs activeTab="settings" clientId={clientId} />
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* 1. Application Identity */}
|
||||
|
||||
690
devfront/src/features/clients/ClientRelationsPage.tsx
Normal file
690
devfront/src/features/clients/ClientRelationsPage.tsx
Normal file
@@ -0,0 +1,690 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react";
|
||||
import { useDeferredValue, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
type DevAssignableUser,
|
||||
addClientRelation,
|
||||
fetchClient,
|
||||
fetchClientRelations,
|
||||
fetchDevUsers,
|
||||
removeClientRelation,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
|
||||
const relationOptions = [
|
||||
"admins",
|
||||
"config_editor",
|
||||
"secret_viewer",
|
||||
"secret_rotator",
|
||||
"jwks_viewer",
|
||||
"jwks_operator",
|
||||
"consent_viewer",
|
||||
"consent_revoker",
|
||||
"relationship_viewer",
|
||||
"audit_viewer",
|
||||
"status_operator",
|
||||
] as const;
|
||||
|
||||
type RelationOption = (typeof relationOptions)[number];
|
||||
|
||||
function relationLabel(relation: RelationOption) {
|
||||
return t(`ui.dev.clients.relationships.option.${relation}.label`, relation);
|
||||
}
|
||||
|
||||
function relationDescription(relation: RelationOption) {
|
||||
return t(
|
||||
`ui.dev.clients.relationships.option.${relation}.description`,
|
||||
relation,
|
||||
);
|
||||
}
|
||||
|
||||
function formatUserLabel(user: DevAssignableUser) {
|
||||
const primary = user.name.trim() || user.email.trim();
|
||||
return `${primary} (${user.email.trim()})`;
|
||||
}
|
||||
|
||||
function ClientRelationsPage() {
|
||||
const params = useParams();
|
||||
const auth = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientId = params.id ?? "";
|
||||
const [selectedRelations, setSelectedRelations] = useState<RelationOption[]>(
|
||||
[],
|
||||
);
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const deferredUserSearch = useDeferredValue(userSearch.trim());
|
||||
const [selectedUser, setSelectedUser] = useState<DevAssignableUser | null>(
|
||||
null,
|
||||
);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
|
||||
const systemRole = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
|
||||
const { data: clientData } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: relationData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["client-relations", clientId],
|
||||
queryFn: () => fetchClientRelations(clientId),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
|
||||
// Calculate permissions for UI hints and button states
|
||||
const isSuperAdmin = systemRole === "super_admin";
|
||||
const myUserId = auth.user?.profile.sub;
|
||||
const isRpAdmin = useMemo(() => {
|
||||
if (isSuperAdmin) return true;
|
||||
if (!relationData?.items || !myUserId) return false;
|
||||
return relationData.items.some(
|
||||
(item) =>
|
||||
item.subject === `User:${myUserId}` && item.relation === "admins",
|
||||
);
|
||||
}, [relationData?.items, myUserId, isSuperAdmin]);
|
||||
|
||||
const canManageRelations = isRpAdmin || isSuperAdmin;
|
||||
|
||||
const isRelationshipViewForbidden =
|
||||
(error as AxiosError | null)?.response?.status === 403;
|
||||
const relationshipViewForbiddenMessage = t(
|
||||
"msg.dev.clients.relationships.view_forbidden",
|
||||
"이 RP의 관계를 조회할 권한이 없습니다. 관리자에게 관계 조회 또는 RP 관리자 관계 부여를 요청해 주세요.",
|
||||
);
|
||||
|
||||
const {
|
||||
data: userSearchData,
|
||||
isFetching: isUserSearchLoading,
|
||||
error: userSearchError,
|
||||
} = useQuery({
|
||||
queryKey: ["dev-users", deferredUserSearch],
|
||||
queryFn: () => fetchDevUsers(deferredUserSearch, 10, clientId),
|
||||
enabled:
|
||||
clientId.length > 0 &&
|
||||
deferredUserSearch.length > 0 &&
|
||||
selectedUser == null &&
|
||||
!isRelationshipViewForbidden,
|
||||
});
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...(relationData?.items ?? [])].sort((a, b) => {
|
||||
const relationCompare = a.relation.localeCompare(b.relation);
|
||||
if (relationCompare !== 0) {
|
||||
return relationCompare;
|
||||
}
|
||||
return a.subject.localeCompare(b.subject);
|
||||
});
|
||||
}, [relationData?.items]);
|
||||
|
||||
const selectedUserExistingRelations = useMemo(() => {
|
||||
if (!selectedUser) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
sortedItems
|
||||
.filter((item) => item.subjectId === selectedUser.id)
|
||||
.map((item) => item.relation),
|
||||
);
|
||||
}, [selectedUser, sortedItems]);
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedUser) {
|
||||
throw new Error(
|
||||
t(
|
||||
"msg.dev.clients.relationships.user_required",
|
||||
"추가할 사용자를 선택하세요.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const pendingRelations = selectedRelations.filter(
|
||||
(relation) => !selectedUserExistingRelations.has(relation),
|
||||
);
|
||||
for (const relation of pendingRelations) {
|
||||
await addClientRelation(clientId, {
|
||||
relation,
|
||||
userId: selectedUser.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["client-relations", clientId],
|
||||
});
|
||||
setSelectedRelations([]);
|
||||
setSelectedUser(null);
|
||||
setUserSearch("");
|
||||
setIsSearchOpen(false);
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.added",
|
||||
"Relationship가 추가되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.add_error",
|
||||
"Relationship 추가 실패: {{error}}",
|
||||
{
|
||||
error:
|
||||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(err as Error).message,
|
||||
},
|
||||
),
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (payload: { relation: string; subject: string }) =>
|
||||
removeClientRelation(clientId, payload.relation, payload.subject),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["client-relations", clientId],
|
||||
});
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.removed",
|
||||
"Relationship가 제거되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.remove_error",
|
||||
"Relationship 제거 실패: {{error}}",
|
||||
{
|
||||
error:
|
||||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(err as Error).message,
|
||||
},
|
||||
),
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!canManageRelations) {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.add_forbidden_viewer",
|
||||
"'관계 조회' 권한만으로는 새로운 관계를 추가하거나 사용자를 검색할 수 없습니다. 'RP 관리자' 권한이 필요합니다.",
|
||||
),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!selectedUser) {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.user_required",
|
||||
"추가할 사용자를 선택하세요.",
|
||||
),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingRelations = selectedRelations.filter(
|
||||
(relation) => !selectedUserExistingRelations.has(relation),
|
||||
);
|
||||
if (pendingRelations.length === 0) {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.relation_required",
|
||||
"추가할 관계를 하나 이상 선택하세요.",
|
||||
),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addMutation.mutate();
|
||||
};
|
||||
|
||||
const handleRelationToggle = (relation: RelationOption) => {
|
||||
setSelectedRelations((current) =>
|
||||
current.includes(relation)
|
||||
? current.filter((item) => item !== relation)
|
||||
: [...current, relation],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectUser = (user: DevAssignableUser) => {
|
||||
setSelectedUser(user);
|
||||
setUserSearch(formatUserLabel(user));
|
||||
setIsSearchOpen(false);
|
||||
};
|
||||
|
||||
const handleRemove = (targetRelation: string, subject: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.dev.clients.relationships.remove_confirm",
|
||||
"이 relationship를 제거하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeMutation.mutate({ relation: targetRelation, subject });
|
||||
}
|
||||
};
|
||||
|
||||
if (!clientId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUserSearchForbidden =
|
||||
(userSearchError as AxiosError | null)?.response?.status === 403;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-4">
|
||||
<div className="flex flex-wrap justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link to="/" className="hover:text-primary">
|
||||
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link to="/clients" className="hover:text-primary">
|
||||
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>{clientData?.client?.name || clientId}</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{t("ui.dev.clients.details.tab.relationships", "Relationships")}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={`/clients/${clientId}`}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<p className="text-3xl font-black leading-tight">
|
||||
{t(
|
||||
"ui.dev.clients.relationships.title",
|
||||
"Client Relationships",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.subtitle",
|
||||
"RP direct operator relation을 조회하고 User 단위로 추가·삭제합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={
|
||||
clientData?.client?.status === "active" ? "info" : "muted"
|
||||
}
|
||||
>
|
||||
{clientData?.client?.status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<ClientDetailTabs activeTab="relationships" clientId={clientId} />
|
||||
</header>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("ui.dev.clients.relationships.add_title", "Add Relationship")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.relationships.add_description",
|
||||
"사용자를 검색해 선택하고, 하나 이상의 운영 관계를 한 번에 부여할 수 있습니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{isRelationshipViewForbidden ? (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
{relationshipViewForbiddenMessage}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-search-input">
|
||||
{t("ui.dev.clients.relationships.user_search", "사용자")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="user-search-input"
|
||||
value={userSearch}
|
||||
onFocus={() => {
|
||||
if (!selectedUser && userSearch.trim() !== "") {
|
||||
setIsSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setSelectedUser(null);
|
||||
setUserSearch(event.target.value);
|
||||
setIsSearchOpen(true);
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.relationships.user_search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
/>
|
||||
{isSearchOpen &&
|
||||
selectedUser == null &&
|
||||
userSearch.trim() !== "" && (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-md border border-border bg-background shadow-lg">
|
||||
{isUserSearchLoading ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.search_loading",
|
||||
"사용자를 찾는 중입니다...",
|
||||
)}
|
||||
</div>
|
||||
) : isUserSearchForbidden ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-destructive font-medium border-b border-border/40 bg-destructive/5 flex flex-col gap-2">
|
||||
<p>
|
||||
{t(
|
||||
"msg.dev.clients.relationships.search_forbidden_user",
|
||||
"일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/80 font-normal">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.search_forbidden_user_hint",
|
||||
"'관계 조회' 권한만으로는 사용자 검색이 제한됩니다. 'RP 관리자' 관계가 필요합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (userSearchData?.items ?? []).length > 0 ? (
|
||||
(userSearchData?.items ?? []).map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
className="flex w-full flex-col gap-1 px-3 py-2 text-left hover:bg-muted/40 border-b border-border/40 last:border-b-0"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
handleSelectUser(user);
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-semibold">
|
||||
{user.name || user.email}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
{user.loginId ? ` · ${user.loginId}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.search_empty",
|
||||
"검색 결과가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedUser && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.selected_user",
|
||||
"선택된 사용자: {{user}}",
|
||||
{ user: formatUserLabel(selectedUser) },
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>
|
||||
{t("ui.dev.clients.relationships.relation", "Relation")}
|
||||
</Label>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{relationOptions.map((relation) => {
|
||||
const disabled =
|
||||
selectedUserExistingRelations.has(relation);
|
||||
const isSelected = selectedRelations.includes(relation);
|
||||
return (
|
||||
<label
|
||||
key={relation}
|
||||
className={`flex gap-3 rounded-xl border p-4 transition-all ${
|
||||
disabled
|
||||
? "border-border/60 bg-muted/30 opacity-60"
|
||||
: isSelected
|
||||
? "border-primary bg-primary/10 shadow-[0_0_0_1px_rgba(59,130,246,0.35)] ring-1 ring-primary/30"
|
||||
: "border-border bg-background hover:border-primary/40 hover:bg-muted/20"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary"
|
||||
checked={isSelected || disabled}
|
||||
disabled={disabled}
|
||||
onChange={() => handleRelationToggle(relation)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
className={`text-sm font-semibold ${
|
||||
isSelected && !disabled ? "text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
{relationLabel(relation)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{relationDescription(relation)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[11px] uppercase tracking-wide ${
|
||||
isSelected && !disabled
|
||||
? "text-primary/80"
|
||||
: "text-muted-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{relation}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={addMutation.isPending || !canManageRelations}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{addMutation.isPending
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.dev.clients.relationships.add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link2 className="h-5 w-5" />
|
||||
{t(
|
||||
"ui.dev.clients.relationships.list_title",
|
||||
"Assigned Relationships",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.relationships.list_description",
|
||||
"현재 RP에 직접 부여된 operator relation 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isRelationshipViewForbidden ? (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
{relationshipViewForbiddenMessage}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.load_error",
|
||||
"Relationship 조회 실패: {{error}}",
|
||||
{
|
||||
error:
|
||||
(error as AxiosError<{ error?: string }>).response?.data
|
||||
?.error ?? (error as Error).message,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.loading",
|
||||
"Relationship를 불러오는 중입니다...",
|
||||
)}
|
||||
</div>
|
||||
) : sortedItems.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.empty",
|
||||
"직접 부여된 relationship가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.relationships.relation", "Relation")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.relationships.subject", "Subject")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.relationships.subject_type", "Type")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px] text-right">
|
||||
{t("ui.dev.clients.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedItems.map((item) => (
|
||||
<TableRow key={`${item.relation}:${item.subject}`}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">
|
||||
{relationLabel(item.relation as RelationOption)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{relationDescription(item.relation as RelationOption)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">
|
||||
{item.userName || item.userEmail || item.subject}
|
||||
</div>
|
||||
{(item.userEmail || item.userLoginId) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[item.userEmail, item.userLoginId]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{item.subject}
|
||||
</div>
|
||||
{item.subjectId && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
ID: {item.subjectId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{item.subjectType || "-"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 text-destructive hover:text-destructive"
|
||||
disabled={
|
||||
removeMutation.isPending || !canManageRelations
|
||||
}
|
||||
onClick={() =>
|
||||
handleRemove(item.relation, item.subject)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("ui.common.delete", "Delete")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientRelationsPage;
|
||||
@@ -38,12 +38,17 @@ import {
|
||||
} from "../../components/ui/table";
|
||||
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function ClientsPage() {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
const role = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
const canCreateClient = role !== "user" && role !== "tenant_member";
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -168,16 +173,18 @@ function ClientsPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button
|
||||
size="sm"
|
||||
className="shadow-lg shadow-primary/30"
|
||||
onClick={() => navigate("/clients/new")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
{canCreateClient && (
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button
|
||||
size="sm"
|
||||
className="shadow-lg shadow-primary/30"
|
||||
onClick={() => navigate("/clients/new")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
@@ -217,7 +224,7 @@ function ClientsPage() {
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="success">
|
||||
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
|
||||
{t("ui.dev.clients.badge.dev_session", "DevFront 세션")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,12 +326,14 @@ function ClientsPage() {
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
{t("ui.dev.clients.list.title", "클라이언트 목록")}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
{canCreateClient && (
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -350,6 +359,29 @@ function ClientsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredClients.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.empty",
|
||||
"조회 가능한 RP가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t(
|
||||
"msg.dev.clients.empty_detail",
|
||||
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredClients.map((client) => (
|
||||
<TableRow key={client.id} className="bg-card/40">
|
||||
<TableCell>
|
||||
|
||||
@@ -99,6 +99,37 @@ export type ClientUpsertRequest = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ClientRelation = {
|
||||
relation: string;
|
||||
subject: string;
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
userLoginId?: string;
|
||||
};
|
||||
|
||||
export type ClientRelationListResponse = {
|
||||
items: ClientRelation[];
|
||||
};
|
||||
|
||||
export type ClientRelationUpsertRequest = {
|
||||
relation: string;
|
||||
subject?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type DevAssignableUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
loginId?: string;
|
||||
};
|
||||
|
||||
export type DevAssignableUserListResponse = {
|
||||
items: DevAssignableUser[];
|
||||
};
|
||||
|
||||
export type ConsentSummary = {
|
||||
subject: string;
|
||||
userName?: string;
|
||||
@@ -164,6 +195,48 @@ export async function fetchClient(clientId: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchClientRelations(clientId: string) {
|
||||
const { data } = await apiClient.get<ClientRelationListResponse>(
|
||||
`/dev/clients/${clientId}/relations`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDevUsers(
|
||||
search: string,
|
||||
limit = 10,
|
||||
clientId?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<DevAssignableUserListResponse>(
|
||||
"/dev/users",
|
||||
{
|
||||
params: { search, limit, clientId },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addClientRelation(
|
||||
clientId: string,
|
||||
payload: ClientRelationUpsertRequest,
|
||||
) {
|
||||
const { data } = await apiClient.post<ClientRelation>(
|
||||
`/dev/clients/${clientId}/relations`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function removeClientRelation(
|
||||
clientId: string,
|
||||
relation: string,
|
||||
subject: string,
|
||||
) {
|
||||
await apiClient.delete(`/dev/clients/${clientId}/relations`, {
|
||||
params: { relation, subject },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateClientStatus(
|
||||
clientId: string,
|
||||
status: ClientStatus,
|
||||
|
||||
@@ -332,6 +332,8 @@ showing = "Showing {{shown}} of {{total}} apps"
|
||||
deleted = "App deleted."
|
||||
delete_error = "Failed to delete: {{error}}"
|
||||
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
|
||||
empty = "No RPs are available."
|
||||
empty_detail = "RPs will appear here when a relationship is assigned to your account."
|
||||
|
||||
[msg.dev.clients.consents]
|
||||
empty = "No consents found."
|
||||
@@ -352,6 +354,7 @@ redirect_saved = "Redirect URIs saved."
|
||||
rotate_confirm = "Rotate Confirm"
|
||||
rotate_error = "Rotate Error"
|
||||
save_error = "Save Error"
|
||||
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
|
||||
secret_rotated = "Secret Rotated"
|
||||
secret_unavailable = "SECRET_NOT_AVAILABLE"
|
||||
subtitle = "Manage OIDC credentials and endpoints."
|
||||
@@ -368,8 +371,29 @@ load_error = "Error loading client: {{error}}"
|
||||
loading = "Loading client..."
|
||||
saved = "Saved"
|
||||
save_error = "Failed to save: {{error}}"
|
||||
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
|
||||
status_changed = "Status changed to {{status}}."
|
||||
|
||||
[msg.dev.clients.relationships]
|
||||
subtitle = "Review direct RP operator relations and add or remove them per user."
|
||||
add_description = "Search for a user, select them, and grant one or more operator relations at once."
|
||||
added = "Relationship added."
|
||||
add_error = "Failed to add relationship: {{error}}"
|
||||
removed = "Relationship removed."
|
||||
remove_error = "Failed to remove relationship: {{error}}"
|
||||
remove_confirm = "Remove this relationship?"
|
||||
user_required = "Select a user to add."
|
||||
relation_required = "Select at least one relationship to add."
|
||||
list_description = "Lists operator relations directly assigned to this RP."
|
||||
load_error = "Failed to load relationships: {{error}}"
|
||||
loading = "Loading relationships..."
|
||||
empty = "No direct relationships assigned."
|
||||
view_forbidden = "You do not have permission to view relationships for this RP. Ask an administrator to grant Relationship Viewer or RP Admin relationship."
|
||||
search_loading = "Searching users..."
|
||||
search_empty = "No users found."
|
||||
search_forbidden_user = "General users cannot use user search for relationship assignment."
|
||||
selected_user = "Selected user: {{user}}"
|
||||
|
||||
[msg.dev.clients.federation]
|
||||
subtitle = "Manage external identity providers for this application."
|
||||
add_subtitle = "Connect an external OIDC provider."
|
||||
@@ -1292,6 +1316,7 @@ untitled = "Untitled"
|
||||
|
||||
[ui.dev.clients.badge]
|
||||
admin_session = "Admin Session"
|
||||
dev_session = "DevFront Session"
|
||||
tenant_selected = "Tenant Selected"
|
||||
|
||||
[ui.dev.clients.filter]
|
||||
@@ -1363,6 +1388,7 @@ title = "Security Note"
|
||||
connection = "Federation"
|
||||
consents = "Consent & Users"
|
||||
settings = "Settings"
|
||||
relationships = "Relationships"
|
||||
|
||||
[ui.dev.clients.general]
|
||||
create = "Create Application"
|
||||
@@ -1442,6 +1468,67 @@ cache_status = "Status"
|
||||
cache_uri = "JWKS URI"
|
||||
revoke_cache = "Revoke Cache"
|
||||
|
||||
[ui.dev.clients.relationships]
|
||||
title = "Client Relationships"
|
||||
add_title = "Add Relationship"
|
||||
relation = "Relation"
|
||||
user_id = "User ID"
|
||||
user_id_placeholder = "kratos user id"
|
||||
add = "Add"
|
||||
list_title = "Assigned Relationships"
|
||||
subject = "Subject"
|
||||
subject_type = "Type"
|
||||
user_search = "User"
|
||||
user_search_placeholder = "Search by name or email..."
|
||||
|
||||
[ui.dev.clients.relationships.option.admins]
|
||||
label = "RP Admin"
|
||||
description = "Full administrator relationship for RP operations."
|
||||
|
||||
[ui.dev.clients.relationships.option.creator]
|
||||
label = "RP Creator"
|
||||
description = "Marks the operator who created this RP."
|
||||
|
||||
[ui.dev.clients.relationships.option.config_editor]
|
||||
label = "RP General Settings"
|
||||
description = "Edit the name, redirect URIs, and general metadata."
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_viewer]
|
||||
label = "Secret View"
|
||||
description = "View the Client secret for this RP."
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_rotator]
|
||||
label = "Secret Rotation"
|
||||
description = "Rotate and reissue the client secret."
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_viewer]
|
||||
label = "JWKS View"
|
||||
description = "View JWKS status, cache details, and key summaries."
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_operator]
|
||||
label = "JWKS Operations"
|
||||
description = "Run operational actions such as refresh and revoke."
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_viewer]
|
||||
label = "Consent View"
|
||||
description = "View consent grants for this RP."
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_revoker]
|
||||
label = "Consent Revoke"
|
||||
description = "Revoke consent grants for this RP."
|
||||
|
||||
[ui.dev.clients.relationships.option.relationship_viewer]
|
||||
label = "Relationship View"
|
||||
description = "View direct relations assigned to this RP."
|
||||
|
||||
[ui.dev.clients.relationships.option.audit_viewer]
|
||||
label = "Audit Log View"
|
||||
description = "View DevFront audit logs for this RP."
|
||||
|
||||
[ui.dev.clients.relationships.option.status_operator]
|
||||
label = "Status Change"
|
||||
description = "Change the active or inactive state of the RP."
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||
docs_title = "Docs & Examples"
|
||||
@@ -1791,3 +1878,12 @@ workspace = "Workspace Tenant (Context)"
|
||||
workspace_desc = "Select and save the tenant you are currently working on to change the API request context."
|
||||
switch_success = "Tenant switch completed"
|
||||
single_notice = "You belong to a single tenant and do not need to switch."
|
||||
|
||||
[msg.dev.forbidden]
|
||||
default = "You do not have permission to access this resource. Please contact an administrator."
|
||||
rp_admin = "RP administrators can only view resources for their assigned apps."
|
||||
tenant_admin = "Tenant administrator permissions are not configured correctly or have expired."
|
||||
user.clients = "General user accounts can only use this feature if they have been granted operational or management relationships for the relevant RP (App). If you need access, please request it from an administrator."
|
||||
user.consents = "Viewing consent history for this App (RP) is only available when granted 'RP Admin', 'Consent View', or 'Consent Revoke' relationships. If you need access, please request it from an administrator."
|
||||
user.audit = "Viewing audit logs for this App (RP) is only available when granted 'RP Admin' or 'Audit View' relationships. If you need access, please request it from an administrator."
|
||||
title = "Access Denied: {{resource}}"
|
||||
|
||||
@@ -329,6 +329,8 @@ subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합
|
||||
deleted = "앱이 삭제되었습니다."
|
||||
delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
delete_error = "삭제 실패: {{error}}"
|
||||
empty = "조회 가능한 RP가 없습니다."
|
||||
empty_detail = "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다."
|
||||
load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
|
||||
loading = "앱 정보를 불러오는 중..."
|
||||
showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다."
|
||||
@@ -352,6 +354,7 @@ redirect_saved = "Redirect URIs가 저장되었습니다."
|
||||
rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?"
|
||||
rotate_error = "재발급 실패: {{error}}"
|
||||
save_error = "저장 실패: {{error}}"
|
||||
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
||||
secret_rotated = "Client Secret이 재발급되었습니다."
|
||||
secret_unavailable = "SECRET_NOT_AVAILABLE"
|
||||
subtitle = "OIDC 자격 증명과 엔드포인트를 관리합니다."
|
||||
@@ -367,9 +370,30 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행
|
||||
load_error = "클라이언트 정보를 불러오지 못했습니다: {{error}}"
|
||||
loading = "클라이언트 정보를 불러오는 중..."
|
||||
save_error = "저장 실패: {{error}}"
|
||||
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
||||
saved = "설정이 저장되었습니다."
|
||||
status_changed = "상태가 {{status}}로 변경되었습니다."
|
||||
|
||||
[msg.dev.clients.relationships]
|
||||
subtitle = "RP direct operator relation을 조회하고 사용자 단위로 추가·삭제합니다."
|
||||
add_description = "사용자를 검색해 선택하고, 하나 이상의 운영 관계를 한 번에 부여할 수 있습니다."
|
||||
added = "관계가 추가되었습니다."
|
||||
add_error = "관계 추가 실패: {{error}}"
|
||||
removed = "관계가 제거되었습니다."
|
||||
remove_error = "관계 제거 실패: {{error}}"
|
||||
remove_confirm = "이 관계를 제거하시겠습니까?"
|
||||
user_required = "추가할 사용자를 선택하세요."
|
||||
relation_required = "추가할 관계를 하나 이상 선택하세요."
|
||||
list_description = "현재 RP에 직접 부여된 operator relation 목록입니다."
|
||||
load_error = "관계 조회 실패: {{error}}"
|
||||
loading = "관계를 불러오는 중입니다..."
|
||||
empty = "직접 부여된 관계가 없습니다."
|
||||
view_forbidden = "이 RP의 관계를 조회할 권한이 없습니다. 관리자에게 관계 조회 또는 RP 관리자 관계 부여를 요청해 주세요."
|
||||
search_loading = "사용자를 찾는 중입니다..."
|
||||
search_empty = "검색 결과가 없습니다."
|
||||
search_forbidden_user = "일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다."
|
||||
selected_user = "선택된 사용자: {{user}}"
|
||||
|
||||
[msg.dev.clients.federation]
|
||||
add_subtitle = "외부 OIDC 제공자를 연결합니다."
|
||||
empty = "등록된 IdP 설정이 없습니다."
|
||||
@@ -1292,6 +1316,7 @@ untitled = "Untitled"
|
||||
|
||||
[ui.dev.clients.badge]
|
||||
admin_session = "관리자 세션"
|
||||
dev_session = "DevFront 세션"
|
||||
tenant_selected = "테넌트: 선택됨"
|
||||
|
||||
[ui.dev.clients.filter]
|
||||
@@ -1362,6 +1387,7 @@ title = "보안 메모"
|
||||
connection = "연동 설정"
|
||||
consents = "동의 및 사용자"
|
||||
settings = "설정"
|
||||
relationships = "관계"
|
||||
|
||||
[ui.dev.clients.general]
|
||||
create = "앱 생성"
|
||||
@@ -1441,6 +1467,67 @@ cache_status = "상태"
|
||||
cache_uri = "JWKS URI"
|
||||
revoke_cache = "캐시 삭제"
|
||||
|
||||
[ui.dev.clients.relationships]
|
||||
title = "클라이언트 관계"
|
||||
add_title = "관계 추가"
|
||||
relation = "관계"
|
||||
user_id = "사용자 ID"
|
||||
user_id_placeholder = "kratos 사용자 id"
|
||||
add = "추가"
|
||||
list_title = "부여된 관계"
|
||||
subject = "주체"
|
||||
subject_type = "유형"
|
||||
user_search = "사용자"
|
||||
user_search_placeholder = "이름 또는 이메일 검색..."
|
||||
|
||||
[ui.dev.clients.relationships.option.admins]
|
||||
label = "RP 관리자"
|
||||
description = "RP 운영 전반을 관리할 수 있는 관리자 관계입니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.creator]
|
||||
label = "RP 생성자"
|
||||
description = "이 RP를 생성한 운영 주체를 표시합니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.config_editor]
|
||||
label = "RP 일반 설정"
|
||||
description = "이름, Redirect URI, 메타데이터 같은 일반 설정을 수정합니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_viewer]
|
||||
label = "시크릿 조회"
|
||||
description = "이 RP의 Client secret을 조회합니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_rotator]
|
||||
label = "시크릿 재발급"
|
||||
description = "Client secret 재발급과 회전을 수행합니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_viewer]
|
||||
label = "JWKS 조회"
|
||||
description = "JWKS 상태, 캐시 정보, 키 요약을 조회합니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_operator]
|
||||
label = "JWKS 운영"
|
||||
description = "JWKS refresh, revoke 같은 운영 작업을 수행합니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_viewer]
|
||||
label = "동의 조회"
|
||||
description = "이 RP의 consent 내역을 조회합니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_revoker]
|
||||
label = "동의 회수"
|
||||
description = "이 RP의 consent를 회수합니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.relationship_viewer]
|
||||
label = "관계 조회"
|
||||
description = "이 RP에 부여된 direct relation을 조회합니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.audit_viewer]
|
||||
label = "감사 로그 조회"
|
||||
description = "이 RP의 DevFront 감사 로그를 조회합니다."
|
||||
|
||||
[ui.dev.clients.relationships.option.status_operator]
|
||||
label = "상태 변경"
|
||||
description = "RP 활성/비활성 상태를 변경합니다."
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||
docs_title = "Docs & Examples"
|
||||
@@ -1787,3 +1874,12 @@ workspace = "작업 테넌트 (컨텍스트)"
|
||||
workspace_desc = "현재 작업 중인 테넌트를 선택하고 저장하여 API 요청 컨텍스트를 변경합니다."
|
||||
switch_success = "테넌트 전환 완료"
|
||||
single_notice = "단일 테넌트에 소속되어 전환할 필요가 없습니다."
|
||||
|
||||
[msg.dev.forbidden]
|
||||
default = "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요."
|
||||
rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다."
|
||||
tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다."
|
||||
user.clients = "일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
|
||||
user.consents = "해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
|
||||
user.audit = "해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
|
||||
title = "{{resource}} 접근 권한 없음"
|
||||
|
||||
@@ -332,6 +332,8 @@ showing = ""
|
||||
deleted = ""
|
||||
delete_error = ""
|
||||
delete_confirm = ""
|
||||
empty = ""
|
||||
empty_detail = ""
|
||||
|
||||
[msg.dev.clients.consents]
|
||||
empty = ""
|
||||
@@ -352,6 +354,7 @@ redirect_saved = ""
|
||||
rotate_confirm = ""
|
||||
rotate_error = ""
|
||||
save_error = ""
|
||||
save_forbidden = ""
|
||||
secret_rotated = ""
|
||||
secret_unavailable = ""
|
||||
subtitle = ""
|
||||
@@ -368,8 +371,29 @@ load_error = ""
|
||||
loading = ""
|
||||
saved = ""
|
||||
save_error = ""
|
||||
save_forbidden = ""
|
||||
status_changed = ""
|
||||
|
||||
[msg.dev.clients.relationships]
|
||||
subtitle = ""
|
||||
add_description = ""
|
||||
added = ""
|
||||
add_error = ""
|
||||
removed = ""
|
||||
remove_error = ""
|
||||
remove_confirm = ""
|
||||
user_required = ""
|
||||
relation_required = ""
|
||||
list_description = ""
|
||||
load_error = ""
|
||||
loading = ""
|
||||
empty = ""
|
||||
view_forbidden = ""
|
||||
search_loading = ""
|
||||
search_empty = ""
|
||||
search_forbidden_user = ""
|
||||
selected_user = ""
|
||||
|
||||
[msg.dev.clients.federation]
|
||||
subtitle = ""
|
||||
add_subtitle = ""
|
||||
@@ -1293,6 +1317,7 @@ untitled = ""
|
||||
|
||||
[ui.dev.clients.badge]
|
||||
admin_session = ""
|
||||
dev_session = ""
|
||||
tenant_selected = ""
|
||||
|
||||
[ui.dev.clients.filter]
|
||||
@@ -1363,6 +1388,7 @@ title = ""
|
||||
connection = ""
|
||||
consents = ""
|
||||
settings = ""
|
||||
relationships = ""
|
||||
|
||||
[ui.dev.clients.general]
|
||||
create = ""
|
||||
@@ -1441,6 +1467,67 @@ cache_status = ""
|
||||
cache_uri = ""
|
||||
revoke_cache = ""
|
||||
|
||||
[ui.dev.clients.relationships]
|
||||
title = ""
|
||||
add_title = ""
|
||||
relation = ""
|
||||
user_id = ""
|
||||
user_id_placeholder = ""
|
||||
add = ""
|
||||
list_title = ""
|
||||
subject = ""
|
||||
subject_type = ""
|
||||
user_search = ""
|
||||
user_search_placeholder = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.admins]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.creator]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.config_editor]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_viewer]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.secret_rotator]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_viewer]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.jwks_operator]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_viewer]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.consent_revoker]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.relationship_viewer]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.audit_viewer]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.relationships.option.status_operator]
|
||||
label = ""
|
||||
description = ""
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = ""
|
||||
docs_title = ""
|
||||
|
||||
@@ -5,6 +5,13 @@ import {
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status === "passed") {
|
||||
await captureEvidence(page, testInfo, testInfo.title);
|
||||
}
|
||||
});
|
||||
|
||||
test("clients page loads correctly", async ({ page }) => {
|
||||
await seedAuth(page);
|
||||
|
||||
@@ -6,10 +6,17 @@ import {
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
|
||||
|
||||
test.describe("DevFront audit logs", () => {
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status === "passed") {
|
||||
await captureEvidence(page, testInfo, testInfo.title);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept().catch(() => {});
|
||||
|
||||
68
devfront/tests/devfront-client-tabs.spec.ts
Normal file
68
devfront/tests/devfront-client-tabs.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { type Page, expect, test } from "@playwright/test";
|
||||
import {
|
||||
type ClientRelation,
|
||||
type Consent,
|
||||
installDevApiMock,
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
|
||||
function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
|
||||
return async ({ page }: { page: Page }) => {
|
||||
const state = {
|
||||
clients: [makeClient("client-tabs", { name: "탭 테스트 앱" })],
|
||||
consents: [] as Consent[],
|
||||
relations: {
|
||||
"client-tabs": [
|
||||
{
|
||||
relation: "config_editor",
|
||||
subject: "User:user-1",
|
||||
subjectType: "User",
|
||||
subjectId: "user-1",
|
||||
},
|
||||
] satisfies ClientRelation[],
|
||||
},
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto(pagePath);
|
||||
|
||||
const header = page
|
||||
.locator("header")
|
||||
.filter({ hasText: "탭 테스트 앱" })
|
||||
.first();
|
||||
const tabs = header.locator(
|
||||
"div.border-b.border-border .whitespace-nowrap",
|
||||
);
|
||||
|
||||
await expect(tabs).toHaveText([
|
||||
"연동 설정",
|
||||
"동의 및 사용자",
|
||||
"설정",
|
||||
"관계",
|
||||
]);
|
||||
|
||||
await expect(
|
||||
header
|
||||
.locator("div.border-b.border-border .text-primary")
|
||||
.filter({ hasText: expectedActive }),
|
||||
).toHaveCount(1);
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("DevFront client detail tabs", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedAuth(page, "rp_admin");
|
||||
});
|
||||
|
||||
test(
|
||||
"settings page keeps tab order and uses localized relationships label",
|
||||
expectClientTabsOrder("/clients/client-tabs/settings", /^설정$/),
|
||||
);
|
||||
|
||||
test(
|
||||
"relationships page keeps tab order and uses localized relationships label",
|
||||
expectClientTabsOrder("/clients/client-tabs/relationships", /^관계$/),
|
||||
);
|
||||
});
|
||||
@@ -6,11 +6,18 @@ import {
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
|
||||
const jwksUri = "https://rp.example.com/.well-known/jwks.json";
|
||||
|
||||
test.describe("DevFront clients lifecycle", () => {
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status === "passed") {
|
||||
await captureEvidence(page, testInfo, testInfo.title);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
|
||||
@@ -5,8 +5,15 @@ import {
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
test.describe("DevFront consents", () => {
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status === "passed") {
|
||||
await captureEvidence(page, testInfo, testInfo.title);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
|
||||
99
devfront/tests/devfront-relationships.spec.ts
Normal file
99
devfront/tests/devfront-relationships.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
type ClientRelation,
|
||||
type Consent,
|
||||
installDevApiMock,
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
test.describe("DevFront relationships", () => {
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status === "passed") {
|
||||
await captureEvidence(page, testInfo, testInfo.title);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
await seedAuth(page, "rp_admin");
|
||||
});
|
||||
|
||||
test("list add and remove direct RP relationships", async ({ page }) => {
|
||||
const state = {
|
||||
clients: [makeClient("client-rel", { name: "Relations app" })],
|
||||
consents: [] as Consent[],
|
||||
users: [
|
||||
{
|
||||
id: "user-2",
|
||||
name: "홍길동",
|
||||
email: "hong@example.com",
|
||||
loginId: "hong01",
|
||||
},
|
||||
],
|
||||
relations: {
|
||||
"client-rel": [
|
||||
{
|
||||
relation: "admins",
|
||||
subject: "User:playwright-user",
|
||||
subjectType: "User",
|
||||
subjectId: "playwright-user",
|
||||
userName: "Playwright User",
|
||||
userEmail: "playwright@example.com",
|
||||
},
|
||||
{
|
||||
relation: "config_editor",
|
||||
subject: "User:user-1",
|
||||
subjectType: "User",
|
||||
subjectId: "user-1",
|
||||
userName: "기존 사용자",
|
||||
userEmail: "existing@example.com",
|
||||
},
|
||||
] satisfies ClientRelation[],
|
||||
},
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-rel/relationships");
|
||||
await expect(page.getByText("클라이언트 관계")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "관계 추가" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "부여된 관계" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("기존 사용자")).toBeVisible();
|
||||
await expect(page.getByText("User:user-1")).toBeVisible();
|
||||
|
||||
await page.getByLabel(/^사용자$/).fill("홍길동");
|
||||
await page.getByRole("button", { name: /홍길동/ }).click();
|
||||
await page.getByLabel(/시크릿 재발급/).check();
|
||||
await page.getByLabel(/동의 조회/).check();
|
||||
await page.getByRole("button", { name: /^추가$/ }).click();
|
||||
|
||||
await expect(
|
||||
page.locator("tr").filter({ hasText: "User:user-2" }).first(),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(4);
|
||||
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: "User:user-2" })
|
||||
.getByRole("button", { name: /Delete|삭제/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
state.relations["client-rel"]?.filter(
|
||||
(item) => item.subject === "User:user-2",
|
||||
).length ?? 0,
|
||||
)
|
||||
.toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ test.describe("DevFront role report", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("user(tenant_member) is blocked with 안내 문구", async ({
|
||||
test("user(tenant_member) can enter and sees empty RP list", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await seedAuth(page, "user");
|
||||
@@ -29,9 +29,14 @@ test.describe("DevFront role report", () => {
|
||||
|
||||
await page.goto("/clients");
|
||||
await expect(
|
||||
page.getByText(/관리자 전용 화면|administrator only/i),
|
||||
page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i),
|
||||
).toBeVisible();
|
||||
await captureEvidence(page, testInfo, "role-user-blocked");
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: /^연동 앱$|^Connected Application$/i,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await captureEvidence(page, testInfo, "role-user-empty-rps");
|
||||
});
|
||||
|
||||
test("rp_admin sees only assigned Gitea app and its logs", async ({
|
||||
|
||||
@@ -5,8 +5,15 @@ import {
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
test.describe("DevFront security and isolation", () => {
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status === "passed") {
|
||||
await captureEvidence(page, testInfo, testInfo.title);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
@@ -52,14 +59,25 @@ test.describe("DevFront security and isolation", () => {
|
||||
await expect(page.getByText("Server side App")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("tenant_member user is blocked at AuthGuard", async ({ page }) => {
|
||||
test("tenant_member user can enter DevFront and sees empty RP list", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedAuth(page, "tenant_member");
|
||||
const state = {
|
||||
clients: [] as ReturnType<typeof makeClient>[],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients");
|
||||
await expect(
|
||||
page.getByText(/DevFront는 관리자 전용 화면입니다|administrator access/i),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/clients$/);
|
||||
await expect(
|
||||
page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /연동 앱 추가|새 클라이언트|Create/i }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("rp_admin receives 403 on clients list and sees ForbiddenMessage", async ({
|
||||
|
||||
@@ -4,8 +4,15 @@ import {
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
test.describe("DevFront tenant switch", () => {
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status === "passed") {
|
||||
await captureEvidence(page, testInfo, testInfo.title);
|
||||
}
|
||||
});
|
||||
|
||||
const MOCK_STATE = {
|
||||
clients: [makeClient("client-a", { name: "Tenant A App" })],
|
||||
consents: [],
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status === "passed") {
|
||||
await captureEvidence(page, testInfo, testInfo.title);
|
||||
}
|
||||
});
|
||||
|
||||
test("has title", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
@@ -53,6 +53,23 @@ export type Consent = {
|
||||
tenantName: string;
|
||||
};
|
||||
|
||||
export type ClientRelation = {
|
||||
relation: string;
|
||||
subject: string;
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
userLoginId?: string;
|
||||
};
|
||||
|
||||
export type DevAssignableUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
loginId?: string;
|
||||
};
|
||||
|
||||
export type AuditLog = {
|
||||
event_id: string;
|
||||
timestamp: string;
|
||||
@@ -67,6 +84,8 @@ export type AuditLog = {
|
||||
export type DevApiMockState = {
|
||||
clients: Client[];
|
||||
consents: Consent[];
|
||||
relations?: Record<string, ClientRelation[]>;
|
||||
users?: DevAssignableUser[];
|
||||
auditLogsByCursor?: Record<
|
||||
string,
|
||||
{ items: AuditLog[]; next_cursor?: string }
|
||||
@@ -253,6 +272,20 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname === "/api/v1/dev/users" && method === "GET") {
|
||||
const search = (searchParams.get("search") || "").toLowerCase();
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "10", 10);
|
||||
const items = (state.users ?? [])
|
||||
.filter((user) => {
|
||||
if (!search) return true;
|
||||
return [user.name, user.email, user.loginId ?? ""].some((value) =>
|
||||
value.toLowerCase().includes(search),
|
||||
);
|
||||
})
|
||||
.slice(0, Number.isFinite(limit) ? limit : 10);
|
||||
return json(route, { items });
|
||||
}
|
||||
|
||||
if (pathname === "/api/v1/dev/clients" && method === "POST") {
|
||||
const payload = (request.postDataJSON() as {
|
||||
name?: string;
|
||||
@@ -292,6 +325,68 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/api/v1/dev/clients/") &&
|
||||
pathname.endsWith("/relations") &&
|
||||
method === "GET"
|
||||
) {
|
||||
const clientId = pathname.split("/")[5] ?? "";
|
||||
return json(route, {
|
||||
items: state.relations?.[clientId] ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/api/v1/dev/clients/") &&
|
||||
pathname.endsWith("/relations") &&
|
||||
method === "POST"
|
||||
) {
|
||||
const clientId = pathname.split("/")[5] ?? "";
|
||||
const payload = (request.postDataJSON() as {
|
||||
relation?: string;
|
||||
subject?: string;
|
||||
userId?: string;
|
||||
}) || { relation: "config_editor" };
|
||||
const subject =
|
||||
payload.subject ||
|
||||
(payload.userId ? `User:${payload.userId}` : "User:playwright-user");
|
||||
const subjectId = subject.startsWith("User:")
|
||||
? subject.slice("User:".length)
|
||||
: subject;
|
||||
const created: ClientRelation = {
|
||||
relation: payload.relation ?? "config_editor",
|
||||
subject,
|
||||
subjectType: "User",
|
||||
subjectId,
|
||||
};
|
||||
if (!state.relations) {
|
||||
state.relations = {};
|
||||
}
|
||||
if (!state.relations[clientId]) {
|
||||
state.relations[clientId] = [];
|
||||
}
|
||||
state.relations[clientId].push(created);
|
||||
appendAuditLog("CLIENT_RELATION_CREATE", "ADD_RELATION", clientId);
|
||||
return json(route, created, 201);
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/api/v1/dev/clients/") &&
|
||||
pathname.endsWith("/relations") &&
|
||||
method === "DELETE"
|
||||
) {
|
||||
const clientId = pathname.split("/")[5] ?? "";
|
||||
const relation = searchParams.get("relation") || "";
|
||||
const subject = searchParams.get("subject") || "";
|
||||
if (state.relations?.[clientId]) {
|
||||
state.relations[clientId] = state.relations[clientId].filter(
|
||||
(item) => !(item.relation === relation && item.subject === subject),
|
||||
);
|
||||
}
|
||||
appendAuditLog("CLIENT_RELATION_DELETE", "REMOVE_RELATION", clientId);
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/api/v1/dev/clients/") &&
|
||||
pathname.endsWith("/status") &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions"
|
||||
import { Namespace, Context, SubjectSet } from "@ory/keto-definitions"
|
||||
|
||||
class User implements Namespace {}
|
||||
|
||||
@@ -20,6 +20,8 @@ class Tenant implements Namespace {
|
||||
admins: (User | SubjectSet<System, "super_admins">)[]
|
||||
members: (User | SubjectSet<System, "super_admins"> | SubjectSet<Tenant, "admins"> | SubjectSet<Tenant, "owners">)[]
|
||||
parents: Tenant[]
|
||||
developer_console_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||
developer_console_grant_manager: (User | SubjectSet<System, "super_admins">)[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
@@ -39,7 +41,18 @@ class Tenant implements Namespace {
|
||||
this.related.parents.traverse((p) => p.permits.manage_admins(ctx)),
|
||||
|
||||
create_subtenant: (ctx: Context): boolean =>
|
||||
this.permits.manage(ctx)
|
||||
this.permits.manage(ctx),
|
||||
|
||||
view_dev_console: (ctx: Context): boolean =>
|
||||
this.related.developer_console_viewer.includes(ctx.subject) ||
|
||||
this.permits.grant_dev_permissions(ctx) ||
|
||||
this.permits.manage(ctx) ||
|
||||
this.related.parents.traverse((p) => p.permits.view_dev_console(ctx)),
|
||||
|
||||
grant_dev_permissions: (ctx: Context): boolean =>
|
||||
this.related.developer_console_grant_manager.includes(ctx.subject) ||
|
||||
this.permits.manage_admins(ctx) ||
|
||||
this.related.parents.traverse((p) => p.permits.grant_dev_permissions(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,17 +61,88 @@ class RelyingParty implements Namespace {
|
||||
admins: (User | SubjectSet<System, "super_admins"> | SubjectSet<Tenant, "admins"> | SubjectSet<Tenant, "owners">)[]
|
||||
parents: Tenant[]
|
||||
access: (User | SubjectSet<Tenant, "members"> | SubjectSet<System, "authenticated_users"> | SubjectSet<System, "super_admins">)[]
|
||||
creator: (User | SubjectSet<System, "super_admins">)[]
|
||||
config_editor: (User | SubjectSet<System, "super_admins">)[]
|
||||
secret_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||
secret_rotator: (User | SubjectSet<System, "super_admins">)[]
|
||||
jwks_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||
jwks_operator: (User | SubjectSet<System, "super_admins">)[]
|
||||
consent_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||
consent_revoker: (User | SubjectSet<System, "super_admins">)[]
|
||||
relationship_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||
audit_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||
status_operator: (User | SubjectSet<System, "super_admins">)[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
view: (ctx: Context): boolean =>
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.parents.traverse((t) => t.permits.view(ctx)),
|
||||
this.related.config_editor.includes(ctx.subject) ||
|
||||
this.related.secret_viewer.includes(ctx.subject) ||
|
||||
this.related.secret_rotator.includes(ctx.subject) ||
|
||||
this.related.jwks_viewer.includes(ctx.subject) ||
|
||||
this.related.jwks_operator.includes(ctx.subject) ||
|
||||
this.related.consent_viewer.includes(ctx.subject) ||
|
||||
this.related.consent_revoker.includes(ctx.subject) ||
|
||||
this.related.relationship_viewer.includes(ctx.subject) ||
|
||||
this.related.audit_viewer.includes(ctx.subject) ||
|
||||
this.related.status_operator.includes(ctx.subject) ||
|
||||
this.related.parents.traverse((t) => t.permits.view(ctx)) ||
|
||||
this.related.parents.traverse((t) => t.permits.view_dev_console(ctx)),
|
||||
|
||||
manage: (ctx: Context): boolean =>
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.parents.traverse((t) => t.permits.manage(ctx)),
|
||||
|
||||
create: (ctx: Context): boolean =>
|
||||
this.related.creator.includes(ctx.subject) ||
|
||||
this.related.parents.traverse((t) => t.permits.grant_dev_permissions(ctx)) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
edit_config: (ctx: Context): boolean =>
|
||||
this.related.config_editor.includes(ctx.subject) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
view_secret: (ctx: Context): boolean =>
|
||||
this.related.secret_viewer.includes(ctx.subject) ||
|
||||
this.permits.rotate_secret(ctx) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
rotate_secret: (ctx: Context): boolean =>
|
||||
this.related.secret_rotator.includes(ctx.subject) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
view_jwks: (ctx: Context): boolean =>
|
||||
this.related.jwks_viewer.includes(ctx.subject) ||
|
||||
this.permits.operate_jwks(ctx) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
operate_jwks: (ctx: Context): boolean =>
|
||||
this.related.jwks_operator.includes(ctx.subject) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
view_consents: (ctx: Context): boolean =>
|
||||
this.related.consent_viewer.includes(ctx.subject) ||
|
||||
this.permits.revoke_consents(ctx) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
revoke_consents: (ctx: Context): boolean =>
|
||||
this.related.consent_revoker.includes(ctx.subject) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
view_relationships: (ctx: Context): boolean =>
|
||||
this.related.relationship_viewer.includes(ctx.subject) ||
|
||||
this.related.parents.traverse((t) => t.permits.grant_dev_permissions(ctx)) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
view_audit_logs: (ctx: Context): boolean =>
|
||||
this.related.audit_viewer.includes(ctx.subject) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
change_status: (ctx: Context): boolean =>
|
||||
this.related.status_operator.includes(ctx.subject) ||
|
||||
this.permits.manage(ctx),
|
||||
|
||||
access: (ctx: Context): boolean =>
|
||||
this.related.access.includes(ctx.subject) ||
|
||||
this.permits.manage(ctx)
|
||||
|
||||
@@ -156,4 +156,4 @@
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [{ "handler": "noop" }]
|
||||
}
|
||||
]
|
||||
]
|
||||
173
docs/devfront-rp-relationships-guide.md
Normal file
173
docs/devfront-rp-relationships-guide.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# DevFront RP 관계 설정 가이드
|
||||
|
||||
이 문서는 DevFront의 RP 상세 화면 > `관계` 탭에서 사용자에게 부여할 수 있는 관계 목록과 각 관계가 의미하는 기능 범위를 정리한다.
|
||||
|
||||
## 적용 범위
|
||||
|
||||
- 대상 namespace: `RelyingParty`
|
||||
- 대상 화면: DevFront RP 상세 화면의 `Relationships` 탭
|
||||
- 대상 subject: 현재 1차 구현에서는 direct `User:<kratosIdentityId>` assignment
|
||||
- 기준 구현:
|
||||
- `docker/ory/keto/namespaces.ts`
|
||||
- `devfront/src/features/clients/ClientRelationsPage.tsx`
|
||||
- `backend/internal/handler/dev_handler.go`
|
||||
|
||||
## 기본 원칙
|
||||
|
||||
- 관계 탭에서 부여하는 관계는 **DevFront 운영 권한**이다.
|
||||
- `RelyingParty#access`는 실제 서비스 로그인/접근 권한이며, DevFront 운영 권한과 별도이다.
|
||||
- 아래 수동 부여 관계 중 하나라도 있으면 해당 RP에 대한 기본 조회 권한(`RelyingParty#view`)도 함께 생긴다.
|
||||
- `RP 관리자(admins)`는 상위 관리 관계이며, 대부분의 세부 운영 권한을 포함한다.
|
||||
- 세부 관계는 필요한 기능만 최소 권한으로 부여할 때 사용한다.
|
||||
- `creator`는 생성 이력/자동 동기화용 내부 relation이며 관계 탭의 수동 부여 목록에는 노출하지 않는다.
|
||||
|
||||
## 관계 목록
|
||||
|
||||
| 화면 표시명 | Relation key | 의미 | 주요 허용 기능 |
|
||||
|---|---|---|---|
|
||||
| RP 관리자 | `admins` | RP 운영 전반을 관리할 수 있는 관리자 관계 | RP 조회, 설정 관리, secret 조회/재발급, JWKS 운영, consent 조회/회수, 관계 조회, 감사 로그 조회, 상태 변경 |
|
||||
| RP 일반 설정 | `config_editor` | RP 이름, Redirect URI, 메타데이터 같은 일반 설정을 수정할 수 있는 관계 | RP 조회, 일반 설정 수정 |
|
||||
| 시크릿 조회 | `secret_viewer` | Client secret을 조회할 수 있는 관계 | RP 조회, client secret 조회 |
|
||||
| 시크릿 재발급 | `secret_rotator` | Client secret 재발급과 rotation을 수행할 수 있는 관계 | RP 조회, client secret 재발급 |
|
||||
| JWKS 조회 | `jwks_viewer` | JWKS 상태, 캐시 정보, key summary를 조회할 수 있는 관계 | RP 조회, JWKS 상태/캐시/key summary 조회 |
|
||||
| JWKS 운영 | `jwks_operator` | JWKS refresh/revoke 같은 운영 작업을 수행할 수 있는 관계 | RP 조회, JWKS 조회, JWKS refresh/revoke |
|
||||
| 동의 조회 | `consent_viewer` | 이 RP의 consent 내역을 조회할 수 있는 관계 | RP 조회, 동의 및 사용자 목록 조회 |
|
||||
| 동의 회수 | `consent_revoker` | 이 RP의 consent를 회수할 수 있는 관계 | RP 조회, 동의 조회, 동의 회수 |
|
||||
| 관계 조회 | `relationship_viewer` | 이 RP에 부여된 direct relation을 조회할 수 있는 관계 | RP 조회, 관계 목록 조회 |
|
||||
| 감사 로그 조회 | `audit_viewer` | 이 RP의 DevFront 감사 로그를 조회할 수 있는 관계 | RP 조회, 해당 RP 감사 로그 조회 |
|
||||
| 상태 변경 | `status_operator` | RP 활성/비활성 상태를 변경할 수 있는 관계 | RP 조회, 활성/비활성 상태 변경 |
|
||||
|
||||
## Permit 매핑
|
||||
|
||||
Keto namespace 기준으로 relation은 다음 permit으로 계산된다.
|
||||
|
||||
| Permit | 허용 relation / 조건 | 기능 의미 |
|
||||
|---|---|---|
|
||||
| `view` | `admins`, `config_editor`, `secret_viewer`, `secret_rotator`, `jwks_viewer`, `jwks_operator`, `consent_viewer`, `consent_revoker`, `relationship_viewer`, `audit_viewer`, `status_operator`, 부모 tenant의 `view` 또는 `view_dev_console` | RP 기본 조회 및 목록 노출 |
|
||||
| `manage` | `admins`, 부모 tenant의 `manage` | RP 관리 상위 권한 |
|
||||
| `create` | `creator`, 부모 tenant의 `grant_dev_permissions`, `manage` | RP 생성. `creator`는 현재 수동 부여하지 않는 내부 relation이다. |
|
||||
| `edit_config` | `config_editor`, `manage` | RP 일반 설정 수정 |
|
||||
| `view_secret` | `secret_viewer`, `rotate_secret`, `manage` | client secret 조회 |
|
||||
| `rotate_secret` | `secret_rotator`, `manage` | client secret 재발급/회전 |
|
||||
| `view_jwks` | `jwks_viewer`, `operate_jwks`, `manage` | JWKS 상태/캐시/key summary 조회 |
|
||||
| `operate_jwks` | `jwks_operator`, `manage` | JWKS refresh/revoke |
|
||||
| `view_consents` | `consent_viewer`, `revoke_consents`, `manage` | consent 목록/상세 조회 |
|
||||
| `revoke_consents` | `consent_revoker`, `manage` | consent 회수 |
|
||||
| `view_relationships` | `relationship_viewer`, 부모 tenant의 `grant_dev_permissions`, `manage` | RP 관계 목록 조회 |
|
||||
| `view_audit_logs` | `audit_viewer`, `manage` | 해당 RP의 DevFront 감사 로그 조회 |
|
||||
| `change_status` | `status_operator`, `manage` | RP 활성/비활성 상태 변경 |
|
||||
| `access` | `access`, `manage` | 실제 서비스 로그인/리소스 접근 |
|
||||
|
||||
## 권한별 운영 예시
|
||||
|
||||
### 특정 사용자가 RP만 조회해야 하는 경우
|
||||
|
||||
최소 관계:
|
||||
|
||||
- `audit_viewer`, `consent_viewer`, `jwks_viewer` 등 필요한 세부 조회 관계 중 하나
|
||||
|
||||
위 세부 관계는 모두 `RelyingParty#view`를 포함하므로, 사용자는 DevFront에서 해당 RP를 볼 수 있다.
|
||||
|
||||
### 특정 사용자가 동의 및 사용자 목록만 봐야 하는 경우
|
||||
|
||||
부여 관계:
|
||||
|
||||
- `consent_viewer`
|
||||
|
||||
허용 결과:
|
||||
|
||||
- RP 목록/상세 조회
|
||||
- `동의 및 사용자 목록` 조회
|
||||
|
||||
허용하지 않는 기능:
|
||||
|
||||
- 동의 회수
|
||||
- secret 재발급
|
||||
- JWKS refresh/revoke
|
||||
- 관계 부여/회수
|
||||
- 상태 변경
|
||||
|
||||
### 특정 사용자가 동의를 조회하고 회수도 해야 하는 경우
|
||||
|
||||
부여 관계:
|
||||
|
||||
- `consent_revoker`
|
||||
|
||||
허용 결과:
|
||||
|
||||
- `revoke_consents` permit
|
||||
- `view_consents` permit도 함께 허용
|
||||
|
||||
### 특정 사용자가 감사 로그만 봐야 하는 경우
|
||||
|
||||
부여 관계:
|
||||
|
||||
- `audit_viewer`
|
||||
|
||||
허용 결과:
|
||||
|
||||
- RP 목록/상세 조회
|
||||
- 해당 RP와 연결된 DevFront 감사 로그 조회
|
||||
|
||||
주의:
|
||||
|
||||
- 감사 로그 필터링은 audit details의 `target_id` 또는 `client_id`가 RP client id와 일치하는지 기준으로 동작한다.
|
||||
- 오래된 로그 또는 일부 경로에서 `target_id`/`client_id`가 누락된 로그는 RP별 권한 사용자에게 보이지 않을 수 있다.
|
||||
|
||||
### 특정 사용자를 RP 운영 담당자로 지정해야 하는 경우
|
||||
|
||||
부여 관계:
|
||||
|
||||
- `admins`
|
||||
|
||||
허용 결과:
|
||||
|
||||
- `manage` permit
|
||||
- 대부분의 세부 운영 권한 허용
|
||||
- consent 조회/회수, 감사 로그 조회, 관계 조회, 상태 변경 등 포함
|
||||
|
||||
주의:
|
||||
|
||||
- `admins`는 강한 권한이다.
|
||||
- 단순 조회나 특정 작업만 필요하면 세부 relation을 우선 사용한다.
|
||||
|
||||
## 자동 부여 관계
|
||||
|
||||
RP 생성 시 `metadata.user_id`가 존재하면 생성자에게 기본 운영 relation 세트가 outbox로 적재된다.
|
||||
|
||||
현재 자동 부여 대상:
|
||||
|
||||
- `admins`
|
||||
- `creator`
|
||||
- `config_editor`
|
||||
- `secret_viewer`
|
||||
- `secret_rotator`
|
||||
- `jwks_viewer`
|
||||
- `jwks_operator`
|
||||
- `consent_viewer`
|
||||
- `consent_revoker`
|
||||
- `relationship_viewer`
|
||||
- `audit_viewer`
|
||||
- `status_operator`
|
||||
|
||||
`creator`는 이 자동 부여 세트에는 포함되지만, 운영자가 관계 탭에서 수동으로 선택하는 관계는 아니다. 생성자 표시는 장기적으로 relation 부여 여부가 아니라 RP metadata 또는 audit read model 기반의 읽기 전용 정보로 제공하는 방향이 적절하다.
|
||||
|
||||
## 관련 tuple 예시
|
||||
|
||||
```text
|
||||
RelyingParty:client-a#admins@User:user-1
|
||||
RelyingParty:client-a#consent_viewer@User:user-2
|
||||
RelyingParty:client-a#consent_revoker@User:user-3
|
||||
RelyingParty:client-a#audit_viewer@User:user-4
|
||||
RelyingParty:client-a#relationship_viewer@User:user-5
|
||||
```
|
||||
|
||||
## 운영 주의사항
|
||||
|
||||
- 관계 부여/회수는 direct Keto write가 아니라 outbox 적재 방식으로 처리한다.
|
||||
- 관계를 부여한 직후 실제 Keto 반영까지 worker 처리 지연이 있을 수 있다.
|
||||
- 사용자가 DevFront에서 기대 권한을 얻지 못하면 다음을 우선 확인한다.
|
||||
- relation tuple의 subject가 실제 로그인한 Kratos identity id와 같은지
|
||||
- outbox worker가 tuple을 Keto에 반영했는지
|
||||
- 대상 RP의 client id가 tuple object와 같은지
|
||||
- audit/consent 로그에 `client_id` 또는 `target_id`가 정확히 기록되는지
|
||||
@@ -34,13 +34,18 @@ classDiagram
|
||||
<<Namespace>>
|
||||
-- Relations --
|
||||
owners: User[]
|
||||
admins: User[] | SubjectSet~Tenant, owners~
|
||||
admins: User[] | SubjectSet~System, super_admins~
|
||||
members: User[]
|
||||
parents: Tenant[]
|
||||
developer_console_viewer: User[]
|
||||
developer_console_grant_manager: User[]
|
||||
-- Permits --
|
||||
view: members OR admins OR parents.view
|
||||
manage: admins OR parents.manage
|
||||
manage: admins OR owners OR parents.manage
|
||||
manage_admins: owners OR parents.manage_admins
|
||||
create_subtenant: manage
|
||||
view_dev_console: developer_console_viewer OR grant_dev_permissions OR manage OR parents.view_dev_console
|
||||
grant_dev_permissions: developer_console_grant_manager OR manage_admins OR parents.grant_dev_permissions
|
||||
}
|
||||
|
||||
class RelyingParty {
|
||||
@@ -49,19 +54,40 @@ classDiagram
|
||||
admins: User[]
|
||||
parents: Tenant[]
|
||||
access: User[] | SubjectSet~Tenant, members~ | SubjectSet~System, authenticated_users~
|
||||
creator: User[]
|
||||
config_editor: User[]
|
||||
secret_rotator: User[]
|
||||
jwks_viewer: User[]
|
||||
jwks_operator: User[]
|
||||
consent_viewer: User[]
|
||||
consent_revoker: User[]
|
||||
relationship_viewer: User[]
|
||||
audit_viewer: User[]
|
||||
status_operator: User[]
|
||||
-- Permits --
|
||||
view: admins OR parents.view
|
||||
view: admins OR direct operator relations OR parents.view OR parents.view_dev_console
|
||||
manage: admins OR parents.manage
|
||||
create: creator OR parents.grant_dev_permissions OR manage
|
||||
edit_config: config_editor OR manage
|
||||
rotate_secret: secret_rotator OR manage
|
||||
view_jwks: jwks_viewer OR operate_jwks OR manage
|
||||
operate_jwks: jwks_operator OR manage
|
||||
view_consents: consent_viewer OR revoke_consents OR manage
|
||||
revoke_consents: consent_revoker OR manage
|
||||
view_relationships: relationship_viewer OR parents.grant_dev_permissions OR manage
|
||||
view_audit_logs: audit_viewer OR manage
|
||||
change_status: status_operator OR manage
|
||||
access: access OR manage
|
||||
}
|
||||
|
||||
%% Relationship lines indicating references (SubjectSets or Direct inclusion)
|
||||
User ..> System : super_admins, authenticated_users
|
||||
User ..> Tenant : owners, admins, members
|
||||
User ..> RelyingParty : admins, access
|
||||
User ..> Tenant : owners, admins, members, developer_console_*
|
||||
User ..> RelyingParty : admins, access, operators
|
||||
|
||||
Tenant "1" --> "*" Tenant : parents (상위 조직 상속)
|
||||
Tenant ..> RelyingParty : parents (소유권 상속)
|
||||
Tenant ..> RelyingParty : view_dev_console / grant_dev_permissions (범위 권한)
|
||||
Tenant ..> RelyingParty : access (members 접근 권한)
|
||||
|
||||
System ..> RelyingParty : access (authenticated_users)
|
||||
@@ -77,11 +103,22 @@ classDiagram
|
||||
|
||||
- **Tenant (테넌트/조직):**
|
||||
- `view` (조회): 테넌트의 일반 멤버(`members`), 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다.
|
||||
- `manage` (관리): 테넌트의 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다.
|
||||
- _참고:_ 조직장(`owners`)은 자동으로 `admins` 집합(SubjectSet)에 포함됩니다.
|
||||
- `manage` (관리): 테넌트의 관리자(`admins`), 조직장(`owners`), 그리고 **상위 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다.
|
||||
- `manage_admins`: 조직장(`owners`)과 상위 테넌트의 `manage_admins` 상속 권한으로 판정합니다.
|
||||
- `view_dev_console`: 직접 부여된 DevFront 조회 relation, `grant_dev_permissions`, `manage`, 상위 tenant 상속으로 판정합니다.
|
||||
- `grant_dev_permissions`: 직접 부여된 DevFront 권한 부여 relation, `manage_admins`, 상위 tenant 상속으로 판정합니다.
|
||||
|
||||
- **RelyingParty (OIDC 앱):**
|
||||
- `view` (조회): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다.
|
||||
- `view` (조회): 앱의 직접 관리자(`admins`), 직접 운영 relation 보유자(`config_editor`, `jwks_viewer` 등), 또는 **이 앱을 소유한 테넌트(parents)에서 `view` 또는 `view_dev_console` 권한을 가진 자**가 조회할 수 있습니다.
|
||||
- `manage` (관리): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다.
|
||||
- `edit_config`, `rotate_secret`, `operate_jwks`, `revoke_consents`, `change_status`: 각 직접 relation 또는 `manage`로 판정합니다.
|
||||
- `view_relationships`: 직접 `relationship_viewer`, 상위 tenant의 `grant_dev_permissions`, 또는 `manage`로 판정합니다.
|
||||
- `view_audit_logs`: 직접 `audit_viewer` 또는 `manage`로 판정합니다.
|
||||
- `access` (접근/로그인 가능 여부): 이 앱에 직접 접근 권한을 부여받은 유저/그룹(`access`), 또는 앱을 관리할 수 있는 권한(`manage`)을 가진 사람이 접근할 수 있습니다.
|
||||
- _접근 대상(access)은 특정 유저, 특정 테넌트의 전 멤버, 또는 전역 인증된 유저(System:authenticated_users)가 될 수 있습니다._
|
||||
|
||||
### 설계 원칙 메모
|
||||
|
||||
- `view_dev_console`는 RP 목록/기본 정보 조회 범위를 주는 tenant 범위 권한입니다.
|
||||
- `view_dev_console`만으로 RP의 개별 운영 액션 permit이 자동 부여되지는 않습니다.
|
||||
- `manage`는 1차 하위호환 permit으로 유지하며, 세부 permit이 완전히 backend/API에 반영되기 전까지 상위 호환 의미를 가집니다.
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
따라서 조직도나 권한이 변경될 때마다 이를 Keto의 관계 튜플로 실시간 변환/전송하고, 권한 검증(Check)은 초고속 병렬 처리가 가능한 Keto 엔진으로 오프로딩(Offloading)합니다.
|
||||
|
||||
## 2. 네임스페이스 (Namespaces)
|
||||
과거 혼용되던 `UserGroup` 네임스페이스는 폐기되며, 철저한 권한 통제를 위해 아래 3개의 네임스페이스만 존재합니다.
|
||||
과거 혼용되던 `UserGroup` 네임스페이스는 폐기되며, 현재 Baron SSO는 아래 4개의 네임스페이스를 기준으로 ReBAC를 구성합니다.
|
||||
|
||||
1. **`Tenant`**: 모든 격리 공간 (회사, 지주사, 사내 부서, 개인 워크스페이스)
|
||||
2. **`RelyingParty`**: 테넌트가 소유하는 자원/앱 (OIDC 클라이언트)
|
||||
3. **`System`**: 테넌트에 종속되지 않는 전역 권한 (Super Admin 등)
|
||||
1. **`User`**: 권한의 subject가 되는 사용자
|
||||
2. **`Tenant`**: 모든 격리 공간 (회사, 지주사, 사내 부서, 개인 워크스페이스, 유저 그룹)
|
||||
3. **`RelyingParty`**: 테넌트가 소유하는 자원/앱 (OIDC 클라이언트)
|
||||
4. **`System`**: 테넌트에 종속되지 않는 전역 권한 (Super Admin 등)
|
||||
|
||||
## 3. 관계 튜플 규칙 (Relationship Tuples)
|
||||
|
||||
@@ -20,6 +21,18 @@
|
||||
- **어드민 자동 상속**: `Tenant:<조직ID>#admins@Tenant:<조직ID>#owners`
|
||||
- **테넌트 계층(부모-자식)**: `Tenant:<하위ID>#parents@Tenant:<상위ID>`
|
||||
*(상위 테넌트의 `admins`는 하위 테넌트의 모든 권한을 상속받습니다.)*
|
||||
- **DevFront 조회 범위 부여**: `Tenant:<조직ID>#developer_console_viewer@User:<유저ID>`
|
||||
- **DevFront 권한 부여 범위 부여**: `Tenant:<조직ID>#developer_console_grant_manager@User:<유저ID>`
|
||||
|
||||
### 3.1.1 Tenant permit 원칙
|
||||
- `view`: 멤버/관리자/상위 tenant 상속 기준의 tenant 조회 권한
|
||||
- `manage`: 관리자/오너/상위 tenant 상속 기준의 tenant 관리 권한
|
||||
- `manage_admins`: 오너 및 상위 tenant 상속 기준의 관리자 관계 관리 권한
|
||||
- `create_subtenant`: `manage`를 가진 주체가 하위 tenant를 생성하는 권한
|
||||
- `view_dev_console`: DevFront 진입 및 tenant 범위 RP 목록/기본 정보 조회 권한
|
||||
- `grant_dev_permissions`: tenant 범위 RP 운영 관계를 부여/회수할 수 있는 상위 권한
|
||||
|
||||
`view_dev_console`와 `grant_dev_permissions`는 `Tenant#manage`와 별개 축으로 분리합니다. 다만 1차 구현에서는 하위호환을 위해 `manage` 또는 `manage_admins`를 가진 주체가 각각 `view_dev_console`, `grant_dev_permissions`도 함께 가지는 모델로 둡니다.
|
||||
|
||||
### 3.2 Relying Party (앱 자원) 제어 및 RP Admin
|
||||
RP에 별도의 가상 테넌트를 만들지 않고, 자원 객체 자체의 다중 상속을 사용합니다.
|
||||
@@ -27,6 +40,46 @@ RP에 별도의 가상 테넌트를 만들지 않고, 자원 객체 자체의
|
||||
- **전담 관리자(RP Admin) 직접 할당**: `RelyingParty:<앱ID>#admins@User:<유저ID>`
|
||||
- **Private 앱 접근 허용**: `RelyingParty:<앱ID>#access@Tenant:<소유테넌트ID>#members`
|
||||
- **Public 앱 접근 허용**: `RelyingParty:<앱ID>#access@System:authenticated_users#members`
|
||||
- **RP 생성 권한 부여**: `RelyingParty:<앱ID>#creator@User:<유저ID>`
|
||||
- **RP 설정 수정 권한 부여**: `RelyingParty:<앱ID>#config_editor@User:<유저ID>`
|
||||
- **Client Secret rotate 권한 부여**: `RelyingParty:<앱ID>#secret_rotator@User:<유저ID>`
|
||||
- **JWKS 조회 권한 부여**: `RelyingParty:<앱ID>#jwks_viewer@User:<유저ID>`
|
||||
- **JWKS 운영 권한 부여**: `RelyingParty:<앱ID>#jwks_operator@User:<유저ID>`
|
||||
- **Consent 조회 권한 부여**: `RelyingParty:<앱ID>#consent_viewer@User:<유저ID>`
|
||||
- **Consent 회수 권한 부여**: `RelyingParty:<앱ID>#consent_revoker@User:<유저ID>`
|
||||
- **Relationship 조회 권한 부여**: `RelyingParty:<앱ID>#relationship_viewer@User:<유저ID>`
|
||||
- **감사 로그 조회 권한 부여**: `RelyingParty:<앱ID>#audit_viewer@User:<유저ID>`
|
||||
- **상태 변경 권한 부여**: `RelyingParty:<앱ID>#status_operator@User:<유저ID>`
|
||||
|
||||
### 3.2.1 RelyingParty permit 원칙
|
||||
- `view`: RP 상세 및 기본 메타데이터 조회 권한
|
||||
- `manage`: 기존 호환용 상위 관리 권한
|
||||
- `create`: RP 생성 권한
|
||||
- `edit_config`: RP 일반 설정 수정 권한
|
||||
- `rotate_secret`: client secret 재발급/rotate 권한
|
||||
- `view_jwks`: JWKS 상태/캐시/key summary 조회 권한
|
||||
- `operate_jwks`: JWKS refresh/revoke 수행 권한
|
||||
- `view_consents`: consent 목록/상세 조회 권한
|
||||
- `revoke_consents`: consent 회수 권한
|
||||
- `view_relationships`: direct / inherited relationship 조회 권한
|
||||
- `view_audit_logs`: 해당 RP의 DevFront 감사 로그 조회 권한
|
||||
- `change_status`: 활성/비활성 상태 변경 권한
|
||||
- `access`: 실제 서비스 로그인 및 리소스 접근 권한
|
||||
|
||||
1차 구현 원칙은 다음과 같습니다.
|
||||
- `RelyingParty#manage`는 제거하지 않고 유지합니다.
|
||||
- `manage`는 신규 세부 permit의 상위 호환 permit으로 동작합니다.
|
||||
- `access`는 서비스 접근 권한이며 DevFront 운영 권한과 동일시하지 않습니다.
|
||||
- `Tenant#view_dev_console`는 RP 목록/기본 정보 조회 범위를 주지만, `edit_config`, `operate_jwks`, `revoke_consents` 같은 개별 운영 액션 permit을 자동 부여하지 않습니다.
|
||||
- RP 개별 운영 액션은 `RelyingParty` 세부 permit으로 직접 판정합니다.
|
||||
|
||||
### 3.3 유저 그룹 subject set 규칙
|
||||
현재 구현에서 유저 그룹은 별도 `UserGroup` namespace를 사용하지 않고, `Tenant` namespace 내부의 유저 그룹 tenant와 subject set으로 표현합니다.
|
||||
|
||||
- **유저 그룹 멤버십**: `Tenant:<GroupTenantID>#members@User:<UserID>`
|
||||
- **유저 그룹 전체에 tenant role 부여**: `Tenant:<TenantID>#<Relation>@Tenant:<GroupTenantID>#members`
|
||||
|
||||
즉, 문서에서 말하는 “유저 그룹 멤버 전체”는 실제 Keto tuple에서 `Tenant:<groupId>#members` subject set으로 표현됩니다.
|
||||
|
||||
## 4. 트랜잭셔널 아웃박스를 통한 정합성 확보
|
||||
Keto와의 데이터 일관성 문제는 시스템의 치명적인 아킬레스건입니다.
|
||||
|
||||
@@ -57,13 +57,13 @@ Ory Keto 내부적으로는 다음과 같은 관계 튜플(Relationship Tuples)
|
||||
|
||||
### 3.1 그룹 멤버십 (Group Membership)
|
||||
사용자를 특정 유저 그룹의 멤버로 등록합니다.
|
||||
- **Tuple:** `UserGroup:<GroupID>#members@User:<UserID>`
|
||||
- **의미:** `UserID` 사용자는 `GroupID` 유저 그룹의 멤버이다.
|
||||
- **Tuple:** `Tenant:<GroupID>#members@User:<UserID>`
|
||||
- **의미:** `GroupID`에 해당하는 유저 그룹 tenant의 멤버로 `UserID` 사용자를 등록한다.
|
||||
|
||||
### 3.2 테넌트 권한 할당 (Tenant Role Assignment)
|
||||
유저 그룹 전체에 특정 테넌트에 대한 역할을 부여합니다.
|
||||
- **Tuple:** `Tenant:<TenantID>#<Relation>@UserGroup:<GroupID>#members`
|
||||
- **의미:** `GroupID` 유저 그룹의 모든 멤버는 `TenantID` 테넌트에 대해 `<Relation>`(예: `view`, `manage`, `admins`) 권한을 가진다.
|
||||
- **Tuple:** `Tenant:<TenantID>#<Relation>@Tenant:<GroupID>#members`
|
||||
- **의미:** `GroupID` 유저 그룹 tenant의 모든 멤버는 `TenantID` 테넌트에 대해 `<Relation>`(예: `view`, `manage`, `admins`) 권한을 가진다.
|
||||
|
||||
### 3.3 자원 소유 및 전파 (Resource Ownership)
|
||||
테넌트가 소유한 하위 자원(RP, API Key 등)에 대한 권한 전파 규칙입니다.
|
||||
@@ -76,7 +76,13 @@ Ory Keto 내부적으로는 다음과 같은 관계 튜플(Relationship Tuples)
|
||||
2. **복합 권한 구성:** 하나의 그룹이 여러 테넌트에 대해 서로 다른 수준의 권한을 가질 수 있어, 실제 조직 구조와 프로젝트 협업 모델을 유연하게 반영할 수 있습니다.
|
||||
3. **Zanzibar 스타일 확장성:** Google Zanzibar 논리를 따르는 Ory Keto를 활용함으로써, 향후 수만 명의 사용자와 수천 개의 테넌트 환경에서도 성능 저하 없이 정교한 권한 체크가 가능합니다.
|
||||
|
||||
## 5. 관련 구현 파일
|
||||
## 5. 현재 구현 기준 주의사항
|
||||
|
||||
- 현재 Baron SSO는 별도 `UserGroup` namespace를 사용하지 않습니다.
|
||||
- 유저 그룹은 `Tenant` namespace 내부의 특수 tenant(`type = USER_GROUP`)로 표현합니다.
|
||||
- 따라서 group membership과 group-based role assignment는 모두 `Tenant:<groupId>#members` subject set을 기준으로 해석해야 합니다.
|
||||
|
||||
## 6. 관련 구현 파일
|
||||
- **Backend Service:** `backend/internal/service/user_group_service.go`
|
||||
- **Backend Handler:** `backend/internal/handler/user_group_handler.go`
|
||||
- **Frontend API:** `adminfront/src/lib/adminApi.ts`
|
||||
|
||||
@@ -497,6 +497,9 @@ rp_admin = "RP administrators can only access resources for the apps they manage
|
||||
tenant_admin = "Tenant administrator permissions are not configured correctly or have expired."
|
||||
title = "Access Denied: {{resource}}"
|
||||
user = "Regular users cannot access the developer console."
|
||||
user.audit = "Viewing audit logs for this App (RP) is only available when granted 'RP Admin' or 'Audit View' relationships. If you need access, please request it from an administrator."
|
||||
user.clients = "General user accounts can only use this feature if they have been granted operational or management relationships for the relevant RP (App). If you need access, please request it from an administrator."
|
||||
user.consents = "Viewing consent history for this App (RP) is only available when granted 'RP Admin', 'Consent View', or 'Consent Revoke' relationships. If you need access, please request it from an administrator."
|
||||
|
||||
[msg.dev.sidebar]
|
||||
notice = "Developer Console"
|
||||
|
||||
@@ -899,6 +899,9 @@ rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니
|
||||
tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다."
|
||||
title = "{{resource}} 접근 권한 없음"
|
||||
user = "일반 사용자는 관리자 화면에 접근할 수 없습니다."
|
||||
user.audit = "해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
|
||||
user.clients = "일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
|
||||
user.consents = "해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
|
||||
|
||||
[msg.dev.sidebar]
|
||||
notice = "개발자 전용 콘솔입니다."
|
||||
|
||||
@@ -774,6 +774,9 @@ rp_admin = ""
|
||||
tenant_admin = ""
|
||||
title = ""
|
||||
user = ""
|
||||
user.audit = ""
|
||||
user.clients = ""
|
||||
user.consents = ""
|
||||
|
||||
[msg.dev.sidebar]
|
||||
notice = ""
|
||||
|
||||
@@ -70,9 +70,11 @@ if [ "$provision_exit_code" -ne 0 ]; then
|
||||
fi
|
||||
|
||||
set +e
|
||||
port="$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"
|
||||
echo "==> adminfront using PORT=$port"
|
||||
(
|
||||
cd adminfront
|
||||
npm test
|
||||
PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" npm test
|
||||
) 2>&1 | tee reports/adminfront-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
Reference in New Issue
Block a user