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, shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding"; } from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector"; import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
const LOCALE_CHANGED_EVENT = "baron_locale_changed"; const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
const staticNavItems: ShellSidebarNavItem[] = [ const staticNavItems: ShellSidebarNavItem[] = [
{ {
@@ -132,19 +130,8 @@ function AppLayout() {
const lastRenewAttemptAtRef = useRef(0); const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null); const lastVisitedRouteRef = useRef<string | null>(null);
const isDevelopmentRuntime = import.meta.env.MODE === "development"; 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 [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileOpen, setIsProfileOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false);
const [, setDevelopmentRenderRevision] = useState(0);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime), readShellSessionExpiryEnabled(!isDevelopmentRuntime),
); );
@@ -173,10 +160,9 @@ function AppLayout() {
const isTest = const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true; ._IS_TEST_MODE === true;
const effectiveRole = mockRoleOverride || profile?.role; const effectiveRole = profile?.role;
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole); const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
const isTenantAdmin = effectiveRole === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0; const manageableCount = profile?.manageableTenants?.length ?? 0;
const orgfrontUrl = buildAuthenticatedOrgChartUrl( const orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175", import.meta.env.ORGFRONT_URL || "http://localhost:5175",
@@ -215,7 +201,8 @@ function AppLayout() {
to: "/system/data-integrity", to: "/system/data-integrity",
icon: ShieldCheck, icon: ShieldCheck,
}); });
} else if (isTenantAdmin || manageableCount > 0) { } else {
// Non-superadmins
if (manageableCount <= 1 && profile?.tenantId) { if (manageableCount <= 1 && profile?.tenantId) {
filteredItems.splice(1, 0, { filteredItems.splice(1, 0, {
labelKey: "ui.admin.nav.my_tenant", labelKey: "ui.admin.nav.my_tenant",
@@ -231,20 +218,7 @@ function AppLayout() {
icon: Building2, icon: Building2,
}); });
} }
filteredItems.splice( filteredItems.splice(filteredItems.findIndex(i => i.to === "/users") + 1, 0, {
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, {
labelKey: "ui.admin.nav.org_chart", labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart", labelFallback: "Org Chart",
to: orgfrontUrl, to: orgfrontUrl,
@@ -254,7 +228,7 @@ function AppLayout() {
} }
return filteredItems; return filteredItems;
}, [mockRoleOverride, profile]); }, [profile]);
const handleLogout = () => { const handleLogout = () => {
if ( if (
@@ -299,21 +273,16 @@ function AppLayout() {
} }
const rerenderDevelopmentShell = () => { const rerenderDevelopmentShell = () => {
setDevelopmentRenderRevision((value) => value + 1); // Re-render when locale changes
}; };
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell); window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
window.addEventListener(DEV_ROLE_CHANGED_EVENT, rerenderDevelopmentShell);
return () => { return () => {
window.removeEventListener( window.removeEventListener(
LOCALE_CHANGED_EVENT, LOCALE_CHANGED_EVENT,
rerenderDevelopmentShell, rerenderDevelopmentShell,
); );
window.removeEventListener(
DEV_ROLE_CHANGED_EVENT,
rerenderDevelopmentShell,
);
}; };
}, []); }, []);
@@ -494,7 +463,7 @@ function AppLayout() {
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"), fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"), fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
}); });
const profileRoleKey = mockRoleOverride || profile?.role || "user"; const profileRoleKey = profile?.role || "user";
const handleSessionExpiryToggle = () => { const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => { setIsSessionExpiryEnabled((prev) => {
const next = !prev; const next = !prev;
@@ -781,7 +750,6 @@ function AppLayout() {
<main className={shellLayoutClasses.mainMinWidth}> <main className={shellLayoutClasses.mainMinWidth}>
<Outlet /> <Outlet />
</main> </main>
<RoleSwitcher />
</div> </div>
</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 profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema = const canAccessSchema = profileRole === "super_admin";
profileRole === "super_admin" || profileRole === "tenant_admin"; Broadway
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data); const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
const isPermissionsTab = location.pathname.includes("/permissions"); const isPermissionsTab = location.pathname.includes("/permissions");

View File

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

View File

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

View File

@@ -819,7 +819,7 @@ function UserCreatePage() {
id="tenantSlug" 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" 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")} {...register("tenantSlug")}
disabled={profile?.role === "tenant_admin"} disabled={profileRole !== "super_admin"}
> >
{nonHanmacFamilyTenants.map((tenant) => ( {nonHanmacFamilyTenants.map((tenant) => (
<option key={tenant.id} value={tenant.slug}> <option key={tenant.id} value={tenant.slug}>

View File

@@ -468,8 +468,7 @@ function UserDetailPage() {
}); });
const profileRole = normalizeAdminRole(profile?.role); const profileRole = normalizeAdminRole(profile?.role);
const isAdmin = const isAdmin = profileRole === "super_admin";
profileRole === "super_admin" || profileRole === "tenant_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id); const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const watchedStatus = watch("status"); 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" 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} value={selectedCompany}
onChange={(event) => onCompanyChange(event.target.value)} onChange={(event) => onCompanyChange(event.target.value)}
disabled={profileRole === "tenant_admin"} disabled={profileRole !== "super_admin"}
> >
<option value="">{t("ui.common.all", "전체 테넌트")}</option> <option value="">{t("ui.common.all", "전체 테넌트")}</option>
{tenantOptions} {tenantOptions}

View File

@@ -28,14 +28,6 @@ apiClient.interceptors.request.use(async (config) => {
config.headers["X-Tenant-ID"] = tenantId; 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; return config;
}); });

View File

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

View File

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

View File

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

View File

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

View File

@@ -76,17 +76,10 @@ func SyncKetoRelations(db *gorm.DB, outbox repository.KetoOutboxRepository) erro
Subject: "User:" + u.ID, Subject: "User:" + u.ID,
Action: domain.KetoOutboxActionCreate, 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.") slog.Info("✅ Keto ReBAC synchronization items added to Outbox.")
return nil return nil
} }

View File

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

View File

@@ -9,14 +9,14 @@ func TestNormalizeRole(t *testing.T) {
want string want string
}{ }{
{name: "super admin unchanged", in: "super_admin", want: RoleSuperAdmin}, {name: "super admin unchanged", in: "super_admin", want: RoleSuperAdmin},
{name: "tenant admin unchanged", in: "tenant_admin", want: RoleTenantAdmin}, {name: "tenant admin mapped to user", in: "tenant_admin", want: RoleUser},
{name: "rp admin unchanged", in: "rp_admin", want: RoleRPAdmin}, {name: "rp admin mapped to user", in: "rp_admin", want: RoleUser},
{name: "user unchanged", in: "user", want: RoleUser}, {name: "user unchanged", in: "user", want: RoleUser},
{name: "super admin hyphen alias", in: "super-admin", want: RoleSuperAdmin}, {name: "super admin hyphen alias", in: "super-admin", want: RoleSuperAdmin},
{name: "super admin compact alias", in: "superadmin", 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: "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: "unknown role mapped to user", in: "custom_role", want: RoleUser},
{name: "empty string mapped to user", in: " ", 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 := fiber.New()
app.Use(func(c *fiber.Ctx) error { 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() return c.Next()
}) })
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus) 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 { app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1", ID: "user-1",
Role: domain.RoleTenantAdmin, Role: "tenant_admin",
}) })
return c.Next() return c.Next()
}) })

