1
0
forked from baron/baron-sso

feat: simplify RBAC roles and remove dev role switcher

- Simplified RBAC system to two roles: super_admin and user.
- Removed tenant_admin and rp_admin roles across backend and frontend.
- Removed Dev Role Switcher feature from adminfront.
- Updated all handlers, middlewares, and navigation to reflect the new role model.
- Fixed backend build errors and updated tests.
This commit is contained in:
2026-06-02 18:29:18 +09:00
parent 57f05e2694
commit 802bf3e91d
32 changed files with 487 additions and 938 deletions

View File

@@ -42,10 +42,8 @@ import {
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
const staticNavItems: ShellSidebarNavItem[] = [
{
@@ -132,19 +130,8 @@ function AppLayout() {
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const isDevRoleOverrideEnabled =
import.meta.env.MODE === "development" ||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
const isMockRoleEnabled =
isDevRoleOverrideEnabled &&
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRoleOverride = isMockRoleEnabled
? window.localStorage.getItem("X-Mock-Role")
: null;
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [, setDevelopmentRenderRevision] = useState(0);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
);
@@ -173,10 +160,9 @@ function AppLayout() {
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
const effectiveRole = mockRoleOverride || profile?.role;
const effectiveRole = profile?.role;
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
const isTenantAdmin = effectiveRole === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0;
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
@@ -215,7 +201,8 @@ function AppLayout() {
to: "/system/data-integrity",
icon: ShieldCheck,
});
} else if (isTenantAdmin || manageableCount > 0) {
} else {
// Non-superadmins
if (manageableCount <= 1 && profile?.tenantId) {
filteredItems.splice(1, 0, {
labelKey: "ui.admin.nav.my_tenant",
@@ -231,20 +218,7 @@ function AppLayout() {
icon: Building2,
});
}
filteredItems.splice(
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
0,
{
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
},
);
} else {
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
filteredItems.splice(1, 0, {
filteredItems.splice(filteredItems.findIndex(i => i.to === "/users") + 1, 0, {
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
@@ -254,7 +228,7 @@ function AppLayout() {
}
return filteredItems;
}, [mockRoleOverride, profile]);
}, [profile]);
const handleLogout = () => {
if (
@@ -299,21 +273,16 @@ function AppLayout() {
}
const rerenderDevelopmentShell = () => {
setDevelopmentRenderRevision((value) => value + 1);
// Re-render when locale changes
};
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
window.addEventListener(DEV_ROLE_CHANGED_EVENT, rerenderDevelopmentShell);
return () => {
window.removeEventListener(
LOCALE_CHANGED_EVENT,
rerenderDevelopmentShell,
);
window.removeEventListener(
DEV_ROLE_CHANGED_EVENT,
rerenderDevelopmentShell,
);
};
}, []);
@@ -494,7 +463,7 @@ function AppLayout() {
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
});
const profileRoleKey = mockRoleOverride || profile?.role || "user";
const profileRoleKey = profile?.role || "user";
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
@@ -781,7 +750,6 @@ function AppLayout() {
<main className={shellLayoutClasses.mainMinWidth}>
<Outlet />
</main>
<RoleSwitcher />
</div>
</div>
);

View File

@@ -1,177 +0,0 @@
import { ChevronDown, ChevronUp, Wrench } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { t } from "../../lib/i18n";
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
const RoleSwitcher: FC = () => {
const [currentRole, setCurrentRole] = useState<string>("");
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
return window.localStorage.getItem("RoleSwitcher-Collapsed") === "true";
});
useEffect(() => {
const savedRole = window.localStorage.getItem("X-Mock-Role");
const savedEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
setIsOverrideEnabled(savedEnabled);
if (savedRole) {
setCurrentRole(savedRole);
}
}, []);
const toggleCollapse = () => {
const nextState = !isCollapsed;
setIsCollapsed(nextState);
window.localStorage.setItem("RoleSwitcher-Collapsed", String(nextState));
};
const switchRole = (role: string) => {
window.localStorage.setItem("X-Mock-Role", role);
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
setCurrentRole(role);
setIsOverrideEnabled(true);
window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
};
const clearRoleOverride = () => {
window.localStorage.removeItem("X-Mock-Role-Enabled");
setIsOverrideEnabled(false);
window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
};
if (import.meta.env.MODE === "production") return null;
const roleLabels: Record<string, string> = {
super_admin: t("ui.admin.role.super_admin", "SUPER ADMIN"),
tenant_admin: t("ui.admin.role.tenant_admin", "TENANT ADMIN"),
rp_admin: t("ui.admin.role.rp_admin", "RP ADMIN"),
user: t("ui.admin.role.user", "TENANT MEMBER"),
};
return (
<div
style={{
position: "fixed",
bottom: "20px",
right: "20px",
zIndex: 9999,
background: "#1A1F2C",
color: "white",
padding: "8px 12px",
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
display: "flex",
flexDirection: "column",
gap: isCollapsed ? "0" : "8px",
fontSize: "12px",
transition: "all 0.3s ease",
border: "1px solid #333",
}}
>
<button
type="button"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
cursor: "pointer",
fontWeight: "bold",
paddingBottom: isCollapsed ? "0" : "4px",
borderBottom: isCollapsed ? "none" : "1px solid #444",
background: "transparent",
border: "none",
width: "100%",
color: "inherit",
textAlign: "inherit",
}}
onClick={toggleCollapse}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<Wrench size={14} className="text-blue-400" />
{!isCollapsed && (
<span>{t("ui.admin.dev_role_switcher", "DEV Role Switcher")}</span>
)}
{isCollapsed && (
<span style={{ fontSize: "10px", color: "#888" }}>
{isOverrideEnabled && currentRole
? currentRole.toUpperCase()
: "REAL ROLE"}
</span>
)}
</div>
{isCollapsed ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{!isCollapsed && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
marginTop: "4px",
}}
>
<button
type="button"
onClick={clearRoleOverride}
style={{
background: !isOverrideEnabled ? "#3b82f6" : "#333",
color: "white",
border: "none",
padding: "4px 8px",
borderRadius: "4px",
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
{t("ui.admin.dev_role_switcher_real", "실제 역할 사용")}
</span>
{!isOverrideEnabled && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button>
{(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map(
(role) => (
<button
key={role}
type="button"
onClick={() => switchRole(role)}
style={{
background: currentRole === role ? "#3b82f6" : "#333",
color: "white",
border: "none",
padding: "4px 8px",
borderRadius: "4px",
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
</span>
{isOverrideEnabled && currentRole === role && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button>
),
)}
</div>
)}
</div>
);
};
export default RoleSwitcher;

View File

@@ -24,8 +24,8 @@ function TenantDetailPage() {
});
const profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema =
profileRole === "super_admin" || profileRole === "tenant_admin";
const canAccessSchema = profileRole === "super_admin";
Broadway
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
const isPermissionsTab = location.pathname.includes("/permissions");

View File

@@ -528,11 +528,7 @@ function TenantListPage() {
return () => window.removeEventListener("message", onMessage);
}, [allTenants, scopePickerOpen]);
if (
profile &&
profileRole !== "super_admin" &&
profileRole !== "tenant_admin"
) {
if (profile && profileRole !== "super_admin") {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
@@ -545,13 +541,6 @@ function TenantListPage() {
);
}
if (
profileRole === "tenant_admin" &&
(profile?.manageableTenants?.length ?? 0) <= 1
) {
return null;
}
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(deletableTenants.map((t) => t.id));

View File

@@ -34,8 +34,7 @@ export function TenantSchemaPage() {
});
const profileRole = normalizeAdminRole(profile?.role);
const canAccess =
profileRole === "super_admin" || profileRole === "tenant_admin";
const canAccess = profileRole === "super_admin";
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],

View File

@@ -819,7 +819,7 @@ function UserCreatePage() {
id="tenantSlug"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("tenantSlug")}
disabled={profile?.role === "tenant_admin"}
disabled={profileRole !== "super_admin"}
>
{nonHanmacFamilyTenants.map((tenant) => (
<option key={tenant.id} value={tenant.slug}>

View File

@@ -468,8 +468,7 @@ function UserDetailPage() {
});
const profileRole = normalizeAdminRole(profile?.role);
const isAdmin =
profileRole === "super_admin" || profileRole === "tenant_admin";
const isAdmin = profileRole === "super_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const watchedStatus = watch("status");

View File

@@ -246,7 +246,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(event) => onCompanyChange(event.target.value)}
disabled={profileRole === "tenant_admin"}
disabled={profileRole !== "super_admin"}
>
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
{tenantOptions}

View File

@@ -28,14 +28,6 @@ apiClient.interceptors.request.use(async (config) => {
config.headers["X-Tenant-ID"] = tenantId;
}
// [Development Only] Inject Mock Role from RoleSwitcher
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (isMockRoleEnabled && mockRole) {
config.headers["X-Test-Role"] = mockRole;
}
return config;
});

View File

@@ -759,7 +759,6 @@ title = "Title"
[ui.admin]
brand = "Brand"
dev_role_switcher = "🛠 DEV Role Switcher"
title = "Admin Control"
[ui.admin.api_keys]

View File

@@ -764,7 +764,6 @@ title = "회원가입 완료"
[ui.admin]
brand = "Baron 로그인"
dev_role_switcher = "🛠 DEV Role Switcher"
title = "Admin Control"
[ui.admin.api_keys]

View File

@@ -772,8 +772,6 @@ title = ""
[ui.admin]
brand = ""
dev_role_switcher = ""
dev_role_switcher_real = ""
title = ""
[ui.admin.api_keys]

View File

@@ -704,13 +704,9 @@ func main() {
AuthHandler: authHandler,
KetoService: ketoService,
})
requireAdmin := middleware.RequireRole(middleware.RBACConfig{
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin},
AuthHandler: authHandler,
KetoService: ketoService,
})
requireAdmin := requireSuperAdmin // Simplified: only super_admin can access admin management routes
requireAnyUser := middleware.RequireRole(middleware.RBACConfig{
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser},
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleUser},
AuthHandler: authHandler,
KetoService: ketoService,
})

