1
0
forked from baron/baron-sso

Merge pull request 'feature/user-group2' (#266) from feature/user-group2 into dev

Reviewed-on: baron/baron-sso#266
This commit is contained in:
2026-02-13 14:56:59 +09:00
14 changed files with 570 additions and 6 deletions

View File

@@ -4,6 +4,7 @@ import {
Key, Key,
KeyRound, KeyRound,
LayoutDashboard, LayoutDashboard,
LogOut,
Moon, Moon,
NotebookTabs, NotebookTabs,
ShieldHalf, ShieldHalf,
@@ -11,7 +12,7 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import LanguageSelector from "../common/LanguageSelector"; import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher"; import RoleSwitcher from "./RoleSwitcher";
@@ -39,11 +40,28 @@ const navItems = [
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
]; ];
function AppLayout() { function AppLayout() {
const navigate = useNavigate();
const [theme, setTheme] = useState<"light" | "dark">(() => { const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme"); const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light"; return stored === "dark" ? "dark" : "light";
}); });
const handleLogout = () => {
if (
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
) {
window.localStorage.removeItem("admin_session");
navigate("/login");
}
};
useEffect(() => {
const session = window.localStorage.getItem("admin_session");
if (!session) {
navigate("/login");
}
}, [navigate]);
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
root.classList.remove("light", "dark"); root.classList.remove("light", "dark");
@@ -109,6 +127,17 @@ function AppLayout() {
</NavLink> </NavLink>
))} ))}
</div> </div>
<div className="px-3 pt-4 border-t border-border/50">
<button
type="button"
onClick={handleLogout}
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
</div>
</nav> </nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block"> <div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
<p> <p>

View File

