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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -759,7 +759,6 @@ title = "Title"
|
||||
|
||||
[ui.admin]
|
||||
brand = "Brand"
|
||||
dev_role_switcher = "🛠 DEV Role Switcher"
|
||||
title = "Admin Control"
|
||||
|
||||
[ui.admin.api_keys]
|
||||
|
||||
@@ -764,7 +764,6 @@ title = "회원가입 완료"
|
||||
|
||||
[ui.admin]
|
||||
brand = "Baron 로그인"
|
||||
dev_role_switcher = "🛠 DEV Role Switcher"
|
||||
title = "Admin Control"
|
||||
|
||||
[ui.admin.api_keys]
|
||||
|
||||
@@ -772,8 +772,6 @@ title = ""
|
||||
|
||||
[ui.admin]
|
||||
brand = ""
|
||||
dev_role_switcher = ""
|
||||
dev_role_switcher_real = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.api_keys]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user