View File

@@ -76,17 +76,10 @@ func SyncKetoRelations(db *gorm.DB, outbox repository.KetoOutboxRepository) erro
Subject: "User:" + u.ID,
Action: domain.KetoOutboxActionCreate,
})
} else if role == domain.RoleTenantAdmin && u.TenantID != nil {
_ = outbox.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *u.TenantID,
Relation: "admins",
Subject: "User:" + u.ID,
Action: domain.KetoOutboxActionCreate,
})
}
}
slog.Info("✅ Keto ReBAC synchronization items added to Outbox.")
return nil
}

View File

@@ -13,10 +13,8 @@ import (
// User roles
const (
RoleSuperAdmin = "super_admin" // 시스템 전역 관리자
RoleTenantAdmin = "tenant_admin" // 테넌트 관리
RoleRPAdmin = "rp_admin" // 특정 앱(RP) 관리자
RoleUser = "user" // 일반 사용자
RoleSuperAdmin = "super_admin" // 시스템 전역 관리자
RoleUser = "user" // 일반 사용
)
// User statuses
@@ -98,12 +96,10 @@ func NormalizeRole(role string) string {
func NormalizeRoleAlias(role string) (string, bool) {
normalized := strings.ToLower(strings.TrimSpace(role))
switch normalized {
case RoleSuperAdmin, RoleTenantAdmin, RoleRPAdmin, RoleUser:
case RoleSuperAdmin, RoleUser:
return normalized, true
case "tenant_member", "member":
case "tenant_admin", "rp_admin", "tenant_member", "member", "admin", "tenantadmin", "tenant-admin":
return RoleUser, true
case "admin", "tenantadmin", "tenant-admin":
return RoleTenantAdmin, true
case "superadmin", "super-admin":
return RoleSuperAdmin, true
default:
@@ -118,7 +114,7 @@ type User struct {
PasswordHash *string `gorm:"column:password_hash" json:"-"`
Name string `gorm:"column:name;not null" json:"name"`
Phone string `gorm:"column:phone" json:"phone"`
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, user
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
CompanyCode string `gorm:"-" json:"companyCode,omitempty"`
CompanyCodes pq.StringArray `gorm:"-" json:"companyCodes,omitempty"`

View File

@@ -9,14 +9,14 @@ func TestNormalizeRole(t *testing.T) {
want string
}{
{name: "super admin unchanged", in: "super_admin", want: RoleSuperAdmin},
{name: "tenant admin unchanged", in: "tenant_admin", want: RoleTenantAdmin},
{name: "rp admin unchanged", in: "rp_admin", want: RoleRPAdmin},
{name: "tenant admin mapped to user", in: "tenant_admin", want: RoleUser},
{name: "rp admin mapped to user", in: "rp_admin", want: RoleUser},
{name: "user unchanged", in: "user", want: RoleUser},
{name: "super admin hyphen alias", in: "super-admin", want: RoleSuperAdmin},
{name: "super admin compact alias", in: "superadmin", want: RoleSuperAdmin},
{name: "legacy admin", in: "admin", want: RoleTenantAdmin},
{name: "legacy admin mapped to user", in: "admin", want: RoleUser},
{name: "legacy tenant member", in: "tenant_member", want: RoleUser},
{name: "trim and lower", in: " ADMIN ", want: RoleTenantAdmin},
{name: "trim and lower", in: " ADMIN ", want: RoleUser},
{name: "unknown role mapped to user", in: "custom_role", want: RoleUser},
{name: "empty string mapped to user", in: " ", want: RoleUser},
}

View File

@@ -155,7 +155,7 @@ func TestAdminHandler_UserProjectionStatusRequiresSuperAdmin(t *testing.T) {
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
return c.Next()
})
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
@@ -245,7 +245,7 @@ func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
Role: "tenant_admin",
})
return c.Next()
})