@@ -80,6 +80,7 @@ type UserProfileResponse struct {
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"` Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
} }
type UpdateUserRequest struct { type UpdateUserRequest struct {

View File

@@ -0,0 +1,137 @@
package handler
import (
"baron-sso-backend/internal/domain"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// --- Mocks ---
type MockUserGroupService struct {
mock.Mock
}
func (m *MockUserGroupService) Create(ctx context.Context, group *domain.UserGroup) error {
return m.Called(ctx, group).Error(0)
}
func (m *MockUserGroupService) Update(ctx context.Context, group *domain.UserGroup) error {
return m.Called(ctx, group).Error(0)
}
func (m *MockUserGroupService) Delete(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
func (m *MockUserGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.UserGroup), args.Error(1)
}
func (m *MockUserGroupService) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
args := m.Called(ctx, tenantID)
return args.Get(0).([]domain.UserGroup), args.Error(1)
}
func (m *MockUserGroupService) AddMember(ctx context.Context, groupID, userID string) error {
return m.Called(ctx, groupID, userID).Error(0)
}
func (m *MockUserGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
return m.Called(ctx, groupID, userID).Error(0)
}
func (m *MockUserGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
args := m.Called(ctx, groupID)
return args.Get(0).([]domain.GroupRole), args.Error(1)
}
func (m *MockUserGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error {
return m.Called(ctx, groupID, tenantID, relation).Error(0)
}
func (m *MockUserGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
return m.Called(ctx, groupID, tenantID, relation).Error(0)
}
// --- Tests ---
func TestUserGroupHandler_List(t *testing.T) {
mockSvc := new(MockUserGroupService)
h := NewUserGroupHandler(mockSvc)
app := fiber.New()
app.Get("/tenants/:tenantId/user-groups", h.List)
tenantID := "t1"
groups := []domain.UserGroup{{ID: "g1", Name: "Group 1"}}
mockSvc.On("List", mock.Anything, tenantID).Return(groups, nil)
req := httptest.NewRequest("GET", "/tenants/t1/user-groups", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result []domain.UserGroup
json.NewDecoder(resp.Body).Decode(&result)
assert.Len(t, result, 1)
assert.Equal(t, "Group 1", result[0].Name)
}
func TestUserGroupHandler_Create(t *testing.T) {
mockSvc := new(MockUserGroupService)
h := NewUserGroupHandler(mockSvc)
app := fiber.New()
app.Post("/tenants/:tenantId/user-groups", h.Create)
body, _ := json.Marshal(map[string]string{"name": "New Group"})
mockSvc.On("Create", mock.Anything, mock.MatchedBy(func(g *domain.UserGroup) bool {
return g.Name == "New Group" && g.TenantID == "t1"
})).Return(nil)
req := httptest.NewRequest("POST", "/tenants/t1/user-groups", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
}
func TestUserGroupHandler_AddMember(t *testing.T) {
mockSvc := new(MockUserGroupService)
h := NewUserGroupHandler(mockSvc)
app := fiber.New()
app.Post("/user-groups/:id/members", h.AddMember)
groupID := "g1"
userID := "u1"
body, _ := json.Marshal(map[string]string{"userId": userID})
mockSvc.On("AddMember", mock.Anything, groupID, userID).Return(nil)
req := httptest.NewRequest("POST", "/user-groups/g1/members", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestUserGroupHandler_AssignRole(t *testing.T) {
mockSvc := new(MockUserGroupService)
h := NewUserGroupHandler(mockSvc)
app := fiber.New()
app.Post("/user-groups/:id/roles", h.AssignRole)
groupID := "g1"
targetTenantID := "t2"
relation := "manage"
body, _ := json.Marshal(map[string]string{"tenantId": targetTenantID, "relation": relation})
mockSvc.On("AssignRoleToTenant", mock.Anything, groupID, targetTenantID, relation).Return(nil)
req := httptest.NewRequest("POST", "/user-groups/g1/roles", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}

View File

@@ -14,6 +14,7 @@ type TenantRepository interface {
FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
FindByName(ctx context.Context, name string) (*domain.Tenant, error) FindByName(ctx context.Context, name string) (*domain.Tenant, error)
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
AddDomain(ctx context.Context, tenantID string, domainName string) error AddDomain(ctx context.Context, tenantID string, domainName string) error
} }
@@ -70,6 +71,17 @@ func (r *tenantRepository) FindByDomain(ctx context.Context, domainName string)
return &tenant, nil return &tenant, nil
} }
func (r *tenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
var tenants []domain.Tenant
if len(ids) == 0 {
return tenants, nil
}
if err := r.db.WithContext(ctx).Preload("Domains").Where("id IN ?", ids).Find(&tenants).Error; err != nil {
return nil, err
}
return tenants, nil
}
func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error { func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error {
td := domain.TenantDomain{ td := domain.TenantDomain{
TenantID: tenantID, TenantID: tenantID,

View File

@@ -18,6 +18,7 @@ type KetoService interface {
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error
ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error)
ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error)
} }
type ketoService struct { type ketoService struct {
@@ -192,3 +193,46 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel
slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject) slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
return nil return nil
} }
func (s *ketoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.readURL))
q := u.Query()
if namespace != "" {
q.Set("namespace", namespace)
}
if relation != "" {
q.Set("relation", relation)
}
if subject != "" {
q.Set("subject_id", subject)
}
u.RawQuery = q.Encode()
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
}
var res relationTuplesResponse
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
objects := make([]string, 0, len(res.RelationTuples))
seen := make(map[string]bool)
for _, rt := range res.RelationTuples {
if !seen[rt.Object] {
objects = append(objects, rt.Object)
seen[rt.Object] = true
}
}
return objects, nil
}

View File

@@ -57,9 +57,12 @@ func TestKetoService_CreateRelation(t *testing.T) {
func TestKetoService_DeleteRelation(t *testing.T) { func TestKetoService_DeleteRelation(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/relation-tuples", r.URL.Path) assert.Equal(t, "/admin/relation-tuples", r.URL.Path)
assert.Equal(t, "DELETE", r.Method) assert.Equal(t, "DELETE", r.Method)
assert.Equal(t, "user1", r.URL.Query().Get("subject_id")) assert.Equal(t, "user1", r.URL.Query().Get("subject_id"))
assert.Equal(t, "tenants", r.URL.Query().Get("namespace"))
assert.Equal(t, "tenant1", r.URL.Query().Get("object"))
assert.Equal(t, "admin", r.URL.Query().Get("relation"))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
}) })

View File

@@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object,
return args.Get(0).([]RelationTuple), args.Error(1) return args.Get(0).([]RelationTuple), args.Error(1)
} }
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
// --- Test Helpers --- // --- Test Helpers ---
type hydraRoundTripperFunc func(*http.Request) (*http.Response, error) type hydraRoundTripperFunc func(*http.Request) (*http.Response, error)