View File

@@ -46,7 +46,7 @@ func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
h := &AdminHandler{IntegrityChecker: checker} h := &AdminHandler{IntegrityChecker: checker}
app := fiber.New() app := fiber.New()
app.Use(func(c *fiber.Ctx) error { 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() return c.Next()
}) })
app.Get("/api/v1/admin/integrity", h.GetDataIntegrity) app.Get("/api/v1/admin/integrity", h.GetDataIntegrity)
@@ -182,7 +182,7 @@ func TestAdminHandler_DeleteOrphanUserLoginIDsRejectsTenantAdmin(t *testing.T) {
h := &AdminHandler{IntegrityChecker: checker} h := &AdminHandler{IntegrityChecker: checker}
app := fiber.New() app := fiber.New()
app.Use(func(c *fiber.Ctx) error { 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() return c.Next()
}) })
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs) 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 { if profile.Role == domain.RoleSuperAdmin {
// Super Admin can see everything or filter by a specific tenant if requested // Super Admin can see everything or filter by a specific tenant if requested
filterTenantID = requestedTenantID 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 { } else {
return errorJSON(c, fiber.StatusForbidden, "forbidden") 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 h.TenantService != nil {
if profile.Role == domain.RoleTenantAdmin { if profile.Role == "tenant_admin" {
manageable, err := h.TenantService.ListManageableTenants(ctx, profile.ID) manageable, err := h.TenantService.ListManageableTenants(ctx, profile.ID)
if err == nil { if err == nil {
profile.ManageableTenants = manageable profile.ManageableTenants = manageable

View File

@@ -252,21 +252,13 @@ func normalizeUserRole(role string) string {
} }
func isDevConsoleRoleAllowed(role string) bool { func isDevConsoleRoleAllowed(role string) bool {
switch normalizeUserRole(role) { r := normalizeUserRole(role)
case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser: return r == domain.RoleSuperAdmin || r == domain.RoleUser
return true
default:
return false
}
} }
func isDevConsoleViewerRole(role string) bool { func isDevConsoleViewerRole(role string) bool {
switch normalizeUserRole(role) { r := normalizeUserRole(role)
case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser: return r == domain.RoleSuperAdmin || r == domain.RoleUser
return true
default:
return false
}
} }
func setCurrentProfileContext(c *fiber.Ctx, profile *domain.UserProfileResponse) { 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 { func shouldScopeDashboardToExplicitClients(role string) bool {
switch normalizeUserRole(role) { switch normalizeUserRole(role) {
case domain.RoleRPAdmin, domain.RoleUser: case "rp_admin", domain.RoleUser:
return true return true
default: default:
return false return false
@@ -562,20 +554,7 @@ func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary c
} }
role := normalizeUserRole(profile.Role) role := normalizeUserRole(profile.Role)
if role == domain.RoleSuperAdmin { return 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)
} }
func resolveClientTenantID(summary clientSummary) string { func resolveClientTenantID(summary clientSummary) string {
@@ -587,19 +566,10 @@ func resolveClientTenantID(summary clientSummary) string {
} }
func isRPAdminClientAllowed(profile *domain.UserProfileResponse, clientID string) bool { 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)) role := normalizeUserRole(profileRole(profile))
if role == domain.RoleUser { return role == domain.RoleSuperAdmin || 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
} }
func manageableTenantKeysFromProfile(profile *domain.UserProfileResponse) map[string]struct{} { 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 { if ok && profile != nil {
setCurrentProfileContext(c, profile) setCurrentProfileContext(c, profile)
role := normalizeUserRole(profile.Role) role := normalizeUserRole(profile.Role)
switch role { if role == domain.RoleSuperAdmin {
case domain.RoleSuperAdmin:
slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID) slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID)
return true, nil 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) { if isAdminEmail(profile.Email) {
slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email) slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email)
return true, nil 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) slog.Info("Dev private permission granted by token role", "role", tokenRole)
return true, nil 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) { if isAdminEmail(tokenEmail) {
slog.Info("Dev private permission granted by token email", "email", tokenEmail) slog.Info("Dev private permission granted by token email", "email", tokenEmail)
return true, nil return true, nil
@@ -1067,7 +1024,6 @@ func (h *DevHandler) listVisibleClientSummaries(
userTenantID := tenantIDFromProfile(profile) userTenantID := tenantIDFromProfile(profile)
isSuperAdmin := role == domain.RoleSuperAdmin isSuperAdmin := role == domain.RoleSuperAdmin
allowedClientIDs := managedClientIDsFromProfile(profile)
isAppManager, err := h.checkAppManagerPermission(c) isAppManager, err := h.checkAppManagerPermission(c)
if err != nil { 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 { if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
continue continue
} }
@@ -1737,7 +1687,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if tenantID == "" && profile.TenantID != nil { if tenantID == "" && profile.TenantID != nil {
tenantID = *profile.TenantID 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") 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"))) statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
allowedClientIDs := managedClientIDsFromProfile(profile) allowedClientIDs := managedClientIDsFromProfile(profile)
allowedClientIDs = mergeStringSets(allowedClientIDs, h.auditClientIDsByPermit(c, profile, clientFilter)) 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{ return c.JSON(devAuditListResponse{
Items: []domain.AuditLog{}, Items: []domain.AuditLog{},
Limit: limit, Limit: limit,

View File

@@ -16,57 +16,57 @@ import (
) )
func TestDevHandler_Isolation(t *testing.T) { func TestDevHandler_Isolation(t *testing.T) {
mockKeto := new(devMockKetoService) createHandler := func(mockKeto *devMockKetoService) *DevHandler {
// Default Mock behavior: deny everything unless explicitly allowed return &DevHandler{
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe() Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
h := &DevHandler{ HTTPClient: &http.Client{
Hydra: &service.HydraAdminService{ Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
AdminURL: "http://hydra.test", if r.Method == http.MethodGet && r.URL.Path == "/clients" {
HTTPClient: &http.Client{ return httpJSONAny(r, http.StatusOK, []map[string]any{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { {
if r.Method == http.MethodGet && r.URL.Path == "/clients" { "client_id": "client-tenant-a",
return httpJSONAny(r, http.StatusOK, []map[string]any{ "client_name": "App Tenant A",
{ "token_endpoint_auth_method": "none", // PKCE
"client_id": "client-tenant-a", "metadata": map[string]any{"tenant_id": "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
"client_id": "client-tenant-b", "metadata": map[string]any{"tenant_id": "tenant-b"},
"client_name": "App Tenant B", },
"token_endpoint_auth_method": "none", // PKCE }), nil
"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"
} }
return httpJSONAny(r, http.StatusOK, map[string]any{ if (r.Method == http.MethodGet || r.Method == http.MethodPut) && strings.HasPrefix(r.URL.Path, "/clients/") {
"client_id": id, id := strings.TrimPrefix(r.URL.Path, "/clients/")
"client_name": "App " + id, tenantID := "tenant-a"
"token_endpoint_auth_method": "none", if id == "client-tenant-b" {
"metadata": map[string]any{"tenant_id": tenantID}, tenantID = "tenant-b"
}), nil }
} return httpJSONAny(r, http.StatusOK, map[string]any{
if r.Method == http.MethodPost && r.URL.Path == "/clients" { "client_id": id,
var body map[string]any "client_name": "App " + id,
json.NewDecoder(r.Body).Decode(&body) "token_endpoint_auth_method": "none",
return httpJSONAny(r, http.StatusCreated, body), nil "metadata": map[string]any{"tenant_id": tenantID},
} }), nil
return httpJSONAny(r, http.StatusNotFound, nil), 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) { t.Run("Local bypass should be removed", func(t *testing.T) {
mockKeto := new(devMockKetoService)
h := createHandler(mockKeto)
app := fiber.New() app := fiber.New()
app.Get("/api/v1/dev/clients", h.ListClients) 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) 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() app := fiber.New()
tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error { app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-a", ID: "super-user",
Role: domain.RoleTenantAdmin, Role: domain.RoleSuperAdmin,
TenantID: &tenantA,
}) })
return c.Next() return c.Next()
}) })
app.Get("/api/v1/dev/clients", h.ListClients) 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) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
@@ -110,12 +99,51 @@ func TestDevHandler_Isolation(t *testing.T) {
} }
json.NewDecoder(resp.Body).Decode(&res) 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, 1, len(res.Items))
assert.Equal(t, "client-tenant-a", res.Items[0].ID) 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) { 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() app := fiber.New()
tenantA := "tenant-a" tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error { app.Use(func(c *fiber.Ctx) error {
@@ -127,6 +155,9 @@ func TestDevHandler_Isolation(t *testing.T) {
return c.Next() return c.Next()
}) })
app.Get("/api/v1/dev/clients", h.ListClients) 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) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
@@ -140,89 +171,44 @@ func TestDevHandler_Isolation(t *testing.T) {
assert.Equal(t, 0, len(res.Items)) assert.Equal(t, 0, len(res.Items))
}) })
t.Run("RP Admin should only see managed clients", func(t *testing.T) { t.Run("GetClient should enforce isolation for non-SuperAdmin", func(t *testing.T) {
app := fiber.New() mockKeto := new(devMockKetoService)
tenantA := "tenant-a" h := createHandler(mockKeto)
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) {
app := fiber.New() app := fiber.New()
tenantA := "tenant-a" tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error { app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-a", ID: "user-a",
Role: domain.RoleTenantAdmin, Role: domain.RoleUser,
TenantID: &tenantA, TenantID: &tenantA,
}) })
return c.Next() return c.Next()
}) })
app.Get("/api/v1/dev/clients/:id", h.GetClient) 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) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-a", nil)
resp, _ := app.Test(req, -1) 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) 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) req = httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-b", nil)
resp, _ = app.Test(req, -1) resp, _ = app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode) 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) { t.Run("CreateClient should record user_id and tenant_id", func(t *testing.T) {
mockKeto := new(devMockKetoService)
h := createHandler(mockKeto)
app := fiber.New() app := fiber.New()
tenantA := "tenant-a" tenantA := "tenant-a"
app.Use(func(c *fiber.Ctx) error { app.Use(func(c *fiber.Ctx) error {

View File

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

View File

@@ -48,7 +48,7 @@ func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error {
if role == domain.RoleSuperAdmin { if role == domain.RoleSuperAdmin {
rps, err = h.Service.ListAll(c.Context()) 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) rps, err = h.Service.List(c.Context(), *profile.TenantID)
} else { } else {
slog.Warn("Forbidden access to all applications", "userID", profile.ID, "role", role) 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 { app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1", ID: "user-1",
Role: domain.RoleTenantAdmin, Role: "tenant_admin",
TenantID: parent("company"), TenantID: parent("company"),
}) })
return c.Next() return c.Next()
@@ -502,7 +502,7 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
app.Use(func(c *fiber.Ctx) error { app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1", ID: "user-1",
Role: domain.RoleTenantAdmin, Role: "tenant_admin",
TenantID: parent("company"), TenantID: parent("company"),
ManageableTenants: []domain.Tenant{ ManageableTenants: []domain.Tenant{
{ID: "private-team", Slug: "private-team"}, {ID: "private-team", Slug: "private-team"},
@@ -545,7 +545,7 @@ func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *te
filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{ filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{
ID: "user-1", ID: "user-1",
Role: domain.RoleTenantAdmin, Role: "tenant_admin",
TenantID: parent("company"), TenantID: parent("company"),
}) })
@@ -1139,7 +1139,7 @@ func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t
app.Use(func(c *fiber.Ctx) error { app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1", ID: "user-1",
Role: domain.RoleTenantAdmin, Role: "tenant_admin",
TenantID: parent("company"), TenantID: parent("company"),
}) })
return c.Next() return c.Next()

View File

@@ -401,7 +401,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// [New] Manageable Tenants Map for efficient lookup // [New] Manageable Tenants Map for efficient lookup
manageableSlugs := make(map[string]bool) 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) profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile != nil { if profile != nil {
var baseTenantIDs []string var baseTenantIDs []string
@@ -485,7 +485,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id")) tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
// Tenant Admin & Member filtering // Tenant Admin & Member filtering
if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin { if requesterRole != domain.RoleSuperAdmin {
hasAccess := manageableSlugs[tID] hasAccess := manageableSlugs[tID]
if !hasAccess { if !hasAccess {
continue continue
@@ -608,7 +608,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
// [New] Check access scope // [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester != nil && requester.Role == domain.RoleTenantAdmin { if requester != nil && requester.Role != domain.RoleSuperAdmin {
allowedKeys := profileTenantAccessKeys(requester) allowedKeys := profileTenantAccessKeys(requester)
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowedKeys) { if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowedKeys) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied") 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 // Role-based access check
if requester != nil && requester.Role == domain.RoleTenantAdmin { if requester != nil && requester.Role != domain.RoleSuperAdmin {
if !profileCanAccessTenant(requester, tItem.ID, tenantSlug) { if !profileCanAccessTenant(requester, tItem.ID, tenantSlug) {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"}) results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
continue continue
@@ -1131,7 +1131,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
break 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"}) results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
appointmentFailed = true appointmentFailed = true
break break
@@ -1495,7 +1495,6 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
} }
var requesterRole string var requesterRole string
var manageableSlugs []string
var profile *domain.UserProfileResponse var profile *domain.UserProfileResponse
// [New] Manual profile resolution to support query-param role mocking // [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) slog.Info("🔑 [AUTH] Using mock role from query for export", "role", mockRole)
requesterRole = 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 // 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 // Try to get actual profile if possible to get manageableTenants
p, _ := c.Locals("user_profile").(*domain.UserProfileResponse) p, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if p != nil { if p != nil {
@@ -1525,42 +1524,19 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
requesterRole = profile.Role requesterRole = profile.Role
} }
// [New] Access Control: only admin roles can export // [New] Access Control: only super_admin can export
if requesterRole != domain.RoleSuperAdmin && requesterRole != domain.RoleTenantAdmin { if requesterRole != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for export") 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 // 1. Fetch Users using Repo for efficiency
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug) users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug)
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export") return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
} }
// 2. Filter by manageable tenants if tenant_admin // 2. Data rows
var filtered []domain.User filtered := users
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
}
// 3. Set CSV Headers // 3. Set CSV Headers
c.Set("Content-Type", "text/csv; charset=utf-8") 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) tenantCache := make(map[string]tenantCacheItem)
manageableSlugs := make(map[string]bool) manageableSlugs := make(map[string]bool)
if requester.Role == domain.RoleTenantAdmin { if requester.Role != domain.RoleSuperAdmin {
manageableSlugs = profileTenantAccessKeys(requester) manageableSlugs = profileTenantAccessKeys(requester)
} }
@@ -1724,7 +1700,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
} }
// Authorization check // Authorization check
if requester.Role == domain.RoleTenantAdmin { if requester.Role != domain.RoleSuperAdmin {
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) { if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"}) results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
continue continue
@@ -1858,7 +1834,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
} }
manageableSlugs := make(map[string]bool) manageableSlugs := make(map[string]bool)
if requester.Role == domain.RoleTenantAdmin { if requester.Role != domain.RoleSuperAdmin {
manageableSlugs = profileTenantAccessKeys(requester) manageableSlugs = profileTenantAccessKeys(requester)
} }
@@ -1882,7 +1858,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
} }
// Authorization check // Authorization check
if requester.Role == domain.RoleTenantAdmin { if requester.Role != domain.RoleSuperAdmin {
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) { if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"}) results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"})
continue continue
@@ -1951,7 +1927,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// [New] Check access scope // [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) 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{} allowed := map[string]bool{}
if requester.TenantID != nil { if requester.TenantID != nil {
allowed[strings.ToLower(*requester.TenantID)] = true allowed[strings.ToLower(*requester.TenantID)] = true
@@ -2006,8 +1982,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
*req.Role = role *req.Role = role
} }
// Tenant admins can only move users within tenants they can manage. // All non-superadmins can only move users within tenants they can manage.
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin { if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil { if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil {
targetSlug := strings.TrimSpace(*req.CompanyCode) targetSlug := strings.TrimSpace(*req.CompanyCode)
targetAllowed := profileCanAccessTenant(requester, "", targetSlug) targetAllowed := profileCanAccessTenant(requester, "", targetSlug)
@@ -2017,13 +1993,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
} }
} }
if !targetAllowed { 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) // [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 metadata is namespaced (key is tenant ID), validate each namespace
// If it's flat, validate using schemaCompCode // If it's flat, validate using schemaCompCode
@@ -2360,13 +2336,13 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
} }
var identity *service.KratosIdentity 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) found, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil { if err == nil {
identity = found identity = found
} }
} }
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin { if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
if identity != nil { if identity != nil {
allowed := map[string]bool{} allowed := map[string]bool{}
if requester.TenantID != nil { if requester.TenantID != nil {
@@ -2763,9 +2739,9 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
return // Nothing changed return // Nothing changed
} }
// 1. Handle Role Changes // 1. Handle Role Changes (Super Admin Only)
if oldRole == domain.RoleSuperAdmin { 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 { if oldRole != newRole {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System", Namespace: "System",
@@ -2775,14 +2751,6 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
Action: domain.KetoOutboxActionDelete, 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 // Add new roles
@@ -2796,14 +2764,6 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
Action: domain.KetoOutboxActionCreate, 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) // 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() return c.Next()
} }
// Tenant Admin check // Since only Super Admin is maintained for tenant management, others are rejected here
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()
}
return errorJSON(c, fiber.StatusForbidden, "forbidden") 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) 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) { func TestRequireRole_Success(t *testing.T) {
app := fiber.New() app := fiber.New()
mockAuth := new(MockAuthProvider) mockAuth := new(MockAuthProvider)
config := RBACConfig{ config := RBACConfig{
AllowedRoles: []string{"admin"}, AllowedRoles: []string{domain.RoleSuperAdmin},
AuthHandler: mockAuth, AuthHandler: mockAuth,
} }
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
ID: "user1", ID: "user1",
Role: "admin", Role: domain.RoleSuperAdmin,
}, nil) }, nil)
app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error { app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error {
@@ -95,13 +91,13 @@ func TestRequireRole_SetsUserIDForAuditContext(t *testing.T) {
app := fiber.New() app := fiber.New()
mockAuth := new(MockAuthProvider) mockAuth := new(MockAuthProvider)
config := RBACConfig{ config := RBACConfig{
AllowedRoles: []string{"admin"}, AllowedRoles: []string{domain.RoleSuperAdmin},
AuthHandler: mockAuth, AuthHandler: mockAuth,
} }
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
ID: "user1", ID: "user1",
Role: "admin", Role: domain.RoleSuperAdmin,
}, nil) }, nil)
app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error { app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error {
@@ -124,13 +120,13 @@ func TestRequireRole_PreservesExistingUserID(t *testing.T) {
app := fiber.New() app := fiber.New()
mockAuth := new(MockAuthProvider) mockAuth := new(MockAuthProvider)
config := RBACConfig{ config := RBACConfig{
AllowedRoles: []string{"admin"}, AllowedRoles: []string{domain.RoleSuperAdmin},
AuthHandler: mockAuth, AuthHandler: mockAuth,
} }
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
ID: "profile-user", ID: "profile-user",
Role: "admin", Role: domain.RoleSuperAdmin,
}, nil) }, nil)
app.Use(func(c *fiber.Ctx) error { app.Use(func(c *fiber.Ctx) error {
@@ -157,7 +153,7 @@ func TestRequireRole_Forbidden(t *testing.T) {
app := fiber.New() app := fiber.New()
mockAuth := new(MockAuthProvider) mockAuth := new(MockAuthProvider)
config := RBACConfig{ config := RBACConfig{
AllowedRoles: []string{"admin"}, AllowedRoles: []string{domain.RoleSuperAdmin},
AuthHandler: mockAuth, AuthHandler: mockAuth,
} }
@@ -231,7 +227,7 @@ func TestRequireTenantMatch_Forbidden(t *testing.T) {
tenant1 := "tenant1" tenant1 := "tenant1"
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
ID: "user1", ID: "user1",
Role: domain.RoleTenantAdmin, Role: "user", // Formerly tenant_admin, now mapped to user which is forbidden here for non-superadmin
TenantID: &tenant1, TenantID: &tenant1,
}, nil) }, nil)

View File

@@ -17,31 +17,21 @@ export function ForbiddenMessage({ resourceToken }: Props) {
"You do not have permission to access this resource. Contact your administrator.", "You do not have permission to access this resource. Contact your administrator.",
); );
if (role === "rp_admin") { if (role === "user") {
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 (resourceToken === "consents") { if (resourceToken === "consents") {
explanation = t( explanation = t(
"msg.dev.forbidden.user.consents", "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") { } else if (resourceToken === "audit") {
explanation = t( explanation = t(
"msg.dev.forbidden.user.audit", "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 { } else {
explanation = t( explanation = t(
"msg.dev.forbidden.user.clients", "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) { function isPrivilegedDeveloperRole(profileRole: string) {
return ( return profileRole === "super_admin";
profileRole === "super_admin" ||
profileRole === "tenant_admin" ||
profileRole === "rp_admin"
);
} }
export function resolveDeveloperAccessGate( export function resolveDeveloperAccessGate(

View File

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