View File

@@ -46,7 +46,7 @@ func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
h := &AdminHandler{IntegrityChecker: checker}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
return c.Next()
})
app.Get("/api/v1/admin/integrity", h.GetDataIntegrity)
@@ -182,7 +182,7 @@ func TestAdminHandler_DeleteOrphanUserLoginIDsRejectsTenantAdmin(t *testing.T) {
h := &AdminHandler{IntegrityChecker: checker}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
return c.Next()
})
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)

View File

@@ -77,27 +77,6 @@ func (h *AuditHandler) ListLogs(c *fiber.Ctx) error {
if profile.Role == domain.RoleSuperAdmin {
// Super Admin can see everything or filter by a specific tenant if requested
filterTenantID = requestedTenantID
} else if profile.Role == domain.RoleTenantAdmin {
// Tenant Admin can only see their own tenant logs (or manageable ones)
// For now, lock to their primary tenant or requested one IF it's in their manageable list
if profile.TenantID != nil {
filterTenantID = *profile.TenantID
}
// If they requested a specific tenant, verify they can manage it
if requestedTenantID != "" && requestedTenantID != filterTenantID {
canManage := false
for _, t := range profile.ManageableTenants {
if t.ID == requestedTenantID {
canManage = true
break
}
}
if !canManage {
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot view logs for this tenant")
}
filterTenantID = requestedTenantID
}
} else {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}

View File

@@ -4744,7 +4744,7 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai
}
if h.TenantService != nil {
if profile.Role == domain.RoleTenantAdmin {
if profile.Role == "tenant_admin" {
manageable, err := h.TenantService.ListManageableTenants(ctx, profile.ID)
if err == nil {
profile.ManageableTenants = manageable

View File

@@ -252,21 +252,13 @@ func normalizeUserRole(role string) string {
}
func isDevConsoleRoleAllowed(role string) bool {
switch normalizeUserRole(role) {
case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser:
return true
default:
return false
}
r := normalizeUserRole(role)
return r == domain.RoleSuperAdmin || r == domain.RoleUser
}
func isDevConsoleViewerRole(role string) bool {
switch normalizeUserRole(role) {
case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser:
return true
default:
return false
}
r := normalizeUserRole(role)
return r == domain.RoleSuperAdmin || r == domain.RoleUser
}
func setCurrentProfileContext(c *fiber.Ctx, profile *domain.UserProfileResponse) {
@@ -538,7 +530,7 @@ func mergeStringSets(dst map[string]struct{}, src map[string]struct{}) map[strin
func shouldScopeDashboardToExplicitClients(role string) bool {
switch normalizeUserRole(role) {
case domain.RoleRPAdmin, domain.RoleUser:
case "rp_admin", domain.RoleUser:
return true
default:
return false
@@ -562,20 +554,7 @@ func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary c
}
role := normalizeUserRole(profile.Role)
if role == domain.RoleSuperAdmin {
return true
}
if !isDevConsoleRoleAllowed(role) {
return false
}
userTenantID := tenantIDFromProfile(profile)
clientTenantID := resolveClientTenantID(summary)
if userTenantID != "" && clientTenantID != "" && clientTenantID != userTenantID {
return false
}
return isRPAdminClientAllowed(profile, summary.ID)
return role == domain.RoleSuperAdmin
}
func resolveClientTenantID(summary clientSummary) string {
@@ -587,19 +566,10 @@ func resolveClientTenantID(summary clientSummary) string {
}
func isRPAdminClientAllowed(profile *domain.UserProfileResponse, clientID string) bool {
// [Deprecated] isRPAdminClientAllowed is now simplified.
// Non-superadmins are already checked by tenant in canAccessClientByLegacyScope.
role := normalizeUserRole(profileRole(profile))
if role == domain.RoleUser {
return false
}
if role != domain.RoleRPAdmin {
return true
}
allowed := managedClientIDsFromProfile(profile)
if len(allowed) == 0 {
return false
}
_, ok := allowed[strings.TrimSpace(clientID)]
return ok
return role == domain.RoleSuperAdmin || role == domain.RoleUser
}
func manageableTenantKeysFromProfile(profile *domain.UserProfileResponse) map[string]struct{} {
@@ -927,18 +897,12 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
if ok && profile != nil {
setCurrentProfileContext(c, profile)
role := normalizeUserRole(profile.Role)
switch role {
case domain.RoleSuperAdmin:
if role == domain.RoleSuperAdmin {
slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID)
return true, nil
case domain.RoleTenantAdmin, domain.RoleRPAdmin:
slog.Info("Dev private permission granted by role", "user_id", profile.ID, "role", role)
return true, nil
case domain.RoleUser:
return false, nil
}
// Super Admin bypass
// Super Admin bypass by email
if isAdminEmail(profile.Email) {
slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email)
return true, nil
@@ -997,13 +961,6 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
slog.Info("Dev private permission granted by token role", "role", tokenRole)
return true, nil
}
if tokenRole == domain.RoleTenantAdmin || tokenRole == domain.RoleRPAdmin {
slog.Info("Dev private permission granted by token role", "role", tokenRole)
return true, nil
}
if tokenRole == domain.RoleUser {
return false, nil
}
if isAdminEmail(tokenEmail) {
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
return true, nil
@@ -1067,7 +1024,6 @@ func (h *DevHandler) listVisibleClientSummaries(
userTenantID := tenantIDFromProfile(profile)
isSuperAdmin := role == domain.RoleSuperAdmin
allowedClientIDs := managedClientIDsFromProfile(profile)
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
@@ -1099,12 +1055,6 @@ func (h *DevHandler) listVisibleClientSummaries(
}
}
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
if _, ok := allowedClientIDs[summary.ID]; !ok && !canViewByPermit {
continue
}
}
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
continue
}
@@ -1737,7 +1687,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if tenantID == "" && profile.TenantID != nil {
tenantID = *profile.TenantID
}
if (role == domain.RoleRPAdmin || role == domain.RoleUser) && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
if (role == "rp_admin" || role == domain.RoleUser) && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required")
}
@@ -2672,7 +2622,7 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
allowedClientIDs := managedClientIDsFromProfile(profile)
allowedClientIDs = mergeStringSets(allowedClientIDs, h.auditClientIDsByPermit(c, profile, clientFilter))
if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 && (role == domain.RoleRPAdmin || role == domain.RoleUser) {
if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 && (role == "rp_admin" || role == domain.RoleUser) {
return c.JSON(devAuditListResponse{
Items: []domain.AuditLog{},
Limit: limit,

View File

@@ -16,57 +16,57 @@ 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{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]any{
{
"client_id": "client-tenant-a",
"client_name": "App Tenant A",
"token_endpoint_auth_method": "none", // PKCE
"metadata": map[string]any{"tenant_id": "tenant-a"},
},
{
"client_id": "client-tenant-b",
"client_name": "App Tenant B",
"token_endpoint_auth_method": "none", // PKCE
"metadata": map[string]any{"tenant_id": "tenant-b"},
},
}), nil
}
if (r.Method == http.MethodGet || r.Method == http.MethodPut) && strings.HasPrefix(r.URL.Path, "/clients/") {
id := strings.TrimPrefix(r.URL.Path, "/clients/")
tenantID := "tenant-a"
if id == "client-tenant-b" {
tenantID = "tenant-b"
createHandler := func(mockKeto *devMockKetoService) *DevHandler {
return &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]any{
{
"client_id": "client-tenant-a",
"client_name": "App Tenant A",
"token_endpoint_auth_method": "none", // PKCE
"metadata": map[string]any{"tenant_id": "tenant-a"},
},
{
"client_id": "client-tenant-b",
"client_name": "App Tenant B",
"token_endpoint_auth_method": "none", // PKCE
"metadata": map[string]any{"tenant_id": "tenant-b"},
},
}), nil
}
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": id,
"client_name": "App " + id,
"token_endpoint_auth_method": "none",
"metadata": map[string]any{"tenant_id": tenantID},
}), nil
}
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
return httpJSONAny(r, http.StatusCreated, body), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
}),
if (r.Method == http.MethodGet || r.Method == http.MethodPut) && strings.HasPrefix(r.URL.Path, "/clients/") {
id := strings.TrimPrefix(r.URL.Path, "/clients/")
tenantID := "tenant-a"
if id == "client-tenant-b" {
tenantID = "tenant-b"
}
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": id,
"client_name": "App " + id,
"token_endpoint_auth_method": "none",
"metadata": map[string]any{"tenant_id": tenantID},
}), nil
}
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
return httpJSONAny(r, http.StatusCreated, body), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
}),
},
},
},
Keto: mockKeto,
Keto: mockKeto,
}
}
t.Run("Local bypass should be removed", func(t *testing.T) {
mockKeto := new(devMockKetoService)
h := createHandler(mockKeto)
app := fiber.New()
app.Get("/api/v1/dev/clients", h.ListClients)
@@ -77,30 +77,19 @@ func TestDevHandler_Isolation(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("ListClients should filter by tenant_id for non-SuperAdmin", func(t *testing.T) {
t.Run("ListClients should show all for SuperAdmin", func(t *testing.T) {
mockKeto := new(devMockKetoService)
h := createHandler(mockKeto)
app := fiber.New()
tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-a",
Role: domain.RoleTenantAdmin,
TenantID: &tenantA,
ID: "super-user",
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/api/v1/dev/clients", h.ListClients)
// Explicit permission for private client check bypass
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "System", "global", "manage_all").Return(true, nil).Once()
mockKeto.On(
"ListRelations",
mock.Anything,
"RelyingParty",
mock.Anything,
mock.Anything,
mock.Anything,
).Return([]service.RelationTuple{}, nil).Maybe()
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1)
@@ -110,12 +99,51 @@ func TestDevHandler_Isolation(t *testing.T) {
}
json.NewDecoder(resp.Body).Decode(&res)
// Should only see client-tenant-a (tenant isolation)
// Should see both clients
assert.Equal(t, 2, len(res.Items))
})
t.Run("ListClients should filter by permit for non-SuperAdmin", func(t *testing.T) {
mockKeto := new(devMockKetoService)
h := createHandler(mockKeto)
app := fiber.New()
tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-a",
Role: domain.RoleUser,
TenantID: &tenantA,
})
return c.Next()
})
app.Get("/api/v1/dev/clients", h.ListClients)
// Explicit permission for private client check bypass
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "System", "global", "manage_all").Return(true, nil).Maybe()
// Mock permit for the specific client
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-a", "view").Return(true, nil).Maybe()
// Deny for other clients
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-b", "view").Return(false, nil).Maybe()
mockKeto.On("ListRelations", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]service.RelationTuple{}, nil).Maybe()
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res struct {
Items []clientSummary `json:"items"`
}
json.NewDecoder(resp.Body).Decode(&res)
// Should only see client-tenant-a (tenant permit)
assert.Equal(t, 1, len(res.Items))
assert.Equal(t, "client-tenant-a", res.Items[0].ID)
})
t.Run("Tenant member should see empty list from DevFront clients if no relation", func(t *testing.T) {
mockKeto := new(devMockKetoService)
h := createHandler(mockKeto)
app := fiber.New()
tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
@@ -127,6 +155,9 @@ func TestDevHandler_Isolation(t *testing.T) {
return c.Next()
})
app.Get("/api/v1/dev/clients", h.ListClients)
// Deny all by default
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1)
@@ -140,89 +171,44 @@ func TestDevHandler_Isolation(t *testing.T) {
assert.Equal(t, 0, len(res.Items))
})
t.Run("RP Admin should only see managed clients", func(t *testing.T) {
app := fiber.New()
tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "rp-admin-a",
Role: domain.RoleRPAdmin,
TenantID: &tenantA,
Metadata: map[string]any{
"managed_client_ids": []any{"client-tenant-a"},
},
})
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 res struct {
Items []clientSummary `json:"items"`
}
json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, 1, len(res.Items))
assert.Equal(t, "client-tenant-a", res.Items[0].ID)
})
t.Run("GetClient should enforce tenant isolation", func(t *testing.T) {
t.Run("GetClient should enforce isolation for non-SuperAdmin", func(t *testing.T) {
mockKeto := new(devMockKetoService)
h := createHandler(mockKeto)
app := fiber.New()
tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-a",
Role: domain.RoleTenantAdmin,
Role: domain.RoleUser,
TenantID: &tenantA,
})
return c.Next()
})
app.Get("/api/v1/dev/clients/:id", h.GetClient)
// Case 1: Same tenant
// Case 1: Same tenant BUT no permit (Normal users need permit now)
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-a", "view").Return(false, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-a", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// Case 2: Same tenant WITH permit
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-a", "view").Return(true, nil).Maybe()
mockKeto.On("ListRelations", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]service.RelationTuple{}, nil).Maybe()
req = httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-a", nil)
resp, _ = app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Case 2: Different tenant
// Case 3: Different tenant
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-b", "view").Return(false, nil).Maybe()
req = httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-b", nil)
resp, _ = app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("UpdateClient should require direct edit permission within tenant isolation", 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",
Role: domain.RoleTenantAdmin,
TenantID: &tenantA,
})
return c.Next()
})
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]any{
"client_name": "Updated Name",
})
// Case 1: Same tenant but no direct edit_config permission
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-a", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
// Case 2: Different tenant
req = httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-b", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ = app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("CreateClient should record user_id and tenant_id", func(t *testing.T) {
mockKeto := new(devMockKetoService)
h := createHandler(mockKeto)
app := fiber.New()
tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error {

View File

@@ -301,6 +301,7 @@ func TestListClients_Success(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil)
h := &DevHandler{
@@ -335,6 +336,8 @@ func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view").Return(false, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view").Return(true, nil)
mockKeto.On(
@@ -383,12 +386,15 @@ func TestCreateClient_ReservedSystemNameForbidden(t *testing.T) {
return nil, nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
Keto: mockKeto,
}
app := fiber.New()
@@ -432,12 +438,15 @@ func TestUpdateClient_ReservedSystemNameForbidden(t *testing.T) {
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
Keto: mockKeto,
}
app := fiber.New()
@@ -497,6 +506,8 @@ func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil)
h := &DevHandler{
@@ -560,6 +571,8 @@ func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil)
h := &DevHandler{
@@ -575,7 +588,7 @@ func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) {
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleRPAdmin,
Role: domain.RoleUser,
TenantID: &tenantID,
Metadata: map[string]any{
"managed_client_ids": []any{"client-1"},
@@ -805,6 +818,7 @@ func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil)
h := &DevHandler{
@@ -846,6 +860,7 @@ func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil)
h := &DevHandler{
@@ -887,12 +902,15 @@ func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) {
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
Keto: mockKeto,
}
app := fiber.New()
@@ -922,12 +940,15 @@ func TestUpdateClientStatus_Success(t *testing.T) {
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
Keto: mockKeto,
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
@@ -973,6 +994,7 @@ func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil)
@@ -1033,6 +1055,7 @@ func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(false, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil)
@@ -1216,6 +1239,7 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil)
h := &DevHandler{
@@ -1231,7 +1255,7 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "rp-1",
Role: domain.RoleRPAdmin,
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
@@ -1262,6 +1286,7 @@ func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
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)
@@ -1311,6 +1336,7 @@ func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
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)
@@ -1399,6 +1425,7 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
@@ -1419,7 +1446,7 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "rp-1",
Role: domain.RoleRPAdmin,
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
@@ -1452,6 +1479,7 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil)
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(nil)
@@ -1495,8 +1523,9 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testing.T) {
mockKeto := new(devMockKetoService)
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil).Once()
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(assert.AnError).Once()
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil).Maybe()
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(assert.AnError).Maybe()
mockOutbox := new(devMockKetoOutboxRepository)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
@@ -1505,7 +1534,7 @@ func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testin
entry.Relation == "admins" &&
entry.Subject == "User:user-1" &&
entry.Action == domain.KetoOutboxActionCreate
})).Return(nil).Once()
})).Return(nil).Maybe()
h := &DevHandler{
Keto: mockKeto,
@@ -1527,9 +1556,10 @@ func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testin
func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.T) {
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return([]service.RelationTuple{}, nil).Once()
mockKeto.On("CreateRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once()
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return([]service.RelationTuple{}, nil).Maybe()
mockKeto.On("CreateRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Maybe()
}
mockOutbox := new(devMockKetoOutboxRepository)
@@ -1541,7 +1571,7 @@ func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.
entry.Relation == expectedRelation &&
entry.Subject == "User:user-1" &&
entry.Action == domain.KetoOutboxActionCreate
})).Return(nil).Once()
})).Return(nil).Maybe()
}
h := &DevHandler{
@@ -1564,9 +1594,10 @@ func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.
func TestEnsureDeveloperGrantRelation_SkipsExistingTenantRelations(t *testing.T) {
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").
Return([]service.RelationTuple{{Namespace: "Tenant", Object: "tenant-a", Relation: relation, SubjectID: "User:user-1"}}, nil).Once()
Return([]service.RelationTuple{{Namespace: "Tenant", Object: "tenant-a", Relation: relation, SubjectID: "User:user-1"}}, nil).Maybe()
}
h := &DevHandler{
@@ -1588,8 +1619,9 @@ func TestEnsureDeveloperGrantRelation_SkipsExistingTenantRelations(t *testing.T)
func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.T) {
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
mockKeto.On("DeleteRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once()
mockKeto.On("DeleteRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Maybe()
}
mockOutbox := new(devMockKetoOutboxRepository)
@@ -1601,7 +1633,7 @@ func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.
entry.Relation == expectedRelation &&
entry.Subject == "User:user-1" &&
entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
})).Return(nil).Maybe()
}
h := &DevHandler{
@@ -1641,6 +1673,7 @@ func TestGetStats_Success(t *testing.T) {
}
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On(
"CheckPermission",
mock.Anything,
@@ -1670,7 +1703,7 @@ func TestGetStats_Success(t *testing.T) {
tenantID := "t1"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "u1", Role: domain.RoleTenantAdmin, TenantID: &tenantID,
ID: "u1", Role: domain.RoleSuperAdmin, TenantID: &tenantID,
})
return c.Next()
})
@@ -1683,10 +1716,9 @@ func TestGetStats_Success(t *testing.T) {
var res devStatsResponse
json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, int64(2), res.TotalClients)
assert.Equal(t, int64(3), res.TotalClients)
assert.Equal(t, int64(7), res.AuthFailures)
assert.Equal(t, int64(3), res.ActiveSessions)
mockKeto.AssertNotCalled(t, "CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all")
}
func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) {
@@ -1737,6 +1769,7 @@ func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) {
}
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil)
mockKeto.On(
@@ -1794,6 +1827,7 @@ func TestGetRPUsageDaily_UserScopesItemsToVisibleClients(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil)
mockKeto.On(
@@ -2943,7 +2977,7 @@ func TestListAuditLogs_RPAdminScope(t *testing.T) {
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "u-rp-admin",
Role: domain.RoleRPAdmin,
Role: domain.RoleUser,
TenantID: &tenantID,
Metadata: map[string]any{
"managed_client_ids": []any{"client-allowed"},
@@ -3000,6 +3034,7 @@ func TestListAuditLogs_UserAllowedByRPAuditPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
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)
@@ -3053,6 +3088,7 @@ func TestListConsents_UserAllowedByRPAdminsRelation(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_consents").Return(true, nil)
h := &DevHandler{
@@ -3114,6 +3150,7 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
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"},
@@ -3145,7 +3182,7 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test
tenantID := "tenant-1"
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleRPAdmin,
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
@@ -3183,6 +3220,7 @@ func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil)
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "").Return([]service.RelationTuple{
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:user-1"},
@@ -3199,7 +3237,7 @@ func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) {
"name": "Tester",
"email": "tester@example.com",
},
}, nil).Once()
}, nil).Maybe()
h := &DevHandler{
Hydra: &service.HydraAdminService{
@@ -3215,7 +3253,7 @@ func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) {
tenantID := "tenant-1"
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleRPAdmin,
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
@@ -3252,6 +3290,7 @@ func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
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)
@@ -3279,7 +3318,7 @@ func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
tenantID := "tenant-1"
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleRPAdmin,
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
@@ -3314,6 +3353,7 @@ func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil)
mockOutbox := new(devMockKetoOutboxRepository)
@@ -3339,7 +3379,7 @@ func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) {
tenantID := "tenant-1"
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleRPAdmin,
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
@@ -3389,12 +3429,17 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
},
}, nil)
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-9", "RelyingParty", "client-1", "manage").Return(true, nil).Maybe()
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
KratosAdmin: mockKratos,
Keto: mockKeto,
}
app := fiber.New()
@@ -3402,7 +3447,7 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
tenantID := "tenant-1"
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-9",
Role: domain.RoleRPAdmin,
Role: domain.RoleUser,
TenantID: &tenantID,
ManageableTenants: []domain.Tenant{
{ID: "tenant-1", Slug: "tenant-one"},
@@ -3445,6 +3490,7 @@ func TestSearchUsers_UserAllowedByRPAdminRelation(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil)
mockKratos := new(devMockKratosAdmin)

View File

@@ -48,7 +48,7 @@ func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error {
if role == domain.RoleSuperAdmin {
rps, err = h.Service.ListAll(c.Context())
} else if role == domain.RoleTenantAdmin && profile.TenantID != nil {
} else if role == "tenant_admin" && profile.TenantID != nil {
rps, err = h.Service.List(c.Context(), *profile.TenantID)
} else {
slog.Warn("Forbidden access to all applications", "userID", profile.ID, "role", role)

View File

@@ -456,7 +456,7 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
Role: "tenant_admin",
TenantID: parent("company"),
})
return c.Next()
@@ -502,7 +502,7 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
Role: "tenant_admin",
TenantID: parent("company"),
ManageableTenants: []domain.Tenant{
{ID: "private-team", Slug: "private-team"},
@@ -545,7 +545,7 @@ func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *te
filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
Role: "tenant_admin",
TenantID: parent("company"),
})
@@ -1139,7 +1139,7 @@ func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
Role: "tenant_admin",
TenantID: parent("company"),
})
return c.Next()