View File

@@ -0,0 +1,202 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// --- Mocks for Repositories ---
type MockUserGroupRepository struct {
mock.Mock
}
func (m *MockUserGroupRepository) Create(ctx context.Context, group *domain.UserGroup) error {
return m.Called(ctx, group).Error(0)
}
func (m *MockUserGroupRepository) Update(ctx context.Context, group *domain.UserGroup) error {
return m.Called(ctx, group).Error(0)
}
func (m *MockUserGroupRepository) Delete(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
func (m *MockUserGroupRepository) FindByID(ctx context.Context, id string) (*domain.UserGroup, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.UserGroup), args.Error(1)
}
func (m *MockUserGroupRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
args := m.Called(ctx, tenantID)
return args.Get(0).([]domain.UserGroup), args.Error(1)
}
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil
}
func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
return nil, nil
}
func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
args := m.Called(ctx, ids)
return args.Get(0).([]domain.User), args.Error(1)
}
func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
return nil, nil
}
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
return nil, 0, nil
}
type MockTenantRepository struct {
mock.Mock
}
func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error { return nil }
func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error { return nil }
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
args := m.Called(ctx, ids)
return args.Get(0).([]domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error {
return nil
}
// --- Tests ---
func TestUserGroupService_Create(t *testing.T) {
mockRepo := new(MockUserGroupRepository)
mockKeto := new(MockKetoService)
// We don't need userRepo or tenantRepo for Create
svc := NewUserGroupService(mockRepo, nil, nil, mockKeto, nil)
group := &domain.UserGroup{
ID: "group-1",
TenantID: "tenant-1",
Name: "Test Group",
}
mockRepo.On("Create", mock.Anything, group).Return(nil)
mockKeto.On("CreateRelation", mock.Anything, "UserGroup", group.ID, "parent_tenant", "Tenant:"+group.TenantID).Return(nil)
err := svc.Create(context.Background(), group)
assert.NoError(t, err)
mockRepo.AssertExpectations(t)
mockKeto.AssertExpectations(t)
}
func TestUserGroupService_AddMember(t *testing.T) {
mockKeto := new(MockKetoService)
svc := NewUserGroupService(nil, nil, nil, mockKeto, nil)
groupID := "group-1"
userID := "user-1"
mockKeto.On("CreateRelation", mock.Anything, "UserGroup", groupID, "members", "User:"+userID).Return(nil)
err := svc.AddMember(context.Background(), groupID, userID)
assert.NoError(t, err)
mockKeto.AssertExpectations(t)
}
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
mockKeto := new(MockKetoService)
svc := NewUserGroupService(nil, nil, nil, mockKeto, nil)
groupID := "group-1"
tenantID := "tenant-alpha"
relation := "manage"
expectedSubject := "UserGroup:" + groupID + "#members"
mockKeto.On("CreateRelation", mock.Anything, "Tenant", tenantID, relation, expectedSubject).Return(nil)
err := svc.AssignRoleToTenant(context.Background(), groupID, tenantID, relation)
assert.NoError(t, err)
mockKeto.AssertExpectations(t)
}
func TestUserGroupService_ListRoles(t *testing.T) {
mockKeto := new(MockKetoService)
mockTenantRepo := new(MockTenantRepository)
svc := NewUserGroupService(nil, nil, mockTenantRepo, mockKeto, nil)
groupID := "group-1"
subject := "UserGroup:" + groupID + "#members"
// Mock Keto relations
tuples := []RelationTuple{
{Object: "t1", Relation: "manage", SubjectID: subject},
{Object: "t2", Relation: "view", SubjectID: subject},
}
mockKeto.On("ListRelations", mock.Anything, "Tenant", "", "", subject).Return(tuples, nil)
// Mock Tenant fetching
tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant One"},
{ID: "t2", Name: "Tenant Two"},
}
mockTenantRepo.On("FindByIDs", mock.Anything, []string{"t1", "t2"}).Return(tenants, nil)
roles, err := svc.ListRoles(context.Background(), groupID)
assert.NoError(t, err)
assert.Len(t, roles, 2)
assert.Equal(t, "Tenant One", roles[0].TenantName)
assert.Equal(t, "manage", roles[0].Relation)
assert.Equal(t, "Tenant Two", roles[1].TenantName)
assert.Equal(t, "view", roles[1].Relation)
mockKeto.AssertExpectations(t)
mockTenantRepo.AssertExpectations(t)
}
func TestUserGroupService_Get_WithKratosFallback(t *testing.T) {
// This tests the logic where a user is in Keto but not in local DB
mockRepo := new(MockUserGroupRepository)
mockKeto := new(MockKetoService)
mockUserRepo := new(MockUserRepository)
// We need a way to mock KratosAdminService but it's a struct, not an interface.
// For this POC test, we'll focus on the Keto and UserRepo parts.
// If needed, we can refactor KratosAdminService to an interface.
svc := NewUserGroupService(mockRepo, mockUserRepo, nil, mockKeto, nil)
groupID := "group-1"
mockRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, Name: "Test"}, nil)
tuples := []RelationTuple{
{Object: groupID, Relation: "members", SubjectID: "User:u1"},
}
mockKeto.On("ListRelations", mock.Anything, "UserGroup", groupID, "members", "").Return(tuples, nil)
// User u1 not in local DB
mockUserRepo.On("FindByIDs", mock.Anything, []string{"u1"}).Return([]domain.User{}, nil)
group, err := svc.Get(context.Background(), groupID)
assert.NoError(t, err)
assert.NotNil(t, group)
// Members should be empty since Kratos is nil in this test setup
assert.Len(t, group.Members, 0)
}