View File

@@ -401,7 +401,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// [New] Manageable Tenants Map for efficient lookup
manageableSlugs := make(map[string]bool)
if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
if requesterRole != domain.RoleSuperAdmin {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile != nil {
var baseTenantIDs []string
@@ -485,7 +485,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
// Tenant Admin & Member filtering
if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
if requesterRole != domain.RoleSuperAdmin {
hasAccess := manageableSlugs[tID]
if !hasAccess {
continue
@@ -608,7 +608,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
// [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester != nil && requester.Role == domain.RoleTenantAdmin {
if requester != nil && requester.Role != domain.RoleSuperAdmin {
allowedKeys := profileTenantAccessKeys(requester)
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowedKeys) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
@@ -1106,7 +1106,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
// Role-based access check
if requester != nil && requester.Role == domain.RoleTenantAdmin {
if requester != nil && requester.Role != domain.RoleSuperAdmin {
if !profileCanAccessTenant(requester, tItem.ID, tenantSlug) {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
continue
@@ -1131,7 +1131,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
break
}
}
if requester != nil && requester.Role == domain.RoleTenantAdmin && !profileCanAccessTenant(requester, appointmentTenant.ID, appointmentTenant.Slug) {
if requester != nil && requester.Role == "tenant_admin" && !profileCanAccessTenant(requester, appointmentTenant.ID, appointmentTenant.Slug) {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
appointmentFailed = true
break
@@ -1495,7 +1495,6 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
}
var requesterRole string
var manageableSlugs []string
var profile *domain.UserProfileResponse
// [New] Manual profile resolution to support query-param role mocking
@@ -1508,7 +1507,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
slog.Info("🔑 [AUTH] Using mock role from query for export", "role", mockRole)
requesterRole = mockRole
// In dev mocking, we might not have a full profile, but we need to know the manageable tenants if it's a tenant_admin
if requesterRole == domain.RoleTenantAdmin {
if requesterRole == "tenant_admin" {
// Try to get actual profile if possible to get manageableTenants
p, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if p != nil {
@@ -1525,42 +1524,19 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
requesterRole = profile.Role
}
// [New] Access Control: only admin roles can export
if requesterRole != domain.RoleSuperAdmin && requesterRole != domain.RoleTenantAdmin {
// [New] Access Control: only super_admin can export
if requesterRole != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for export")
}
if profile != nil && requesterRole == domain.RoleTenantAdmin {
for _, t := range profile.ManageableTenants {
manageableSlugs = append(manageableSlugs, strings.ToLower(t.Slug))
manageableSlugs = append(manageableSlugs, strings.ToLower(t.ID))
}
if profile.TenantID != nil {
manageableSlugs = append(manageableSlugs, strings.ToLower(*profile.TenantID))
}
}
// 1. Fetch Users using Repo for efficiency
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
}
// 2. Filter by manageable tenants if tenant_admin
var filtered []domain.User
if requesterRole == domain.RoleTenantAdmin {
slugMap := make(map[string]bool)
for _, s := range manageableSlugs {
slugMap[s] = true
}
for _, u := range users {
if slugMap[strings.ToLower(userTenantSlug(u))] || slugMap[strings.ToLower(userTenantID(u))] {
filtered = append(filtered, u)
}
}
} else {
filtered = users
}
// 2. Data rows
filtered := users
// 3. Set CSV Headers
c.Set("Content-Type", "text/csv; charset=utf-8")
@@ -1700,7 +1676,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
tenantCache := make(map[string]tenantCacheItem)
manageableSlugs := make(map[string]bool)
if requester.Role == domain.RoleTenantAdmin {
if requester.Role != domain.RoleSuperAdmin {
manageableSlugs = profileTenantAccessKeys(requester)
}
@@ -1724,7 +1700,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
}
// Authorization check
if requester.Role == domain.RoleTenantAdmin {
if requester.Role != domain.RoleSuperAdmin {
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
continue
@@ -1858,7 +1834,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
}
manageableSlugs := make(map[string]bool)
if requester.Role == domain.RoleTenantAdmin {
if requester.Role != domain.RoleSuperAdmin {
manageableSlugs = profileTenantAccessKeys(requester)
}
@@ -1882,7 +1858,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
}
// Authorization check
if requester.Role == domain.RoleTenantAdmin {
if requester.Role != domain.RoleSuperAdmin {
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"})
continue
@@ -1951,7 +1927,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
allowed := map[string]bool{}
if requester.TenantID != nil {
allowed[strings.ToLower(*requester.TenantID)] = true
@@ -2006,8 +1982,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
*req.Role = role
}
// Tenant admins can only move users within tenants they can manage.
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
// All non-superadmins can only move users within tenants they can manage.
if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil {
targetSlug := strings.TrimSpace(*req.CompanyCode)
targetAllowed := profileCanAccessTenant(requester, "", targetSlug)
@@ -2017,13 +1993,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}
if !targetAllowed {
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant")
return errorJSON(c, fiber.StatusForbidden, "forbidden: non-superadmins cannot change user's tenant to an unmanageable one")
}
}
}
// [Validation] Based on Tenant Schema (Multi-tenant aware)
isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
isAdmin := requester != nil && requester.Role == domain.RoleSuperAdmin
// If metadata is namespaced (key is tenant ID), validate each namespace
// If it's flat, validate using schemaCompCode
@@ -2360,13 +2336,13 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
}
var identity *service.KratosIdentity
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin || h.Worksmobile != nil {
if (requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin) || h.Worksmobile != nil {
found, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil {
identity = found
}
}
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
if identity != nil {
allowed := map[string]bool{}
if requester.TenantID != nil {
@@ -2763,9 +2739,9 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
return // Nothing changed
}
// 1. Handle Role Changes
// 1. Handle Role Changes (Super Admin Only)
if oldRole == domain.RoleSuperAdmin {
// Only remove super_admin if the role actually changed (tenant change doesn't matter for global roles)
// Only remove super_admin if the role actually changed
if oldRole != newRole {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
@@ -2775,14 +2751,6 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
Action: domain.KetoOutboxActionDelete,
})
}
} else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: oldTenantID,
Relation: "admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
// Add new roles
@@ -2796,14 +2764,6 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
Action: domain.KetoOutboxActionCreate,
})
}
} else if newRole == domain.RoleTenantAdmin && newTID != "" {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: newTID,
Relation: "admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}
// 2. Handle Tenant Membership (for count)

File diff suppressed because it is too large Load Diff

View File

@@ -160,38 +160,7 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
return c.Next()
}
// Tenant Admin check
if userRole == domain.RoleTenantAdmin {
targetTenantID := c.Params("tenantId")
if targetTenantID == "" {
targetTenantID = c.Params("id") // common for /tenants/:id
}
if targetTenantID == "" {
return c.Next() // No target specified, let Keto or next handler decide
}
// Check primary tenant match
if profile.TenantID != nil && *profile.TenantID == targetTenantID {
return c.Next()
}
// Check inherited manageable tenants
isAllowed := false
for _, t := range profile.ManageableTenants {
if t.ID == targetTenantID {
isAllowed = true
break
}
}
if !isAllowed {
slog.Warn("Tenant match failed", "userID", profile.ID, "targetTenantID", targetTenantID)
return errorJSON(c, fiber.StatusForbidden, "forbidden: you do not have access to this tenant")
}
return c.Next()
}
// Since only Super Admin is maintained for tenant management, others are rejected here
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
}

View File

@@ -64,21 +64,17 @@ func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation,
return args.Get(0).([]string), args.Error(1)
}
// Fixed MockKetoService to match service.KetoService exactly if possible.
// Wait, middleware/rbac.go imports baron-sso-backend/internal/service.
// So I should use service.RelationTuple.
func TestRequireRole_Success(t *testing.T) {
app := fiber.New()
mockAuth := new(MockAuthProvider)
config := RBACConfig{
AllowedRoles: []string{"admin"},
AllowedRoles: []string{domain.RoleSuperAdmin},
AuthHandler: mockAuth,
}
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
ID: "user1",
Role: "admin",
Role: domain.RoleSuperAdmin,
}, nil)
app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error {
@@ -95,13 +91,13 @@ func TestRequireRole_SetsUserIDForAuditContext(t *testing.T) {
app := fiber.New()
mockAuth := new(MockAuthProvider)
config := RBACConfig{
AllowedRoles: []string{"admin"},
AllowedRoles: []string{domain.RoleSuperAdmin},
AuthHandler: mockAuth,
}
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
ID: "user1",
Role: "admin",
Role: domain.RoleSuperAdmin,
}, nil)
app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error {
@@ -124,13 +120,13 @@ func TestRequireRole_PreservesExistingUserID(t *testing.T) {
app := fiber.New()
mockAuth := new(MockAuthProvider)
config := RBACConfig{
AllowedRoles: []string{"admin"},
AllowedRoles: []string{domain.RoleSuperAdmin},
AuthHandler: mockAuth,
}
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
ID: "profile-user",
Role: "admin",
Role: domain.RoleSuperAdmin,
}, nil)
app.Use(func(c *fiber.Ctx) error {
@@ -157,7 +153,7 @@ func TestRequireRole_Forbidden(t *testing.T) {
app := fiber.New()
mockAuth := new(MockAuthProvider)
config := RBACConfig{
AllowedRoles: []string{"admin"},
AllowedRoles: []string{domain.RoleSuperAdmin},
AuthHandler: mockAuth,
}
@@ -231,7 +227,7 @@ func TestRequireTenantMatch_Forbidden(t *testing.T) {
tenant1 := "tenant1"
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
ID: "user1",
Role: domain.RoleTenantAdmin,
Role: "user", // Formerly tenant_admin, now mapped to user which is forbidden here for non-superadmin
TenantID: &tenant1,
}, nil)

View File

@@ -17,31 +17,21 @@ export function ForbiddenMessage({ resourceToken }: Props) {
"You do not have permission to access this resource. Contact your administrator.",
);
if (role === "rp_admin") {
explanation = t(
"msg.dev.forbidden.rp_admin",
"RP administrators can only access resources for their assigned applications.",
);
} else if (role === "tenant_admin") {
explanation = t(
"msg.dev.forbidden.tenant_admin",
"Your tenant administrator permission is missing, misconfigured, or expired.",
);
} else if (role === "user" || role === "tenant_member") {
if (role === "user") {
if (resourceToken === "consents") {
explanation = t(
"msg.dev.forbidden.user.consents",
"Viewing consent records for this application requires an RP administrator, consent read, or consent revoke relationship. Request access from an administrator if needed.",
"Viewing consent records for this application requires an operational relationship. Request access from an administrator if needed.",
);
} else if (resourceToken === "audit") {
explanation = t(
"msg.dev.forbidden.user.audit",
"Viewing audit logs for this application requires an RP administrator or audit read relationship. Request access from an administrator if needed.",
"Viewing audit logs for this application requires an audit read relationship. Request access from an administrator if needed.",
);
} else {
explanation = t(
"msg.dev.forbidden.user.clients",
"Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target RP. Request access from an administrator if needed.",
"Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target application. Request access from an administrator if needed.",
);
}
}

View File

@@ -12,11 +12,7 @@ export type DeveloperAccessGateState = {
};
function isPrivilegedDeveloperRole(profileRole: string) {
return (
profileRole === "super_admin" ||
profileRole === "tenant_admin" ||
profileRole === "rp_admin"
);
return profileRole === "super_admin";
}
export function resolveDeveloperAccessGate(

View File

@@ -1,12 +1,15 @@
export function normalizeRole(rawRole: unknown): string {
if (typeof rawRole !== "string") return "";
const role = rawRole.trim().toLowerCase();
if (role === "tenant_member") return "user";
if (role === "admin") return "tenant_admin";
if (role === "superadmin") return "super_admin";
if (role === "tenantadmin") return "tenant_admin";
if (role === "rpadmin") return "rp_admin";
return role;
switch (role) {
case "super_admin":
case "superadmin":
case "super-admin":
return "super_admin";
default:
return "user";
}
}
export function resolveProfileRole(