View File

@@ -0,0 +1,72 @@
# Ory Keto 관계 튜플 샘플 가이드 (Relationship Tuple Samples)
이 문서는 Baron SSO의 **유저 그룹 중심 통합 권한 정책**을 구현하기 위해 Ory Keto에 저장되는 실제 데이터 샘플을 제공합니다.
## 1. 기본 명명 규칙 (Naming Convention)
- **Namespace:** `Tenant`, `UserGroup`, `RelyingParty`, `User`
- **Format:** `namespace:object_id#relation@subject` (subject는 `user_id`이거나 `namespace:object_id#relation` 형태의 Subject Set임)
---
## 2. 유저 그룹 및 멤버십 샘플 (User Group & Membership)
### 2.1 그룹 멤버 추가
"한맥 IT 개발팀(`hanmac-it-dev`) 그룹에 사용자 `alice`를 멤버로 추가"
- **Tuple:** `UserGroup:hanmac-it-dev#members@User:alice-uuid`
- **설명:** alice는 이제 해당 그룹의 권한을 상속받을 준비가 됨.
### 2.2 그룹장(Leader) 임명
"사용자 `bob``hanmac-it-dev` 그룹의 그룹장으로 임명"
- **Tuple:** `UserGroup:hanmac-it-dev#owners@User:bob-uuid`
- **설명:** bob은 그룹의 소유권을 가지며, 정책에 따라 해당 테넌트의 어드민이 됨.
---
## 3. 정책 기반 자동 권한 상속 (Policy Bridges)
### 3.1 그룹장 -> 테넌트 어드민 승격
"그룹장은 해당 그룹(테넌트)의 어드민 권한을 가진다" (**핵심 정책**)
- **Tuple:** `Tenant:hanmac-it-dev#admins@UserGroup:hanmac-it-dev#owners`
- **설명:** 이 튜플 하나로 인해 `UserGroup:hanmac-it-dev#owners`에 속한 `bob``Tenant:hanmac-it-dev``admins` 자격을 얻음.
### 3.2 그룹 멤버 -> 테넌트 일반 권한
"그룹 멤버들은 해당 테넌트의 조회(view) 권한을 가진다"
- **Tuple:** `Tenant:hanmac-it-dev#members@UserGroup:hanmac-it-dev#members`
- **설명:** 그룹에 속한 모든 사용자가 테넌트 자원을 볼 수 있게 함.
---
## 4. 테넌트 간 권한 상속 및 자원 소유 (Hierarchy)
### 4.1 타 테넌트 관리 권한 부여
"개발팀 그룹(`hanmac-it-dev`)에게 `alpha-project` 테넌트의 관리 권한을 부여"
- **Tuple:** `Tenant:alpha-project#manage@UserGroup:hanmac-it-dev#members`
- **설명:** 이제 개발팀 멤버 전원이 `alpha-project`를 요리할 수 있음.
### 4.2 테넌트-자원(RP) 연결
"인증 앱(`messenger-app`)은 `hanmac-it-dev` 테넌트 소속이다"
- **Tuple:** `RelyingParty:messenger-app#parents@Tenant:hanmac-it-dev`
- **설명:** `hanmac-it-dev`를 관리하는 사람은 부모 관계를 통해 `messenger-app`도 관리할 수 있게 됨.
---
## 5. 종합 시나리오 예제: Hanmac Family
| 대상 리소스 | 관계 | 주체 (Subject) | 비고 |
| :--- | :--- | :--- | :--- |
| `UserGroup:hanmac-it` | `members` | `User:alice` | Alice는 IT팀 멤버 |
| `UserGroup:hanmac-it` | `owners` | `User:bob` | Bob은 IT팀 그룹장 |
| `Tenant:hanmac-it` | `admins` | `UserGroup:hanmac-it#owners` | **정책:** Bob은 IT 테넌트 어드민 |
| `Tenant:project-x` | `manage` | `UserGroup:hanmac-it#members` | IT팀 전체가 Project-X 관리 |
| `RelyingParty:rp-01` | `parents` | `Tenant:project-x` | rp-01은 Project-X 소속 |
### 🔍 권한 확인 (Check API) 결과:
1. **Bob은 `rp-01`을 관리할 수 있는가?**
- `bob` -> `UserGroup:hanmac-it#owners` -> `Tenant:hanmac-it#admins` -> (상속 시) -> `rp-01`
- **결과: YES**
2. **Alice는 `rp-01`을 관리할 수 있는가?**
- `alice` -> `UserGroup:hanmac-it#members` -> `Tenant:project-x#manage` -> (부모 관계) -> `rp-01`
- **결과: YES**
3. **Alice는 `Tenant:hanmac-it`의 설정을 바꿀 수 있는가?**
- Alice는 `members`일 뿐 `admins`가 아님.
- **결과: NO**

View File

@@ -355,6 +355,7 @@ title_with_code = "Title With Code"
type = "Type" type = "Type"
[msg.userfront.error.whitelist] [msg.userfront.error.whitelist]
"$normalizedCode" = "{{error}}"
settings_disabled = "Account settings are currently unavailable." settings_disabled = "Account settings are currently unavailable."
invalid_session = "Your session has expired. Please sign in again." invalid_session = "Your session has expired. Please sign in again."
verification_required = "Additional verification is required. Please follow the instructions." verification_required = "Additional verification is required. Please follow the instructions."
@@ -366,6 +367,7 @@ bad_request = "Please check your input."
password_or_email_mismatch = "Email or password does not match." password_or_email_mismatch = "Email or password does not match."
[msg.userfront.error.ory] [msg.userfront.error.ory]
"$normalizedCode" = "{{error}}"
access_denied = "The user denied the consent request." access_denied = "The user denied the consent request."
consent_required = "Consent is required to continue." consent_required = "Consent is required to continue."
interaction_required = "Additional interaction is required. Please try again." interaction_required = "Additional interaction is required. Please try again."
@@ -1336,6 +1338,6 @@ logout = "Logout"
overview = "Overview" overview = "Overview"
relying_parties = "Apps (RP)" relying_parties = "Apps (RP)"
tenant_dashboard = "Tenant Dashboard" tenant_dashboard = "Tenant Dashboard"
tenant_groups = "Tenant Groups" user_groups = "User Groups"
tenants = "Tenants" tenants = "Tenants"
users = "Users" users = "Users"

View File

@@ -355,6 +355,7 @@ title_with_code = "오류: {{code}}"
type = "오류 종류: {{type}}" type = "오류 종류: {{type}}"
[msg.userfront.error.whitelist] [msg.userfront.error.whitelist]
"$normalizedCode" = "{{error}}"
settings_disabled = "현재 계정 설정 화면은 준비 중입니다." settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요." invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요." verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
@@ -366,6 +367,7 @@ bad_request = "입력값을 확인해 주세요."
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다." password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
[msg.userfront.error.ory] [msg.userfront.error.ory]
"$normalizedCode" = "{{error}}"
access_denied = "사용자가 동의를 거부했습니다." access_denied = "사용자가 동의를 거부했습니다."
consent_required = "앱 접근 동의가 필요합니다." consent_required = "앱 접근 동의가 필요합니다."
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요." interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
@@ -1336,6 +1338,6 @@ logout = "로그아웃"
overview = "개요" overview = "개요"
relying_parties = "애플리케이션(RP)" relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드" tenant_dashboard = "테넌트 대시보드"
tenant_groups = "테넌트 그룹" user_groups = "유저 그룹"
tenants = "테넌트" tenants = "테넌트"
users = "사용자" users = "사용자"

View File

@@ -355,6 +355,7 @@ title_with_code = ""
type = "" type = ""
[msg.userfront.error.whitelist] [msg.userfront.error.whitelist]
"$normalizedCode" = ""
settings_disabled = "" settings_disabled = ""
invalid_session = "" invalid_session = ""
verification_required = "" verification_required = ""
@@ -366,6 +367,7 @@ bad_request = ""
password_or_email_mismatch = "" password_or_email_mismatch = ""
[msg.userfront.error.ory] [msg.userfront.error.ory]
"$normalizedCode" = ""
access_denied = "" access_denied = ""
consent_required = "" consent_required = ""
interaction_required = "" interaction_required = ""
@@ -690,7 +692,7 @@ logout = ""
overview = "" overview = ""
relying_parties = "" relying_parties = ""
tenant_dashboard = "" tenant_dashboard = ""
tenant_groups = "" user_groups = ""
tenants = "" tenants = ""
users = "" users = ""

View File

@@ -73,11 +73,16 @@ function parseTomlKeys(filePath) {
continue; continue;
} }
const key = line.slice(0, eqIndex).trim(); let key = line.slice(0, eqIndex).trim();
if (!key) { if (!key) {
continue; continue;
} }
// Strip quotes if present
if (key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1);
}
const fullKey = [...currentSection, key].join('.'); const fullKey = [...currentSection, key].join('.');
keys.add(fullKey); keys.add(fullKey);
} }

View File

@@ -0,0 +1,45 @@
// This file is used by tools/i18n-scanner to mark keys as used.
// These keys are either used dynamically or in a way that the scanner cannot detect.
import { t } from "../../adminfront/src/lib/i18n";
// Navigation
t("ui.admin.nav.overview");
t("ui.admin.nav.tenant_dashboard");
t("ui.admin.nav.user_groups");
t("ui.admin.nav.tenants");
t("ui.admin.nav.users");
t("ui.admin.nav.api_keys");
t("ui.admin.nav.audit_logs");
t("ui.admin.nav.auth_guard");
t("ui.admin.nav.logout");
t("ui.admin.nav.relying_parties");
// Common & Info
t("err.common.unknown");
t("msg.info.saved_success");
// Userfront Error - Ory
t("msg.userfront.error.ory.access_denied");
t("msg.userfront.error.ory.consent_required");
t("msg.userfront.error.ory.interaction_required");
t("msg.userfront.error.ory.invalid_client");
t("msg.userfront.error.ory.invalid_grant");
t("msg.userfront.error.ory.invalid_request");
t("msg.userfront.error.ory.invalid_scope");
t("msg.userfront.error.ory.login_required");
t("msg.userfront.error.ory.request_forbidden");
t("msg.userfront.error.ory.server_error");
t("msg.userfront.error.ory.temporarily_unavailable");
t("msg.userfront.error.ory.unauthorized_client");
t("msg.userfront.error.ory.unsupported_response_type");
// Userfront Error - Whitelist
t("msg.userfront.error.whitelist.bad_request");
t("msg.userfront.error.whitelist.invalid_session");
t("msg.userfront.error.whitelist.not_found");
t("msg.userfront.error.whitelist.password_or_email_mismatch");
t("msg.userfront.error.whitelist.rate_limited");
t("msg.userfront.error.whitelist.recovery_expired");
t("msg.userfront.error.whitelist.recovery_invalid");
t("msg.userfront.error.whitelist.verification_required");